feat: TTS-only first run, platform-curated ASR, guided OS permissions, parakeet-mlx
Only the TTS model (~2.4 GB) is required on first run; ASR models are per-platform curated picks (curated_on in models.yaml) installed on demand. Every transcription surface returns a typed asr_model_missing error with a one-click download CTA instead of silently pulling multi-GB Whisper weights. Settings -> Models is a grouped, platform-aware catalog. New guided permissions UX (wizard System Check + Settings -> Permissions + mic pre-flight) with native mic-state checks and OS settings deep-links. New parakeet-mlx engine brings Parakeet TDT v3 to Apple Silicon (language-gated capture preference so multilingual dictation never regresses). Docs: expressive-speech page, Flush/Unload + CPU-fallback triage, clone-length FAQ. Hardening: preflight fails open for custom model pins, ROCm curation no longer inherits NVIDIA picks, Windows mic probe reads the NonPackaged consent key, CaptureWidget setup race fixed, offline-cache CI simulation fixes so empty-cache runners stay green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3a7368cb26
commit
63fd497caf
@@ -63,8 +63,17 @@ jobs:
|
||||
# `uv sync` because smoke only hits /health + fixture profiles.
|
||||
run: uv sync --all-extras
|
||||
|
||||
# HF_HUB_OFFLINE=1 is a recurrence guard, not an optimization: a test
|
||||
# that reaches huggingface.co fails fast and loud instead of silently
|
||||
# downloading model weights mid-suite (the preload_model() Hub-probe
|
||||
# bug pulled the full 2.3 GB k2-fsa/OmniVoice checkpoint into every
|
||||
# networked empty-cache run before it was caught). All legitimate HF
|
||||
# interactions in tests are stubbed; anything that trips this is a
|
||||
# test-isolation bug.
|
||||
- name: Run pytest
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
|
||||
# Docs-drift CI gate (Phase 1 INST-06). The validator extracts code
|
||||
# blocks tagged `<!-- validate -->` from docs/install/*.md and asserts
|
||||
@@ -80,6 +89,8 @@ jobs:
|
||||
# the separate session is kept for cheaper, clearer CI output.
|
||||
- name: Run pytest (backend/tests, isolated)
|
||||
run: uv run pytest backend/tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as tests/
|
||||
|
||||
# Cache ~/.bun/install/cache keyed on bun.lock — `bun install` drops
|
||||
# from ~15 s cold to near-instant on warm cache.
|
||||
@@ -287,3 +298,5 @@ jobs:
|
||||
|
||||
- name: Run smoke tests
|
||||
run: uv run pytest tests/smoke/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
|
||||
|
||||
+39
-26
@@ -8,39 +8,52 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**Highlights**
|
||||
|
||||
- First run is ~2.4 GB, not ~5 GB — only the TTS model is required
|
||||
- Settings → Models redesigned: grouped, platform-aware, one-click "for your system" picks
|
||||
- Guided mic + Accessibility permissions with Open Settings deep-links
|
||||
- Parakeet TDT v3 on Apple Silicon (`parakeet-mlx`)
|
||||
- No more silent multi-GB Whisper downloads — a download prompt instead
|
||||
- First-run analytics consent question, Colab notebook, ROCm Docker image
|
||||
- New docs: expressive speech, Flush/Unload, clone-length FAQ
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings → Models: grouped catalog (TTS / ASR / Dictation / Diarisation), "recommended for this machine" chips, incompatible models collapsed behind a toggle
|
||||
- Only the TTS model (~2.4 GB) is required on first run; ASR picks are curated per platform via `curated_on` in `models.yaml` (MLX on Apple Silicon, CT2+Turbo on CUDA, PyTorch on ROCm, int8 on CPU)
|
||||
|
||||
### Added
|
||||
|
||||
- **OmniVoice now asks — once — whether you'd like to share anonymous usage stats.** The opt-in analytics toggle existed but was buried in Settings, so almost nobody ever saw it. New installs now get one honest question in the first-run wizard (two equal-weight Yes/No buttons, nothing pre-ticked); existing installs see a one-time dismissible banner, where dismissing counts as no. The privacy promise is unchanged: nothing is sent without an explicit yes, skipping means no, and source builds (which ship no analytics destination) never even ask. Saying yes shares content-free product signals — an install ping, version updates, crashes (error class + bucketed uptime, never logs), error *types* (capped and deduplicated), and a single farewell ping if you uninstall via the scripts — all enforced by the same code-level property allowlist as before: never your text, audio, file names, or anything identifying. Change your mind anytime in Settings → Privacy.
|
||||
- Settings → Permissions + wizard System Check: live OS mic/Accessibility grant state, per-OS guidance, Open Settings deep-links; dictation pre-flights the mic grant
|
||||
- `parakeet-mlx` engine: Parakeet TDT v3 on Apple Silicon (25 EU languages, word timestamps, ~2 GB); never auto-downloads
|
||||
- First-run consent question for the existing opt-in analytics (two equal buttons, skip = no; source builds never ask)
|
||||
- Official Google Colab notebook (`notebooks/OmniVoice_Studio_Colab.ipynb`) — full app + API feature tour on a free T4
|
||||
- ROCm Docker image `ghcr.io/debpalash/omnivoice-studio:rocm` (+ `:stable-rocm`, `:X.Y.Z-rocm`) (#1165)
|
||||
|
||||
- **An official "Run on Google Colab" notebook — try OmniVoice without a local GPU.** `notebooks/OmniVoice_Studio_Colab.ipynb` boots the full app (web UI included) on a free Colab T4: it builds the frontend in-notebook with bun, installs the backend with uv while reusing Colab's preinstalled CUDA PyTorch, and opens the UI through Colab's built-in port proxy — no third-party tunnels, no API keys. An optional Colab-Secrets cell enables gated diarization models, and a smoke-test cell generates and plays a first line of speech inline. It then tours the whole feature surface through the backend API, one self-contained cell per feature with inline playback and honest runtime notes: multilingual TTS, zero-shot voice cloning, voice design from a plain-English description, saved voice profiles, a TTS→ASR transcription round trip, AI-watermark detection (generated vs. plain audio), the OpenAI-compatible `/v1` API via the official `openai` client, a two-voice story, a chaptered m4b audiobook, and an optional miniature video dub (English→Spanish) with vocal-isolation stems. The Open-in-Colab badge lives in the README.
|
||||
### Docs
|
||||
|
||||
- **A Docker image for AMD GPUs: `ghcr.io/debpalash/omnivoice-studio:rocm`.** The Docker image was CUDA-only, so AMD cards (an RX 7900 XTX under Podman, say) silently ran on CPU. Every preview and release now also ships a ROCm variant — `:rocm` is the rolling preview, with `:stable-rocm` / `:X.Y.Z-rocm` mirroring the release tags on GHCR and Docker Hub alike. Pass the GPU through with `--device /dev/kfd --device /dev/dri` (works for Docker and Podman/Quadlet; no container toolkit needed) and it's auto-detected; a new `rocm` profile in the Compose file does the same. (#1165)
|
||||
- `docs/expressive-speech.md`: per-engine breaths/laughter/emotion control, incl. the default engine's 13 native reaction tags
|
||||
- Flush caches / Unload documented in the performance guide, incl. `POST /system/flush-memory` for scripts
|
||||
- README FAQ: why a longer reference clip doesn't clone better (zero-shot 15 s cap; fine-tuning is the audiobook-grade path)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **FunASR/SenseVoice transcription no longer crashes or swaps speaker identities across 30-second chunks when inline diarization is enabled.** The pinned FunASR 1.3.1 release defaults CAM++ to a punctuation-dependent segmentation mode, while OmniVoice intentionally does not load a second punctuation model — so the first transcription failed inside FunASR before returning any text. Separately, CAM++ cluster IDs are local to each `generate()` call, so externally chunking a long recording could reuse the same label for different people. The backend now uses VAD speaker segments, requests SenseVoice timestamps, accepts the release's `sentence` output field, and lets FunASR's internal VAD process the complete recording in one bounded call so dubbing receives globally consistent timed speaker turns. (#182)
|
||||
|
||||
- **Every synthetic-audio output now carries the invisible provenance watermark — no matter which door it leaves through.** The OpenAI-compatible `/v1/audio/speech` endpoint returned unwatermarked audio while `/generate` marked the same text, and an audit found the same gap in streaming TTS, batch dubs, audiobooks/stories, dub segment previews, and archetype previews — a real compliance problem with EU AI Act Art. 50(2) (machine-readable marking of synthetic audio) applying from 2 August 2026. All producers now route through one `mark_synthetic` chokepoint at the tensor stage, guarded by per-route detection tests plus a structural test so a future audio route can't ship unmarked; the Settings toggle behaves exactly as before, and audio rendered while the toggle was off is never passed off as marked. The opt-in SoniTranslate sidecar synthesizes and muxes outside this pipeline and remains a documented exception. (#1169)
|
||||
|
||||
- **"Can't reach the local OmniVoice backend" finally tells the real story outside the desktop app.** Backend deaths (out-of-memory kills, hard crashes) were only diagnosable through the desktop shell's crash markers — a `bun run dev` browser session, a Docker deployment, or a LAN-share client got the same vague error with zero evidence. The backend now keeps a run sentinel and, on its next start, reports an unclean death as the same crash notice desktop users get (with the death window, what it was doing, and a scrubbed log tail — attached automatically to bug reports). The error itself is now honest about your deployment: it says whether the backend *was answering and stopped* or *never answered at all*, and points at the `bun run dev` terminal / `omnivoice.log` in dev or `docker logs`/`journalctl` on a server instead of "restart the app". Dev runs also print a loud exit banner (exit code, log tail, OOM hint) the moment uvicorn dies. (#1164)
|
||||
|
||||
- **The "Setup failed" screen no longer crashes instead of appearing.** A variable-ordering bug in the splash screen threw a JavaScript error exactly (and only) when first-run setup failed — replacing the one screen whose job is to explain the failure with a blank crash. Fixed, and the failed card is now covered by a rendering test so it can't silently break again. Thanks @bultodepapas! (#1159)
|
||||
|
||||
- **Backend errors keep their stack traces in the log.** When persisting a dub job failed, the log recorded only a one-line summary — the traceback that says *why* was discarded, making reports undiagnosable. Fixed there (thanks @bultodepapas!), and the same fix was swept across 19 more error logs backend-wide: diarization and transcription crashes, dubbing audio-mix and Smart Fit fallbacks, dictation failures, and more now log the full trace. (#1160)
|
||||
|
||||
- **A stuck audio probe can no longer freeze reference-clip uploads.** Picking a reference clip for voice cloning reads its duration through the browser's media element — and a media element that never answers left the upload hanging forever. The probe now times out after 10 seconds, and clips whose codec the app's webview can't decode are still accepted as before (the backend decodes them with ffmpeg), now locked in by tests. Thanks @bultodepapas! (#1162)
|
||||
|
||||
- **A malformed EPUB chapter now imports partially instead of vanishing.** If parsing a chapter's HTML failed midway, the failure was silent; the proposed fix would have silently dropped the whole chapter from the audiobook instead. Now the import keeps every word extracted before the failure and logs exactly which entry broke — a truncated chapter you can spot beats a missing one you can't. Thanks @bultodepapas! (#1161)
|
||||
|
||||
- **Sidebar data that fails to load now says so in the console.** If fetching your voices, history, dub history, projects, or exports failed, the app silently showed the previous (or an empty) list — indistinguishable from actually having nothing. Each of those failures is now logged with which fetch broke, so "my voices vanished" reports carry a cause. Thanks @bultodepapas! (#1158)
|
||||
|
||||
- **A missing (or broken) MCP dependency can no longer kill the whole backend at startup.** One Windows user's backend loaded all 32 models and then died with exit code 1 because the `mcp` package couldn't be imported — the MCP integration called "exit the program" and a technicality (`SystemExit` isn't an `Exception`) let it slip past the guard meant to make MCP optional. The MCP layer now degrades to "/mcp disabled" on any import failure, the error names what actually failed (the package can be present but broken — e.g. pywin32 on Windows — and "not installed" was a misdiagnosis), and the exit-containment now covers this whole class. (#1156)
|
||||
|
||||
- **The "Setup failed" screen dismisses itself when the backend comes back — and relaunching the app is now a retry, not a dead end.** If the backend died at startup, reopening the app just refocused the dead window, and even when the backend recovered on its own the failed card stayed up until you manually reloaded. Now: launching the app again while setup is failed re-runs the same recovery as the Retry button, and the failed screen quietly polls the backend and jumps into the app the moment it answers. (#1156)
|
||||
|
||||
- **Ended the `forrtl: error (200)` mid-session backend crashes on Windows.** Several crash reports shared one cryptic signature: the backend aborted with exit code 2 during normal work (transcribing, downloading models). The culprit is a console handler inside the math runtime that torch/numpy ship — when Windows delivered a console close/logoff event to the backend, that handler killed the process on the spot. The backend now runs with no console attached at all, plus the runtime's handler is disabled outright, in the app and in from-source runs alike. (#1153)
|
||||
|
||||
- **Non-Latin text can't crash synthesis on Windows anymore.** Generating with text like Vietnamese `ả` failed with `400 Bad Request: 'charmap' codec can't encode character…` — a voice engine prints the text it's synthesizing, and the backend's Windows console encoding (cp1252) couldn't represent it. The backend's output streams are now forced to UTF-8, the print-guard swallows encoding errors (logs are best-effort; your audio is not), and the desktop app runs the backend in UTF-8 mode across the board. (#1155)
|
||||
- TTS-only installs get a one-click ASR download prompt instead of silent 1.6–3 GB Whisper pulls (dub, batch, dictation, clone-ref, `/v1` STT, boot warm-up)
|
||||
- FunASR/SenseVoice: no more crash or speaker-identity swaps across 30 s chunks with inline diarization (#182)
|
||||
- Provenance watermark now applied on every synthetic-audio route (`/v1/audio/speech`, streaming, batch dubs, audiobooks, previews) via one chokepoint (#1169)
|
||||
- "Can't reach the local backend" now reports unclean backend deaths with evidence, in dev/Docker/LAN too (#1164)
|
||||
- "Setup failed" screen renders instead of crashing — thanks @bultodepapas! (#1159)
|
||||
- Backend error logs keep stack traces (swept across 20 sites) — thanks @bultodepapas! (#1160)
|
||||
- Reference-clip uploads can't hang on a stuck audio probe (10 s timeout) — thanks @bultodepapas! (#1162)
|
||||
- Malformed EPUB chapters import partially instead of vanishing — thanks @bultodepapas! (#1161)
|
||||
- Failed sidebar fetches are logged instead of silently showing stale/empty lists — thanks @bultodepapas! (#1158)
|
||||
- A missing/broken `mcp` package degrades to "/mcp disabled" instead of killing the backend at startup (#1156)
|
||||
- "Setup failed" auto-dismisses when the backend recovers; relaunching the app retries instead of refocusing a dead window (#1156)
|
||||
- Fixed `forrtl: error (200)` mid-session backend crashes on Windows (console-handler kill in the bundled math runtime) (#1153)
|
||||
- Non-Latin text can't crash synthesis on Windows (backend forced to UTF-8) (#1155)
|
||||
- App boot no longer silently downloads the ~2.3 GB TTS model when it isn't installed — warm-up loads from the local cache only, and startup makes no Hugging Face calls
|
||||
- Quitting with a batch dub in flight can't hang shutdown anymore (the batch worker now honors cancellation)
|
||||
|
||||
- **Video export errors now tell you what actually went wrong.** A failed export could claim your "filename is too long" (it was `F:\video.mp4`) and suggest checking whether FFmpeg is installed (it was, and had worked before). The real cause — the assembled ffmpeg command exceeding Windows' 32,767-character command-line limit on exports with many tracks/segments — is now both diagnosed honestly and largely avoided: oversized filter graphs are handed to ffmpeg via a script file instead of the command line. Each failure mode (command too long / ffmpeg missing / ffmpeg reported an error) now gets its own advice instead of everyone getting all of it. (#1152)
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ For anything new: prefer what's already pinned in `pyproject.toml` / `frontend/p
|
||||
|
||||
**Docs-sync (hard rule, owner-set 2026-06-11):** any change that alters something these docs describe — README.md, CONTRIBUTING.md, SECURITY.md, SUPPORT.md, LICENSE, or `docs/**` (install flows, Docker tag semantics, platform support, versioning/release behavior, review process, supported versions) — must update those docs **in the same PR** as the change. If a doc impact is discovered after merge, the docs fix is the immediate next commit, not backlog. Stale docs are treated as bugs.
|
||||
|
||||
**Release notes / changelog (hard rule, owner-set 2026-06-16):** every tagged release gets a **high-quality, user-facing `## [X.Y.Z] — DATE` section in `CHANGELOG.md`** before (or in the same hour as) the tag — never the "Auto-generated release for vX.Y.Z…" fallback. `release.yml` extracts that section verbatim as the GitHub Release body (the `Extract CHANGELOG section for tag` step), so a missing/empty section ships a bare release. Quality bar = the existing house style: a one-paragraph headline, then `### Added` / `### Fixed` / `### Changed` / `### License` / `### CI` subsections; each entry is a **bold one-line lead** (what the user gets), 1–3 lines of plain-English why, and the `(#NNN)` issue/PR ref — grouped by theme, written for users, **not** raw commit dumps. This applies to **preview builds too**: preview release notes summarize what's new on `main` since the last stable, in the same style. Workflow: as features merge, keep `## [Unreleased]` current; at release time rename it to the version + date. If a release was already cut with the fallback body, the next action is to backfill `CHANGELOG.md` **and** `gh release edit <tag>` the live body — not backlog.
|
||||
**Release notes / changelog (hard rule, owner-set 2026-06-16):** every tagged release gets a **high-quality, user-facing `## [X.Y.Z] — DATE` section in `CHANGELOG.md`** before (or in the same hour as) the tag — never the "Auto-generated release for vX.Y.Z…" fallback. `release.yml` extracts that section verbatim as the GitHub Release body (the `Extract CHANGELOG section for tag` step), so a missing/empty section ships a bare release. Quality bar (owner-restyled 2026-07-17, replaces the old bold-lead paragraphs): **quiet and scannable** — a short `**Highlights**` bullet list first (plain words, one line each), then `### Changed` / `### Added` / `### Docs` / `### Fixed` / `### License` / `### CI` subsections where each entry is a **single one-liner** with the `(#NNN)` issue/PR ref and contributor credit (`— thanks @user!`) where applicable. Written for users, grouped by theme, no multi-line paragraphs, **not** raw commit dumps. This applies to **preview builds too**: preview release notes summarize what's new on `main` since the last stable, in the same style. Workflow: as features merge, keep `## [Unreleased]` current; at release time rename it to the version + date. If a release was already cut with the fallback body, the next action is to backfill `CHANGELOG.md` **and** `gh release edit <tag>` the live body — not backlog.
|
||||
|
||||
**Localization (hard rule):** No hardcoded non-English (CJK) **user-facing text** anywhere in the codebase except the translation layer (`frontend/src/i18n/`). All UI strings go through i18n (`t('...')` keys in `locales/*.json`); native language names live in `i18n/index.ts` (`LANGUAGES`). Functional CJK is allowed and tracked via the allowlist in `tests/test_no_hardcoded_cjk.py` — text-processing regexes, model/engine vocabulary & identifiers (e.g. CosyVoice speaker IDs), localized error matching, demo/eval data, and test fixtures. CI fails on any hardcoded CJK outside the allowlist; to add legitimate functional CJK, extend `_ALLOWED_FILES` there with a justification.
|
||||
|
||||
|
||||
@@ -180,6 +180,8 @@ Pick your OS and follow the guide end-to-end:
|
||||
|
||||
Feels slow? [docs/performance.md](docs/performance.md) covers where generation time actually goes, the tuning knobs, and the three classic causes of "it got slow".
|
||||
|
||||
Want breaths, laughter, pauses, whispering, or emotion in the output? [docs/expressive-speech.md](docs/expressive-speech.md) covers exactly what each engine can do today — and what's spec'd but not shipped yet.
|
||||
|
||||
> Coming from **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)** (now archived)? There's a dedicated migration guide: [docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md).
|
||||
|
||||
<details>
|
||||
@@ -301,10 +303,10 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
|
||||
### 🎧 ASR Engines
|
||||
|
||||
**10 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Settings → Engines**. Nine run fully on-device; the tenth (OpenAI-compatible) is an optional remote client for Qwen3-ASR or any compatible server.
|
||||
**11 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**. Ten run fully on-device; the eleventh (OpenAI-compatible) is an optional remote client for Qwen3-ASR or any compatible server.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full lineup</b> — 10 engines, what each is best at, and compute-type notes</summary>
|
||||
<summary><b>📊 The full lineup</b> — 11 engines, what each is best at, and compute-type notes</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -316,6 +318,7 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Settings → Models** and dictation prefers it automatically when your system language is one of its 25 (European) languages; other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses. |
|
||||
| **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**. |
|
||||
@@ -416,7 +419,7 @@ Ships two [skills](https://skills.sh): **`omnivoice`** — speak and transcribe
|
||||
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation |
|
||||
| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
|
||||
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
|
||||
| **ASR** | 9 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation), crash-isolated subprocess backend |
|
||||
| **ASR** | 10 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation), crash-isolated subprocess backend |
|
||||
| **TTS** | 14 engines (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
|
||||
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
|
||||
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
|
||||
@@ -529,7 +532,7 @@ Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, transl
|
||||
<br/>
|
||||
Honest answer: <b>it depends on what you're doing.</b>
|
||||
|
||||
<b>Where OmniVoice is genuinely competitive:</b> voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (14 TTS engines, 10 ASR engines, your choice of translation).
|
||||
<b>Where OmniVoice is genuinely competitive:</b> voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (14 TTS engines, 11 ASR engines, your choice of translation).
|
||||
|
||||
<b>Where ElevenLabs still wins:</b> out-of-the-box consistency and polish, especially for English TTS. Their one model is heavily tuned; our quality depends on which engine you pick, your hardware, and — for cloning — the reference audio (a dry, close-mic clip clones dramatically better than a noisy or echoey one).
|
||||
|
||||
@@ -538,6 +541,16 @@ Honest answer: <b>it depends on what you're doing.</b>
|
||||
Try it on your real material — it's free and takes one download. Many users replace ElevenLabs outright; some keep both. Both outcomes are fine with us.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why doesn't a longer reference clip sound more like me?</b></summary>
|
||||
<br/>
|
||||
Because OmniVoice's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on at generation time — it is never trained on. Feeding it 2 hours doesn't teach it your voice; past a short window the extra audio is simply not used. The dubbing pipeline's reference builder targets ~8 s and hard-caps at 15 s (<code>backend/services/speaker_clone.py</code>), and engines cap the prompt themselves (VoxCPM2 trims references to 30 s). This is different from ElevenLabs <i>Professional</i> Voice Cloning, which fine-tunes a model on hours of your audio — that's a training job, not a bigger prompt.
|
||||
|
||||
<b>What actually moves clone quality is the clip, not its length.</b> Zero-shot cloning mirrors the acoustics and delivery of the prompt, so: record 5–15 seconds (~8 s is the sweet spot) of continuous natural speech, close to the mic, in a quiet room with no reverb or music — an echoey clip clones echoey. One speaker only, and read in the tone and pace you want the output to have, because the clone copies your delivery, not just your timbre. Recording a few candidate clips and comparing results beats any amount of extra footage.
|
||||
|
||||
<b>Want audiobook-grade, trained-on-your-voice fidelity?</b> That path exists, but it's offline fine-tuning, not an in-app button: prepare a dataset of your recordings (<a href="docs/data_preparation.md">docs/data_preparation.md</a>) and fine-tune the bundled checkpoint via <code>init_from_checkpoint</code> (<a href="docs/training.md">docs/training.md</a>). Fair warning — it's a technical, command-line workflow that needs a capable GPU and hours of transcribed audio. In-app fine-tuning / long-reference "professional" cloning is on the <a href="docs/ROADMAP.md">roadmap</a> as research only; no promised date.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
|
||||
<br/>
|
||||
|
||||
@@ -85,6 +85,11 @@ if IS_MAC_ARM:
|
||||
# do NOT collect_all() mlx because that double-registers mlx.core with
|
||||
# nanobind and the binary aborts on the first mlx.core touch.
|
||||
hiddenimports.append('mlx_whisper')
|
||||
# Parakeet TDT v3 ASR (services.asr_backend.ParakeetMLXBackend) — imported
|
||||
# lazily at is_available()/transcribe time, so the tracer misses it. Same
|
||||
# rule as mlx_whisper: list the package, never collect_all() anything that
|
||||
# touches nanobind-registered mlx.core.
|
||||
hiddenimports.append('parakeet_mlx')
|
||||
# mlx-audio engine multiplexer — Kokoro / CSM / Dia / Qwen3-TTS /
|
||||
# Chatterbox / MeloTTS / OuteTTS / … — gives mac-ARM users a rich
|
||||
# engine picker. Like mlx_whisper it's mac-ARM-only; also like
|
||||
|
||||
@@ -76,8 +76,16 @@ async def _worker():
|
||||
job_id, job["finished_at"] - job["started_at"],
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Task cancellation always means SHUTDOWN: the job-level cancel
|
||||
# endpoint only flips job["status"] — nothing ever cancels this
|
||||
# task to abort a single job. Swallowing the CancelledError here
|
||||
# made the worker unkillable (the while-loop re-entered
|
||||
# _queue.get() and event-loop teardown hung forever in
|
||||
# _cancel_all_tasks waiting on a task that never finishes). Mark
|
||||
# the in-flight job, then let the cancellation propagate.
|
||||
job["status"] = "cancelled"
|
||||
job["finished_at"] = time.time()
|
||||
raise
|
||||
except Exception as e:
|
||||
job["status"] = "failed"
|
||||
# plan-04 (#131): guaranteed non-empty, structured reason.
|
||||
@@ -448,6 +456,14 @@ async def enqueue_batch_job(
|
||||
if not lang_list:
|
||||
raise HTTPException(400, "At least one target language is required")
|
||||
|
||||
# TTS-only install: no ASR model on disk → typed 409 with a download CTA
|
||||
# now, instead of accepting the job and having the transcribe stage
|
||||
# silently auto-download multi-GB whisper weights (or fail) in the worker.
|
||||
from services.asr_backend import asr_model_missing_detail, asr_model_missing_error
|
||||
missing = await asyncio.to_thread(asr_model_missing_error)
|
||||
if missing is not None:
|
||||
raise HTTPException(409, {**missing, "message": asr_model_missing_detail(missing)})
|
||||
|
||||
# Save the uploaded video
|
||||
batch_dir = os.path.join(DATA_DIR, "batch")
|
||||
os.makedirs(batch_dir, exist_ok=True)
|
||||
|
||||
@@ -79,6 +79,20 @@ async def transcribe_audio(
|
||||
|
||||
use_accurate = (mode or "").strip().lower() == "accurate"
|
||||
|
||||
# TTS-only install: no ASR model on disk → typed 409 with a download
|
||||
# CTA, BEFORE any backend is constructed (the whisper backends
|
||||
# auto-download multi-GB weights from HF on first load).
|
||||
from services.asr_backend import asr_model_missing_detail, asr_model_missing_error
|
||||
missing = await asyncio.to_thread(
|
||||
asr_model_missing_error,
|
||||
purpose="transcribe" if use_accurate else "dictation",
|
||||
)
|
||||
if missing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**missing, "message": asr_model_missing_detail(missing)},
|
||||
)
|
||||
|
||||
def _run():
|
||||
if use_accurate:
|
||||
# Accurate mode: full WhisperX with forced alignment —
|
||||
|
||||
@@ -157,6 +157,26 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
# run the dedicated low-latency handler. Otherwise fall through to the
|
||||
# legacy Whisper/WebM path, byte-for-byte unchanged.
|
||||
spec = _select_sherpa_spec(websocket)
|
||||
|
||||
# TTS-only install: no ASR model on disk for this session's selection →
|
||||
# typed error frame + close, BEFORE any recognizer is built (both the
|
||||
# sherpa loader and the whisper backends auto-download weights on first
|
||||
# load). The client renders a one-click download CTA from the payload.
|
||||
from services.asr_backend import asr_model_missing_detail, asr_model_missing_error
|
||||
missing = await asyncio.to_thread(
|
||||
asr_model_missing_error, purpose="dictation",
|
||||
sherpa_model_id=spec.id if spec is not None else None,
|
||||
)
|
||||
if missing is not None:
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "error", "kind": "asr_model_missing",
|
||||
"message": asr_model_missing_detail(missing), **missing,
|
||||
})
|
||||
await websocket.close()
|
||||
except Exception: # noqa: BLE001 — client may already be gone
|
||||
pass
|
||||
return
|
||||
if spec is not None:
|
||||
from services.asr_backend import SherpaDictationBackend, capture_lease
|
||||
ok, _reason = SherpaDictationBackend.is_available()
|
||||
|
||||
@@ -441,6 +441,9 @@ async def dub_transcribe_stream(
|
||||
job = _get_job(job_id)
|
||||
|
||||
preflight_error: Optional[str] = None
|
||||
# Extra machine-readable fields merged into the preflight `error` SSE event
|
||||
# (e.g. the typed asr_model_missing payload → download-CTA in the UI).
|
||||
preflight_payload: Optional[dict] = None
|
||||
asr_audio_target: Optional[str] = None
|
||||
_asr_backend = None
|
||||
scene_cuts: list = []
|
||||
@@ -486,31 +489,57 @@ async def dub_transcribe_stream(
|
||||
if not asr_audio_target or not os.path.exists(asr_audio_target):
|
||||
preflight_error = "No audio available for transcription."
|
||||
else:
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
try:
|
||||
# The PyTorch-Whisper backend lazily builds its own pipeline
|
||||
# when no preloaded `_asr_pipe` is present (issue #255), so it
|
||||
# no longer needs OMNIVOICE_PRELOAD_TTS_ASR=1.
|
||||
_asr_backend = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
# Eagerly load the model HERE so a real load failure (e.g.
|
||||
# WhisperX: missing weights, CTranslate2/cuDNN mismatch, the
|
||||
# torch-2.6 weights-only VAD regression) surfaces once, with
|
||||
# its actual cause, as a clean preflight `error` event —
|
||||
# instead of being buried in N cryptic per-chunk failures
|
||||
# and retried on every chunk (#578). Run in a thread so the
|
||||
# (blocking) load doesn't stall the event loop.
|
||||
_ensure_loaded = getattr(_asr_backend, "ensure_loaded", None)
|
||||
if callable(_ensure_loaded):
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
_gpu_pool, _ensure_loaded
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("transcribe preflight: ASR load failed (job=%s)", job_id)
|
||||
from core.failure import build_failure
|
||||
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
|
||||
preflight_error = "ASR backend initialization failed: " + f["reason"] + (
|
||||
f" — {f['hint']}" if f.get("hint") else ""
|
||||
from services.asr_backend import (
|
||||
active_backend_id,
|
||||
asr_model_missing_detail,
|
||||
asr_model_missing_error,
|
||||
get_active_asr_backend,
|
||||
)
|
||||
# TTS-only install: no ASR model on disk. Bail BEFORE any
|
||||
# backend is constructed/loaded — the whisper backends would
|
||||
# otherwise silently auto-download multi-GB weights from HF.
|
||||
# Typed payload → the UI renders a one-click download CTA.
|
||||
# A preloaded `_asr_pipe` only substitutes for the
|
||||
# *pytorch-whisper* backend (its sole consumer) — any other
|
||||
# active backend still loads its own weights, so the preflight
|
||||
# must run for them even when the pipe is present.
|
||||
_missing = None
|
||||
_skip_preflight = (
|
||||
getattr(_model, "_asr_pipe", None) is not None
|
||||
and active_backend_id() == "pytorch-whisper"
|
||||
)
|
||||
if not _skip_preflight:
|
||||
_missing = await asyncio.get_running_loop().run_in_executor(
|
||||
None, asr_model_missing_error
|
||||
)
|
||||
if _missing is not None:
|
||||
preflight_error = asr_model_missing_detail(_missing)
|
||||
preflight_payload = _missing
|
||||
if _missing is None:
|
||||
try:
|
||||
# The PyTorch-Whisper backend lazily builds its own pipeline
|
||||
# when no preloaded `_asr_pipe` is present (issue #255), so it
|
||||
# no longer needs OMNIVOICE_PRELOAD_TTS_ASR=1.
|
||||
_asr_backend = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
# Eagerly load the model HERE so a real load failure (e.g.
|
||||
# WhisperX: missing weights, CTranslate2/cuDNN mismatch, the
|
||||
# torch-2.6 weights-only VAD regression) surfaces once, with
|
||||
# its actual cause, as a clean preflight `error` event —
|
||||
# instead of being buried in N cryptic per-chunk failures
|
||||
# and retried on every chunk (#578). Run in a thread so the
|
||||
# (blocking) load doesn't stall the event loop.
|
||||
_ensure_loaded = getattr(_asr_backend, "ensure_loaded", None)
|
||||
if callable(_ensure_loaded):
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
_gpu_pool, _ensure_loaded
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("transcribe preflight: ASR load failed (job=%s)", job_id)
|
||||
from core.failure import build_failure
|
||||
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
|
||||
preflight_error = "ASR backend initialization failed: " + f["reason"] + (
|
||||
f" — {f['hint']}" if f.get("hint") else ""
|
||||
)
|
||||
scene_cuts = job.get("scene_cuts") or []
|
||||
|
||||
async def _gen_body():
|
||||
@@ -521,7 +550,8 @@ async def dub_transcribe_stream(
|
||||
# `data`); if that native error wins, the client falls back to the
|
||||
# misleading generic "stream dropped … ASR backend failed" message
|
||||
# and the real cause (in `detail`) is lost (#578).
|
||||
yield _sse_event("error", {"detail": preflight_error, "retryable": True})
|
||||
yield _sse_event("error", {"detail": preflight_error, "retryable": True,
|
||||
**(preflight_payload or {})})
|
||||
yield _sse_event("done", {})
|
||||
return
|
||||
import math
|
||||
@@ -1224,6 +1254,25 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
|
||||
# having offload_tts_for_asr free it) was pure cost.
|
||||
_model = await get_model() if should_preload_tts_asr() else None
|
||||
|
||||
# TTS-only install: no ASR model on disk → typed 409 with a download CTA,
|
||||
# BEFORE any backend is constructed (the whisper backends auto-download
|
||||
# multi-GB weights from HF on first load). Same gate as the SSE preflight:
|
||||
# a preloaded `_asr_pipe` only substitutes for the *pytorch-whisper*
|
||||
# backend (its sole consumer), so it only skips the preflight there.
|
||||
from services.asr_backend import (
|
||||
active_backend_id,
|
||||
asr_model_missing_detail,
|
||||
asr_model_missing_error,
|
||||
)
|
||||
if not (getattr(_model, "_asr_pipe", None) is not None
|
||||
and active_backend_id() == "pytorch-whisper"):
|
||||
missing = await asyncio.to_thread(asr_model_missing_error)
|
||||
if missing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**missing, "message": asr_model_missing_detail(missing)},
|
||||
)
|
||||
|
||||
def _transcribe():
|
||||
|
||||
asr_audio_target = job.get("vocals_path")
|
||||
|
||||
@@ -1259,6 +1259,16 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
|
||||
if not segments:
|
||||
raise HTTPException(status_code=400, detail="Job has no segments")
|
||||
|
||||
# TTS-only install: no ASR model on disk → typed 409 with a download CTA,
|
||||
# BEFORE any backend load could silently auto-download whisper weights.
|
||||
from services.asr_backend import asr_model_missing_detail, asr_model_missing_error
|
||||
missing = await asyncio.to_thread(asr_model_missing_error)
|
||||
if missing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**missing, "message": asr_model_missing_detail(missing)},
|
||||
)
|
||||
|
||||
def _recognize():
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
backend = get_active_asr_backend()
|
||||
|
||||
@@ -20,6 +20,7 @@ Reference: https://platform.openai.com/docs/api-reference/audio
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@@ -440,7 +441,18 @@ async def create_transcription(
|
||||
),
|
||||
):
|
||||
"""Transcribe audio to text. Compatible with OpenAI's POST /v1/audio/transcriptions."""
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
from services.asr_backend import (
|
||||
asr_model_missing_detail,
|
||||
asr_model_missing_error,
|
||||
get_active_asr_backend,
|
||||
)
|
||||
|
||||
# TTS-only install: no ASR model on disk → actionable 409 (string detail —
|
||||
# OpenAI-compat clients expect plain messages), BEFORE any backend load
|
||||
# could silently auto-download multi-GB whisper weights.
|
||||
missing = await asyncio.to_thread(asr_model_missing_error)
|
||||
if missing is not None:
|
||||
raise HTTPException(status_code=409, detail=asr_model_missing_detail(missing))
|
||||
|
||||
# Write uploaded file to a temp location
|
||||
suffix = os.path.splitext(file.filename or "audio.wav")[1] or ".wav"
|
||||
|
||||
@@ -91,16 +91,36 @@ def get_model_catalog() -> ModelCatalog:
|
||||
# ── Platform Detection ─────────────────────────────────────────────────────
|
||||
|
||||
def _current_platform_tags() -> list[str]:
|
||||
"""Return platform tags that the current host supports."""
|
||||
"""Return platform tags that the current host supports.
|
||||
|
||||
Beyond the OS/arch tags, emits the acceleration family so both the
|
||||
``platforms`` gate and the ``curated_on`` recommendation field can key on
|
||||
it: ``cuda`` (NVIDIA — also present on ROCm hosts, where torch reports
|
||||
CUDA available, so existing ``platforms: [cuda]`` entries keep working),
|
||||
``rocm`` (AMD HIP builds), and ``cpu`` (no GPU acceleration at all —
|
||||
Apple Silicon is NOT tagged cpu; it curates via ``darwin-arm64``).
|
||||
"""
|
||||
tags = [sys.platform]
|
||||
arch = _platform.machine()
|
||||
tags.append(f"{sys.platform}-{arch}")
|
||||
has_gpu = False
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
tags.append("cuda")
|
||||
has_gpu = True
|
||||
# ROCm torch masquerades through the CUDA API (torch.version.hip
|
||||
# set, torch.cuda.is_available() True when the AMD GPU is usable).
|
||||
# Grant 'rocm' only when BOTH hold: a ROCm *build* on a host whose
|
||||
# AMD GPU isn't actually visible must curate as CPU, not as a
|
||||
# working ROCm host.
|
||||
if getattr(torch.version, "hip", None):
|
||||
tags.append("rocm")
|
||||
except Exception:
|
||||
pass
|
||||
is_apple_silicon = sys.platform == "darwin" and arch == "arm64"
|
||||
if not has_gpu and not is_apple_silicon:
|
||||
tags.append("cpu")
|
||||
return tags
|
||||
|
||||
|
||||
@@ -112,6 +132,30 @@ def _model_supported(model: dict) -> bool:
|
||||
return bool(set(plats) & set(_current_platform_tags()))
|
||||
|
||||
|
||||
def _model_curated(model: dict, tags: "set[str] | None" = None) -> bool:
|
||||
"""True when this model is a curated "best for your system" pick here.
|
||||
|
||||
Driven by the ``curated_on`` field in models.yaml (``all`` matches every
|
||||
host). Required models are always curated — the preset must include them.
|
||||
"""
|
||||
if model.get("required"):
|
||||
return True
|
||||
curated_on = model.get("curated_on") or []
|
||||
if "all" in curated_on:
|
||||
return True
|
||||
if tags is None:
|
||||
tags = set(_current_platform_tags())
|
||||
# A ROCm host also carries the 'cuda' tag (HIP masquerades through the
|
||||
# CUDA API; the tag keeps `platforms: [cuda]` support-gates working). For
|
||||
# *curation* ignore it: `curated_on: [cuda]` means NVIDIA-tuned picks —
|
||||
# sweeping them into the AMD preset recommended models that are slow or
|
||||
# broken there. Entries that want AMD list 'rocm' explicitly (the CT2
|
||||
# large-v3 already does).
|
||||
if "rocm" in tags:
|
||||
tags = tags - {"cuda"}
|
||||
return bool(set(curated_on) & tags)
|
||||
|
||||
|
||||
# ── HF Cache Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def hf_cache_dir() -> str:
|
||||
@@ -432,6 +476,7 @@ def list_models():
|
||||
cached_by_repo = _scan_cache_on_disk()
|
||||
|
||||
out = []
|
||||
host_tags = set(_current_platform_tags())
|
||||
for m in KNOWN_MODELS:
|
||||
cached = cached_by_repo.get(m["repo_id"])
|
||||
on_disk = cached is not None and cached["size_on_disk"] > 0
|
||||
@@ -446,6 +491,9 @@ def list_models():
|
||||
"size_on_disk_bytes": cached["size_on_disk"] if cached else 0,
|
||||
"nb_files": cached["nb_files"] if cached else 0,
|
||||
"supported": _model_supported(m),
|
||||
# Curated "best for your system" pick (curated_on in models.yaml) —
|
||||
# drives the recommended badge in the wizard and Settings model store.
|
||||
"curated": _model_curated(m, host_tags),
|
||||
})
|
||||
response = {
|
||||
"models": out,
|
||||
@@ -463,18 +511,21 @@ def list_models():
|
||||
|
||||
@router.get("/setup/recommendations")
|
||||
def recommendations():
|
||||
"""Return a curated model preset for the caller's device + architecture."""
|
||||
"""Return a curated model preset for the caller's device + architecture.
|
||||
|
||||
Data-driven from the ``curated_on`` field in models.yaml — adding or
|
||||
retargeting a curated pick is a catalog edit, not a code change. Only the
|
||||
TTS model is required; the ASR picks here are the optional "best for your
|
||||
system" set the wizard and Settings surface for on-demand install.
|
||||
"""
|
||||
is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64"
|
||||
is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64"
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
has_cuda = False
|
||||
try:
|
||||
import torch
|
||||
has_cuda = bool(torch.cuda.is_available())
|
||||
except Exception:
|
||||
pass
|
||||
tags = set(_current_platform_tags())
|
||||
has_cuda = "cuda" in tags and "rocm" not in tags
|
||||
has_rocm = "rocm" in tags
|
||||
|
||||
# Device label — used as the card title.
|
||||
if is_mac_arm:
|
||||
@@ -482,50 +533,47 @@ def recommendations():
|
||||
elif is_mac_intel:
|
||||
device_label = "macOS Intel (x86_64)"
|
||||
elif is_windows:
|
||||
device_label = "Windows x64" + (" + CUDA" if has_cuda else "")
|
||||
device_label = "Windows x64" + (" + CUDA" if has_cuda else " + ROCm" if has_rocm else "")
|
||||
elif is_linux:
|
||||
device_label = "Linux x64" + (" + CUDA" if has_cuda else "")
|
||||
device_label = "Linux x64" + (" + CUDA" if has_cuda else " + ROCm" if has_rocm else "")
|
||||
else:
|
||||
device_label = f"{sys.platform} / {_platform.machine()}"
|
||||
|
||||
# Pick the preset for this device.
|
||||
# Curated preset for this host, in catalog order (required entries lead).
|
||||
curated = [
|
||||
m for m in KNOWN_MODELS
|
||||
if _model_curated(m, tags) and _model_supported(m)
|
||||
]
|
||||
|
||||
if is_mac_arm:
|
||||
recommended_ids = [
|
||||
"k2-fsa/OmniVoice",
|
||||
"Systran/faster-whisper-large-v3",
|
||||
"mlx-community/whisper-large-v3-mlx",
|
||||
"mlx-community/whisper-large-v3-turbo",
|
||||
"mlx-community/Kokoro-82M-bf16",
|
||||
"KittenML/kitten-tts-mini-0.8",
|
||||
]
|
||||
rationale = (
|
||||
"Apple Silicon gets the full stack: OmniVoice for multilingual clone + "
|
||||
"WhisperX (faster-whisper weights) for cross-platform ASR + MLX-Whisper "
|
||||
"for the Apple-optimised speedup + Whisper Turbo (5× faster) for live "
|
||||
"dictation + Kokoro (mlx-audio) for fast local English + KittenTTS as "
|
||||
"a CPU-realtime backup."
|
||||
"Apple Silicon preset: OmniVoice (required) covers multilingual TTS + "
|
||||
"cloning on its own. The optional picks are Metal-native: MLX Whisper "
|
||||
"large-v3 for dubbing/transcription, Whisper Turbo (MLX) + Parakeet TDT "
|
||||
"v3 for live dictation, Kokoro + KittenTTS for instant English TTS."
|
||||
)
|
||||
elif has_cuda:
|
||||
rationale = (
|
||||
"NVIDIA preset: OmniVoice (required) runs standalone. Optional ASR picks "
|
||||
"are CUDA-accelerated via CTranslate2 — Whisper large-v3 for dubbing "
|
||||
"(best word timestamps), Turbo for 5× faster transcription, Parakeet TDT "
|
||||
"v3 for live dictation. KittenTTS adds CPU-realtime English."
|
||||
)
|
||||
elif has_rocm:
|
||||
rationale = (
|
||||
"AMD/ROCm preset: OmniVoice (required) runs standalone. CTranslate2 has "
|
||||
"no ROCm backend, so the PyTorch Whisper large-v3 build is the "
|
||||
"GPU-accelerated ASR route; faster-whisper works on CPU, and Parakeet "
|
||||
"TDT v3 handles live dictation."
|
||||
)
|
||||
else:
|
||||
recommended_ids = [
|
||||
"k2-fsa/OmniVoice",
|
||||
"Systran/faster-whisper-large-v3",
|
||||
"KittenML/kitten-tts-mini-0.8",
|
||||
]
|
||||
if has_cuda:
|
||||
recommended_ids.append("openai/whisper-large-v3")
|
||||
rationale = (
|
||||
"Cross-platform stack + pytorch-whisper as a CUDA-accelerated "
|
||||
"ASR fallback. MLX / mlx-audio are Apple-Silicon-only and don't "
|
||||
"apply here."
|
||||
)
|
||||
else:
|
||||
rationale = (
|
||||
"Cross-platform stack: OmniVoice (multilingual clone) + WhisperX "
|
||||
"(faster-whisper ASR) + KittenTTS (English turbo, CPU-realtime). "
|
||||
"Clean install, every model runs on CPU."
|
||||
)
|
||||
rationale = (
|
||||
"CPU preset: OmniVoice (required) runs standalone. Optional picks favour "
|
||||
"speed on CPU — Whisper large-v3 (int8) for accuracy, Turbo when speed "
|
||||
"matters, Parakeet TDT v3 (int8 ONNX) for live dictation, KittenTTS for "
|
||||
"instant English TTS."
|
||||
)
|
||||
|
||||
known_by_id = {m["repo_id"]: m for m in KNOWN_MODELS}
|
||||
cached_ids: set[str] = set()
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
@@ -539,11 +587,11 @@ def recommendations():
|
||||
cached_ids = set(_scan_cache_on_disk().keys())
|
||||
|
||||
entries = []
|
||||
for rid in recommended_ids:
|
||||
meta = known_by_id.get(rid, {})
|
||||
for meta in curated:
|
||||
rid = meta["repo_id"]
|
||||
# Mirror /models: a truncated cache (weights missing) is not installed, so
|
||||
# the wizard counts it toward the remaining download instead of "all set".
|
||||
installed = rid in cached_ids and cache_is_complete(meta or {"repo_id": rid})
|
||||
installed = rid in cached_ids and cache_is_complete(meta)
|
||||
entries.append({
|
||||
"repo_id": rid,
|
||||
"label": meta.get("label", rid),
|
||||
|
||||
@@ -11,8 +11,18 @@
|
||||
# label (required) — Human-readable display name
|
||||
# role (required) — TTS | ASR | Diarisation
|
||||
# size_gb (required) — Approximate download size in GiB
|
||||
# required (optional) — true if the app needs this model to function
|
||||
# required (optional) — true if the app needs this model to function.
|
||||
# Only the TTS model is required: the app boots and
|
||||
# generates speech with it alone. ASR is optional and
|
||||
# installed on demand (curated picks below).
|
||||
# platforms (optional) — restrict to specific OS+arch tags (e.g. darwin-arm64, cuda)
|
||||
# curated_on (optional) — host tags for which this model is a curated
|
||||
# "best for your system" pick, surfaced by
|
||||
# GET /setup/recommendations and the wizard/Settings.
|
||||
# Tags: all | darwin-arm64 | darwin-x86_64 | cuda |
|
||||
# rocm | cpu (cpu = no GPU acceleration on this host).
|
||||
# Unlike `platforms` this never hides a model — it
|
||||
# only drives recommendations.
|
||||
# note (optional) — shown in the UI as a tooltip/footnote
|
||||
# config_only (optional) — true for pipeline repos that ship no weight file of
|
||||
# their own (weights live in referenced sub-repos). Such
|
||||
@@ -28,33 +38,43 @@ models:
|
||||
role: TTS
|
||||
size_gb: 2.4
|
||||
required: true
|
||||
curated_on: [all]
|
||||
|
||||
# ── ASR (optional — curated per platform) ─────────────────────────────
|
||||
# No ASR model is required to boot: TTS-only installs work. Dubbing,
|
||||
# dictation, and clone-reference transcription prompt for the curated
|
||||
# pick when first used.
|
||||
|
||||
- repo_id: "Systran/faster-whisper-large-v3"
|
||||
label: "Whisper large-v3 (faster-whisper — default, cross-platform)"
|
||||
label: "Whisper large-v3 (faster-whisper — cross-platform, 99 langs)"
|
||||
role: ASR
|
||||
size_gb: 2.9
|
||||
required: true
|
||||
|
||||
# ── Optional ASR ──────────────────────────────────────────────────────
|
||||
curated_on: [cuda, rocm, cpu, darwin-x86_64]
|
||||
note: "The universal pick: best word-timestamp robustness for dubbing, runs on CUDA and CPU everywhere. On Apple Silicon prefer the MLX build."
|
||||
|
||||
- repo_id: "mlx-community/whisper-large-v3-mlx"
|
||||
label: "Whisper large-v3 (MLX — optional mac-ARM speedup)"
|
||||
label: "Whisper large-v3 (MLX — best for Apple Silicon)"
|
||||
role: ASR
|
||||
size_gb: 3.0
|
||||
platforms: [darwin-arm64]
|
||||
curated_on: [darwin-arm64]
|
||||
note: "Metal-accelerated on Apple Silicon — the curated dubbing/transcription pick on this hardware."
|
||||
|
||||
- repo_id: "mlx-community/whisper-large-v3-turbo"
|
||||
label: "Whisper large-v3 Turbo (MLX — fastest dictation)"
|
||||
role: ASR
|
||||
size_gb: 1.6
|
||||
platforms: [darwin-arm64]
|
||||
curated_on: [darwin-arm64]
|
||||
note: "5× faster than large-v3, 0.8B params. Best for live dictation on Apple Silicon."
|
||||
|
||||
- repo_id: "openai/whisper-large-v3"
|
||||
label: "Whisper large-v3 (PyTorch — last-resort fallback)"
|
||||
label: "Whisper large-v3 (PyTorch — GPU path for AMD/ROCm)"
|
||||
role: ASR
|
||||
size_gb: 3.1
|
||||
platforms: [cuda]
|
||||
platforms: [cuda, rocm]
|
||||
curated_on: [rocm]
|
||||
note: "CTranslate2 has no ROCm backend, so on AMD GPUs this PyTorch build is the accelerated route."
|
||||
|
||||
- repo_id: "mlx-community/whisper-tiny-mlx"
|
||||
label: "Whisper tiny (MLX ASR — fast fallback)"
|
||||
@@ -66,6 +86,7 @@ models:
|
||||
label: "Whisper large-v3 Turbo (5× faster, 0.8B)"
|
||||
role: ASR
|
||||
size_gb: 1.6
|
||||
curated_on: [cuda, cpu]
|
||||
note: "Best speed/quality tradeoff. 5× faster than large-v3 with minimal WER loss. Community CTranslate2 conversion (no official Systran/OpenAI turbo repo) — re-verify availability on catalog audits."
|
||||
|
||||
- repo_id: "Systran/faster-distil-whisper-large-v3"
|
||||
@@ -108,6 +129,14 @@ models:
|
||||
platforms: [cuda]
|
||||
note: "English-optimized with punctuation/capitalization. Requires nemo_toolkit[asr]."
|
||||
|
||||
- repo_id: "mlx-community/parakeet-tdt-0.6b-v3"
|
||||
label: "Parakeet TDT 0.6B v3 (MLX — Apple Silicon, 25 EU langs)"
|
||||
role: ASR
|
||||
size_gb: 1.2
|
||||
platforms: [darwin-arm64]
|
||||
curated_on: [darwin-arm64]
|
||||
note: "The Parakeet tier for Apple Silicon: 25 European languages, TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via parakeet-mlx. Installing it makes dictation/capture prefer it automatically when your system language is one of the 25 covered (European) languages — other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses."
|
||||
|
||||
- repo_id: "UsefulSensors/moonshine-base"
|
||||
label: "Moonshine base (edge-optimized, 61M, ONNX)"
|
||||
role: ASR
|
||||
@@ -134,6 +163,7 @@ models:
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-parakeet-tdt-v3
|
||||
tag: offline
|
||||
curated_on: [all]
|
||||
note: "Recommended live-dictation default. CPU, int8 ONNX. Requires sherpa-onnx."
|
||||
|
||||
- repo_id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8"
|
||||
@@ -210,6 +240,7 @@ models:
|
||||
label: "KittenTTS (English, 8 preset voices, CPU realtime)"
|
||||
role: TTS
|
||||
size_gb: 0.08
|
||||
curated_on: [all]
|
||||
|
||||
# ── mlx-audio engines (Apple Silicon only) ────────────────────────────
|
||||
|
||||
@@ -217,6 +248,7 @@ models:
|
||||
label: "Kokoro 82M (8 langs, small, mlx-audio default)"
|
||||
role: TTS
|
||||
size_gb: 0.15
|
||||
curated_on: [darwin-arm64]
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
|
||||
+12
-1
@@ -681,7 +681,18 @@ async def lifespan(app: FastAPI):
|
||||
prev_loading_detail = dict(loading_detail)
|
||||
loop = asyncio.get_running_loop()
|
||||
def _warm():
|
||||
from services.asr_backend import get_capture_asr_backend
|
||||
from services.asr_backend import (
|
||||
asr_model_missing_error,
|
||||
get_capture_asr_backend,
|
||||
)
|
||||
# TTS-only install: no dictation ASR model on disk. Warming
|
||||
# would silently auto-download weights at boot — skip; the
|
||||
# first dictation prompts for the download instead.
|
||||
if asr_model_missing_error(purpose="dictation") is not None:
|
||||
logger.info(
|
||||
"Capture ASR preload skipped: no ASR model installed; "
|
||||
"dictation will offer a download on first use.")
|
||||
return
|
||||
loading_detail["sub_stage"] = "loading_asr"
|
||||
loading_detail["detail"] = "Warming up ASR engine…"
|
||||
backend = get_capture_asr_backend()
|
||||
|
||||
@@ -1377,6 +1377,140 @@ class NeMoASRBackend(ASRBackend):
|
||||
pass
|
||||
|
||||
|
||||
# ── Parakeet TDT v3 via MLX (Apple Silicon — the mac Parakeet tier) ─────────
|
||||
|
||||
# Default model for the parakeet-mlx backend. ~1.2 GB download, ~2 GB unified
|
||||
# memory at runtime, 25 European languages, TDT token/word timestamps.
|
||||
_PARAKEET_MLX_DEFAULT = "mlx-community/parakeet-tdt-0.6b-v3"
|
||||
|
||||
|
||||
class ParakeetMLXBackend(ASRBackend):
|
||||
"""NVIDIA Parakeet TDT v3 on Apple Silicon via MLX (senstella/parakeet-mlx).
|
||||
|
||||
Gives macs the Parakeet tier that CUDA/CPU users already have through
|
||||
sherpa-onnx / NeMo: 25 European languages, TDT token timestamps (so word
|
||||
timing comes from the decoder itself — no wav2vec2 alignment pass needed),
|
||||
~2 GB unified memory, dictation-grade speed on the GPU. Unlike the
|
||||
nemo-parakeet backend it needs no nemo_toolkit (whose transformers pin
|
||||
conflicts with ours) — parakeet-mlx is a small pure-Python package on top
|
||||
of mlx, installed by default on Apple Silicon source installs.
|
||||
"""
|
||||
id = "parakeet-mlx"
|
||||
display_name = "Parakeet TDT v3 (MLX — Apple Silicon, 25 langs)"
|
||||
# MLX runs on the unified-memory GPU only; there is no meaningful CPU tier
|
||||
# (is_available hard-gates on Apple Silicon via mlx_supported()).
|
||||
gpu_compat = ("mps",)
|
||||
|
||||
def __init__(self, model_name: str | None = None):
|
||||
self._model_name = model_name or os.environ.get(
|
||||
"ASR_MODEL_PARAKEET_MLX", _PARAKEET_MLX_DEFAULT,
|
||||
)
|
||||
self._model = None
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
# Shared platform gate FIRST — one rule for every MLX engine (#390).
|
||||
# Returns False on Linux/Windows/mac-Intel before any package import.
|
||||
from core.device_caps import mlx_supported
|
||||
ok, why = mlx_supported()
|
||||
if not ok:
|
||||
return False, why
|
||||
try:
|
||||
import parakeet_mlx # noqa: F401
|
||||
return True, "ready"
|
||||
# OSError/RuntimeError too, not just ImportError: in a PyInstaller
|
||||
# bundle mlx's native dylib/metallib can fail to load even when the
|
||||
# package imports (same guard as MLXWhisperBackend).
|
||||
except (ImportError, OSError, RuntimeError) as e:
|
||||
return False, f"parakeet-mlx unavailable: {e}"
|
||||
|
||||
def _ensure_model(self):
|
||||
if self._model is not None:
|
||||
return
|
||||
import parakeet_mlx
|
||||
logger.info("parakeet-mlx loading %s", self._model_name)
|
||||
self._model = parakeet_mlx.from_pretrained(self._model_name)
|
||||
|
||||
def ensure_loaded(self) -> None:
|
||||
self._ensure_model()
|
||||
|
||||
@staticmethod
|
||||
def _tokens_to_words(tokens) -> list[dict]:
|
||||
"""Merge parakeet-mlx AlignedTokens (subword pieces; a leading space
|
||||
marks a word start) into whisper-shaped word dicts."""
|
||||
words: list[dict] = []
|
||||
for tok in tokens:
|
||||
text = tok.text or ""
|
||||
if not text.strip():
|
||||
continue
|
||||
if text.startswith(" ") or not words:
|
||||
words.append({
|
||||
"word": text,
|
||||
"start": float(tok.start),
|
||||
"end": float(tok.end),
|
||||
})
|
||||
else:
|
||||
words[-1]["word"] += text
|
||||
words[-1]["end"] = float(tok.end)
|
||||
for w in words:
|
||||
w["word"] = w["word"].strip()
|
||||
return words
|
||||
|
||||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True,
|
||||
language: str | None = None) -> dict:
|
||||
self._ensure_model()
|
||||
logger.info(
|
||||
"parakeet-mlx transcribing %s (model=%s, word_timestamps=%s)",
|
||||
audio_path, self._model_name, word_timestamps,
|
||||
)
|
||||
# chunk_duration bounds unified-memory use on long files (the
|
||||
# upstream-recommended long-audio setting); short capture buffers and
|
||||
# bounded dub chunks are unaffected.
|
||||
result = self._model.transcribe(audio_path, chunk_duration=120.0)
|
||||
|
||||
# Map AlignedResult (sentences → subword tokens with start/end) to the
|
||||
# repo's standard shape: segments/words dicts + `chunks`, like the
|
||||
# other backends.
|
||||
segments_out = []
|
||||
for sent in result.sentences:
|
||||
text = (sent.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
seg = {
|
||||
"text": text,
|
||||
"start": float(sent.start),
|
||||
"end": float(sent.end),
|
||||
"words": self._tokens_to_words(sent.tokens) if word_timestamps else [],
|
||||
}
|
||||
segments_out.append(seg)
|
||||
|
||||
chunks = [
|
||||
{"text": seg["text"], "timestamp": (seg["start"], seg["end"])}
|
||||
for seg in segments_out
|
||||
]
|
||||
return {
|
||||
"text": (result.text or "").strip(),
|
||||
"chunks": chunks,
|
||||
"segments": segments_out,
|
||||
# Parakeet v3 auto-detects among its 25 languages but does not
|
||||
# expose the pick — report the caller's requested language when
|
||||
# given, else None. Never hardcode 'en': consumers treat this
|
||||
# value as detected truth (aligner pick, UI badge), and this
|
||||
# backend serves 25 languages, not one.
|
||||
"language": language,
|
||||
}
|
||||
|
||||
def unload(self) -> None:
|
||||
self._model = None
|
||||
import gc
|
||||
gc.collect()
|
||||
try:
|
||||
import mlx.core as mx
|
||||
mx.clear_cache() # release MLX's unified-memory buffer cache
|
||||
except Exception: # noqa: BLE001 — best-effort; absent on older mlx
|
||||
pass
|
||||
|
||||
|
||||
# ── Moonshine (edge-optimized, variable-length — from ASR Leaderboard) ─────
|
||||
|
||||
|
||||
@@ -2074,6 +2208,7 @@ _REGISTRY: dict[str, type[ASRBackend]] = _LazyASRRegistry({
|
||||
"mlx-whisper": MLXWhisperBackend,
|
||||
"pytorch-whisper": PyTorchWhisperBackend,
|
||||
"nemo-parakeet": NeMoASRBackend,
|
||||
"parakeet-mlx": ParakeetMLXBackend,
|
||||
"moonshine": MoonshineASRBackend,
|
||||
"funasr": FunASRBackend,
|
||||
"sherpa-onnx-asr": SherpaDictationBackend,
|
||||
@@ -2099,6 +2234,11 @@ _INSTALL_HINTS: dict[str, str] = {
|
||||
"OmniVoice manages; in-app isolation for this engine is tracked "
|
||||
"separately."
|
||||
),
|
||||
"parakeet-mlx": (
|
||||
"uv add parakeet-mlx (Apple Silicon only — installed by default on "
|
||||
"mac-ARM source installs since 0.3.22. Parakeet TDT v3 on the GPU via "
|
||||
"MLX: 25 European languages, word timestamps, ~2 GB unified memory.)"
|
||||
),
|
||||
"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)",
|
||||
@@ -2326,6 +2466,13 @@ def transcribe_reference(audio_path: str) -> str | None:
|
||||
if cached is not None:
|
||||
_ref_transcript_cache.move_to_end(fingerprint)
|
||||
return cached
|
||||
# No ASR model installed (TTS-only install): skip quietly instead of
|
||||
# letting the backend auto-download multi-GB weights mid-/generate — this
|
||||
# path is best-effort by contract (the engine's built-in fallback applies).
|
||||
if asr_model_missing_error() is not None:
|
||||
logger.info("transcribe_reference: no ASR model installed — skipping "
|
||||
"reference auto-transcription (no silent download).")
|
||||
return None
|
||||
try:
|
||||
backend = get_active_asr_backend()
|
||||
except Exception as e: # noqa: BLE001 — never let ASR break generation
|
||||
@@ -2478,6 +2625,82 @@ def dictation_model_id() -> str | None:
|
||||
return mid if is_sherpa_model(mid) else None
|
||||
|
||||
|
||||
def _parakeet_mlx_installed() -> bool:
|
||||
"""True only when the parakeet-mlx model weights are ALREADY on disk.
|
||||
|
||||
The capture picker prefers Parakeet TDT v3 on Apple Silicon, but only when
|
||||
it costs nothing: like every whisper-family backend, parakeet-mlx
|
||||
auto-downloads from HF on first load, and the capture path must never
|
||||
trigger a surprise multi-GB download (the asr_model_missing contract).
|
||||
Installed state comes from the same HF-cache helpers the model store uses
|
||||
(positive results memoized — see :func:`_repo_installed`), so the answer
|
||||
matches the Settings → Models install badges. Never raises.
|
||||
"""
|
||||
try:
|
||||
repo = os.environ.get("ASR_MODEL_PARAKEET_MLX", _PARAKEET_MLX_DEFAULT)
|
||||
return _repo_installed(repo)
|
||||
except Exception: # noqa: BLE001 — a broken check must not break the picker
|
||||
logger.warning("parakeet-mlx installed-check failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
#: The 25 (European) languages Parakeet TDT 0.6B v3 supports (NVIDIA model
|
||||
#: card). Everything else — CJK, Arabic, Hindi, … — is whisper-only.
|
||||
_PARAKEET_MLX_LANGS = frozenset({
|
||||
"bg", "cs", "da", "de", "el", "en", "es", "et", "fi", "fr", "hr", "hu",
|
||||
"it", "lt", "lv", "mt", "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sv",
|
||||
"uk",
|
||||
})
|
||||
|
||||
|
||||
def _locale_language() -> str | None:
|
||||
"""Primary language subtag of the process locale (``de_DE.UTF-8`` → ``de``),
|
||||
or None when no usable locale is set (C/POSIX, empty — e.g. a launchd GUI
|
||||
environment). Same stdlib-only signal endpoint_race's probe-order hint
|
||||
uses. Never raises."""
|
||||
cands: list[str] = []
|
||||
for key in ("LC_ALL", "LC_MESSAGES", "LANG"):
|
||||
v = os.environ.get(key)
|
||||
if v:
|
||||
cands.append(v)
|
||||
try:
|
||||
import locale as _locale
|
||||
cands.extend(x for x in _locale.getlocale() if x)
|
||||
except Exception: # noqa: BLE001 — locale probing is best-effort
|
||||
pass
|
||||
for cand in cands:
|
||||
lang = re.split(r"[_\-.@]", cand.strip().lower(), maxsplit=1)[0]
|
||||
if lang and lang not in ("c", "posix"):
|
||||
return lang
|
||||
return None
|
||||
|
||||
|
||||
def _capture_prefers_parakeet() -> bool:
|
||||
"""Whether the capture picker should auto-prefer parakeet-mlx right now.
|
||||
|
||||
Three gates, cheapest first: the backend is available (Apple Silicon +
|
||||
package), the user's language is covered, and the weights are already on
|
||||
disk (never a surprise download).
|
||||
|
||||
Language-parity rule (smallest honest rule — there is no explicit
|
||||
dictation-language setting, the REST ``language`` field is an unused
|
||||
hint): Parakeet TDT v3 knows exactly 25 (European) languages, while the
|
||||
mlx-whisper tier it replaces covers ~100 — so auto-prefer Parakeet only
|
||||
when the OS locale (the one signal we have) names a covered language.
|
||||
No usable locale, or a non-covered one (CJK, Arabic, …) → keep whisper:
|
||||
installing a 25-language engine must never silently break dictation that
|
||||
worked yesterday. Pinning ``ASR_MODEL_PARAKEET_MLX`` explicitly bypasses
|
||||
the language gate (the user chose the engine; trust them).
|
||||
"""
|
||||
ok, _ = ParakeetMLXBackend.is_available()
|
||||
if not ok:
|
||||
return False
|
||||
if not os.environ.get("ASR_MODEL_PARAKEET_MLX") \
|
||||
and _locale_language() not in _PARAKEET_MLX_LANGS:
|
||||
return False
|
||||
return _parakeet_mlx_installed()
|
||||
|
||||
|
||||
def get_capture_asr_backend() -> ASRBackend:
|
||||
"""Pick the fastest ASR engine for capture / dictation.
|
||||
|
||||
@@ -2485,10 +2708,18 @@ def get_capture_asr_backend() -> ASRBackend:
|
||||
|
||||
0. sherpa-onnx dictation — when ``dictation.model_id`` names one of the
|
||||
seven sherpa models (live/CPU; the new live-dictation path).
|
||||
1. mlx-whisper Turbo — Apple Silicon, ~5× faster than large-v3
|
||||
2. mlx-whisper large — still native Metal, faster than CPU int8
|
||||
3. faster-whisper — cross-platform CTranslate2 fallback
|
||||
4. pytorch-whisper — last resort
|
||||
1. parakeet-mlx — Apple Silicon, only when the model is ALREADY
|
||||
installed (never a surprise download) AND the
|
||||
OS-locale language is one of Parakeet's 25
|
||||
(European) languages — see
|
||||
:func:`_capture_prefers_parakeet`; a CJK/etc
|
||||
locale keeps the multilingual whisper tier
|
||||
below (language parity). TDT decoding is
|
||||
dictation-grade fast on the GPU.
|
||||
2. mlx-whisper Turbo — Apple Silicon, ~5× faster than large-v3
|
||||
3. mlx-whisper large — still native Metal, faster than CPU int8
|
||||
4. faster-whisper — cross-platform CTranslate2 fallback
|
||||
5. pytorch-whisper — last resort
|
||||
|
||||
The caller should also pass ``word_timestamps=False`` to the returned
|
||||
backend to skip per-word timing and shave another ~30% latency.
|
||||
@@ -2527,24 +2758,267 @@ def get_capture_asr_backend() -> ASRBackend:
|
||||
"falling back to Whisper capture engine", sherpa_id,
|
||||
)
|
||||
|
||||
if _capture_backend is not None and _capture_backend_key is None:
|
||||
# Prefer an already-installed Parakeet TDT v3 on Apple Silicon (when
|
||||
# the language gate allows it — see _capture_prefers_parakeet). Gated
|
||||
# on the weights being on disk so this NEVER triggers a download —
|
||||
# users opt in by installing the model from Settings → Models. The
|
||||
# gate's answer is part of the warm-singleton key so installing
|
||||
# parakeet mid-session rebuilds the singleton instead of serving the
|
||||
# stale whisper pick until restart (the memo in _repo_installed keeps
|
||||
# the repeated check cheap once it turns positive).
|
||||
prefer_parakeet = _capture_prefers_parakeet()
|
||||
auto_key = f"auto:parakeet={int(prefer_parakeet)}"
|
||||
if _capture_backend is not None and _capture_backend_key == auto_key:
|
||||
return _capture_backend
|
||||
|
||||
if prefer_parakeet:
|
||||
_capture_backend = ParakeetMLXBackend()
|
||||
_capture_backend_key = auto_key
|
||||
return _capture_backend
|
||||
|
||||
# Prefer MLX Turbo on Apple Silicon
|
||||
ok, _ = MLXWhisperBackend.is_available()
|
||||
if ok:
|
||||
_capture_backend = MLXWhisperBackend(model_name=_MLX_MODEL_TURBO)
|
||||
_capture_backend_key = None
|
||||
_capture_backend_key = auto_key
|
||||
return _capture_backend
|
||||
|
||||
# Fall back to faster-whisper (CPU int8 on non-Apple)
|
||||
ok, _ = FasterWhisperBackend.is_available()
|
||||
if ok:
|
||||
_capture_backend = FasterWhisperBackend()
|
||||
_capture_backend_key = None
|
||||
_capture_backend_key = auto_key
|
||||
return _capture_backend
|
||||
|
||||
# Last resort
|
||||
_capture_backend = PyTorchWhisperBackend()
|
||||
_capture_backend_key = None
|
||||
_capture_backend_key = auto_key
|
||||
return _capture_backend
|
||||
|
||||
|
||||
# ── No-ASR-installed preflight (TTS-only installs) ──────────────────────────
|
||||
#
|
||||
# Only the TTS model is required (models.yaml): a fresh install legitimately
|
||||
# has NO ASR model on disk. Every whisper-family backend above happily
|
||||
# *auto-downloads* its weights from HF on first load (faster_whisper's
|
||||
# WhisperModel, mlx_whisper, whisperx and the transformers pipeline all
|
||||
# default to download-on-miss), so an ASR-less install that hit dub / batch /
|
||||
# dictation either silently pulled a multi-GB model or died with an opaque
|
||||
# error offline. Consumers call :func:`asr_model_missing_error` BEFORE any
|
||||
# backend is constructed or loaded and turn the typed payload into an
|
||||
# actionable 409 / SSE / WS error carrying a one-click download CTA.
|
||||
|
||||
#: Machine-readable error id — the frontend keys its download-CTA UI on this.
|
||||
ASR_MODEL_MISSING = "asr_model_missing"
|
||||
|
||||
_PYTORCH_ASR_DEFAULT = "openai/whisper-large-v3-turbo"
|
||||
_FASTER_WHISPER_DEFAULT = "Systran/faster-whisper-large-v3"
|
||||
|
||||
# faster-whisper / WhisperX short model aliases → the HF repo they download.
|
||||
# Covers our own defaults plus the documented size aliases; an unrecognized
|
||||
# alias returns None and the preflight stays out of the way (never blocks).
|
||||
_FW_ALIAS_REPOS = {
|
||||
"tiny": "Systran/faster-whisper-tiny",
|
||||
"base": "Systran/faster-whisper-base",
|
||||
"small": "Systran/faster-whisper-small",
|
||||
"medium": "Systran/faster-whisper-medium",
|
||||
"large-v2": "Systran/faster-whisper-large-v2",
|
||||
"large-v3": "Systran/faster-whisper-large-v3",
|
||||
"distil-large-v3": "Systran/faster-distil-whisper-large-v3",
|
||||
}
|
||||
|
||||
|
||||
def _fw_repo(name: str) -> str | None:
|
||||
"""HF repo for a faster-whisper/WhisperX model name (alias or repo id)."""
|
||||
name = (name or "").strip()
|
||||
return name if "/" in name else _FW_ALIAS_REPOS.get(name.lower())
|
||||
|
||||
|
||||
def _offline_asr_repo() -> str | None:
|
||||
"""The HF repo the active *offline* (dub/batch) ASR backend would download
|
||||
on first load, or None when the selection can't be preflighted (FunASR /
|
||||
NeMo / Moonshine / OpenAI-compat are explicit opt-ins — stay out of the
|
||||
way there)."""
|
||||
bid = active_backend_id()
|
||||
if bid == "whisperx":
|
||||
return _fw_repo(os.environ.get("ASR_MODEL_WHISPERX", "large-v3"))
|
||||
if bid in ("faster-whisper", "faster-whisper-isolated"):
|
||||
# The crash-isolated sidecar loads the SAME CT2 weights as in-process
|
||||
# faster-whisper (it reuses the ASR_MODEL_FASTER selection).
|
||||
return _fw_repo(os.environ.get("ASR_MODEL_FASTER", _FASTER_WHISPER_DEFAULT))
|
||||
if bid == "mlx-whisper":
|
||||
return os.environ.get("ASR_MODEL", _MLX_MODEL_DEFAULT)
|
||||
if bid == "parakeet-mlx":
|
||||
return os.environ.get("ASR_MODEL_PARAKEET_MLX", _PARAKEET_MLX_DEFAULT)
|
||||
if bid == "sherpa-onnx-asr":
|
||||
# The offline sherpa backend loads the configured dictation model
|
||||
# (same resolution as SherpaDictationBackend.__init__ with no args).
|
||||
# Unknown/none → fail open.
|
||||
try:
|
||||
from services import sherpa_dictation as _sd
|
||||
spec = _sd.get_spec(
|
||||
os.environ.get("OMNIVOICE_SHERPA_ASR_MODEL", _sd.DEFAULT_MODEL_ID)
|
||||
)
|
||||
return spec.repo_id if spec is not None else None
|
||||
except Exception: # noqa: BLE001 — preflight must stay best-effort
|
||||
return None
|
||||
if bid == "pytorch-whisper":
|
||||
return os.environ.get("OMNIVOICE_PYTORCH_ASR_MODEL", _PYTORCH_ASR_DEFAULT)
|
||||
return None
|
||||
|
||||
|
||||
def _capture_whisper_repo() -> str | None:
|
||||
"""The HF repo :func:`get_capture_asr_backend`'s non-sherpa fallback chain
|
||||
would download — same order, but WITHOUT constructing a backend. ``None``
|
||||
means the selection can't be preflighted (the caller fails open)."""
|
||||
# Mirrors the picker's parakeet-mlx step exactly (availability + installed
|
||||
# weights + the language gate): because that step is gated on the weights
|
||||
# being installed, when it wins the preflight is trivially satisfied
|
||||
# (installed state is what the gate checked).
|
||||
if _capture_prefers_parakeet():
|
||||
return os.environ.get("ASR_MODEL_PARAKEET_MLX", _PARAKEET_MLX_DEFAULT)
|
||||
ok, _ = MLXWhisperBackend.is_available()
|
||||
if ok:
|
||||
return _MLX_MODEL_TURBO
|
||||
ok, _ = FasterWhisperBackend.is_available()
|
||||
if ok:
|
||||
# An unrecognized-but-valid alias (a name faster_whisper itself can
|
||||
# resolve but our alias table doesn't know) yields None here — FAIL
|
||||
# OPEN rather than coerce to the default repo and demand a download
|
||||
# of a model the user never picked.
|
||||
return _fw_repo(os.environ.get("ASR_MODEL_FASTER", _FASTER_WHISPER_DEFAULT))
|
||||
return os.environ.get("OMNIVOICE_PYTORCH_ASR_MODEL", _PYTORCH_ASR_DEFAULT)
|
||||
|
||||
|
||||
def _recommended_asr_model(purpose: str, missing_repo: str | None) -> dict | None:
|
||||
"""The catalog entry to offer in the download CTA.
|
||||
|
||||
Offline: the missing repo itself when it's in the catalog (guarantees
|
||||
download → retry succeeds), else the first curated + host-supported
|
||||
non-sherpa ASR pick. Dictation: the curated sherpa dictation entry (the
|
||||
payload's ``dictation_id`` lets the client also set ``dictation.model_id``
|
||||
so a retry picks it up); when sherpa-onnx isn't importable the Whisper
|
||||
fallback repo is recommended instead.
|
||||
"""
|
||||
from api.routers.setup.models import KNOWN_MODELS, _model_curated, _model_supported
|
||||
|
||||
def _shape(m: dict) -> dict:
|
||||
rec = {"repo_id": m["repo_id"], "label": m["label"], "size_gb": m["size_gb"]}
|
||||
if m.get("dictation_id"):
|
||||
rec["dictation_id"] = m["dictation_id"]
|
||||
return rec
|
||||
|
||||
by_id = {m["repo_id"]: m for m in KNOWN_MODELS}
|
||||
exact = by_id.get(missing_repo) if missing_repo else None
|
||||
want_sherpa = False
|
||||
if purpose == "dictation":
|
||||
if exact is not None and exact.get("engine") == "sherpa-onnx":
|
||||
return _shape(exact)
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
want_sherpa = ok
|
||||
if not want_sherpa and exact is not None and _model_supported(exact):
|
||||
return _shape(exact)
|
||||
for m in KNOWN_MODELS:
|
||||
if m.get("role") != "ASR":
|
||||
continue
|
||||
if (m.get("engine") == "sherpa-onnx") != want_sherpa:
|
||||
continue
|
||||
if _model_curated(m) and _model_supported(m):
|
||||
return _shape(m)
|
||||
return None
|
||||
|
||||
|
||||
#: Repos confirmed installed this session (positive-only memo). Installs only
|
||||
#: ADD models, so no invalidation is needed — and dictation utterances /
|
||||
#: generates stop paying a full ``scan_cache_dir`` walk on every call once a
|
||||
#: repo has been confirmed once. (A user deleting a model mid-session degrades
|
||||
#: to the pre-preflight behaviour for that repo: fail open, auto-download on
|
||||
#: next use.) Test fixtures that stub ``is_cached`` clear this between tests.
|
||||
_INSTALLED_REPO_MEMO: set[str] = set()
|
||||
|
||||
|
||||
def _repo_installed(repo: str) -> bool:
|
||||
"""``is_cached`` + ``cache_is_complete`` with a positive-only session memo.
|
||||
|
||||
Installed state comes from the same HF-cache helpers the model store uses,
|
||||
so the answer matches the Settings → Models install badges."""
|
||||
if repo in _INSTALLED_REPO_MEMO:
|
||||
return True
|
||||
from api.routers.setup.models import cache_is_complete, get_model_catalog, is_cached
|
||||
meta = get_model_catalog().get(repo) or {"repo_id": repo}
|
||||
if is_cached(repo) and cache_is_complete(meta):
|
||||
_INSTALLED_REPO_MEMO.add(repo)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
sherpa_model_id: str | None = None) -> dict | None:
|
||||
"""None when the active ASR selection can transcribe without downloading
|
||||
anything; otherwise the typed ``{"error": "asr_model_missing", ...}``
|
||||
payload for a 409 / SSE / WS error with a download CTA.
|
||||
|
||||
``purpose="dictation"`` mirrors the capture selection order (sherpa pref →
|
||||
parakeet-mlx → MLX turbo → faster-whisper → pytorch); anything else uses
|
||||
the offline dub/batch selection (:func:`active_backend_id`).
|
||||
``sherpa_model_id`` lets the live-dictation WS pass its per-session
|
||||
``?model=`` override. Installed state comes from the same HF-cache helpers
|
||||
the model store uses (see :func:`_repo_installed`), so the answer matches
|
||||
the Settings → Models install badges.
|
||||
|
||||
FAIL-OPEN rule: a repo the model catalog doesn't know (a custom
|
||||
``ASR_MODEL_*`` pin, pytorch-whisper's default repo, an unrecognized
|
||||
alias) returns None — the download CTA can only install catalog entries,
|
||||
so a payload here would trap the user in an un-installable CTA loop; the
|
||||
previous auto-download behaviour is the honest fallback. Never raises —
|
||||
a broken preflight must degrade to the old behaviour, not block ASR.
|
||||
"""
|
||||
try:
|
||||
if purpose == "dictation":
|
||||
sid = sherpa_model_id or dictation_model_id()
|
||||
if sid:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
if ok:
|
||||
from services import sherpa_dictation as _sd
|
||||
spec = _sd.get_spec(sid)
|
||||
if spec is not None:
|
||||
if _sd.is_installed(spec):
|
||||
return None
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": spec.repo_id,
|
||||
"recommended": _recommended_asr_model(purpose, spec.repo_id),
|
||||
}
|
||||
repo = _capture_whisper_repo()
|
||||
else:
|
||||
repo = _offline_asr_repo()
|
||||
if repo is None:
|
||||
return None # explicit opt-in engine — can't (and shouldn't) preflight
|
||||
from api.routers.setup.models import get_model_catalog
|
||||
if get_model_catalog().get(repo) is None:
|
||||
return None # not installable from the CTA — fail open (see docstring)
|
||||
if _repo_installed(repo):
|
||||
return None
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": repo,
|
||||
"recommended": _recommended_asr_model(purpose, repo),
|
||||
}
|
||||
except Exception: # noqa: BLE001 — preflight is best-effort, never a blocker
|
||||
logger.warning("ASR install preflight failed — proceeding without it",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def asr_model_missing_detail(payload: dict) -> str:
|
||||
"""Human-readable (English) fallback message for the typed payload —
|
||||
what legacy clients / logs see; the frontend renders its own i18n copy."""
|
||||
rec = payload.get("recommended") or {}
|
||||
if rec.get("label"):
|
||||
return (
|
||||
"No speech-to-text model is installed. Download "
|
||||
f"{rec['label']} ({rec['size_gb']} GB) from Settings → Models, "
|
||||
"then retry."
|
||||
)
|
||||
return ("No speech-to-text model is installed. Download one from "
|
||||
"Settings → Models, then retry.")
|
||||
|
||||
@@ -889,6 +889,14 @@ def resolve_omnivoice_checkpoint() -> str:
|
||||
checkpoint = os.environ.get("OMNIVOICE_MODEL", _DEFAULT_OMNIVOICE_CHECKPOINT).strip()
|
||||
if not checkpoint:
|
||||
return _DEFAULT_OMNIVOICE_CHECKPOINT
|
||||
if checkpoint == "test":
|
||||
# Test-suite sentinel (tests/conftest.py sets OMNIVOICE_MODEL=test):
|
||||
# return it verbatim. Self-healing it to the real default — "test"
|
||||
# is a bare token like the #693 engine-id leak — would hand every
|
||||
# app-booting test the real 2.3 GB k2-fsa/OmniVoice checkpoint,
|
||||
# which is exactly the download the sentinel exists to prevent. A
|
||||
# real load against "test" fails fast with a clear HF error instead.
|
||||
return checkpoint
|
||||
# Honor a HF repo id (org/repo) or an EXPLICIT local path (absolute, or with
|
||||
# a path separator). A bare token like "omnivoice" must NOT be treated as a
|
||||
# local dir even if a cwd-relative folder happens to share its name — that
|
||||
@@ -1231,36 +1239,27 @@ async def preload_model():
|
||||
if model is not None:
|
||||
return # already loaded
|
||||
try:
|
||||
# Check if the required model checkpoint exists before attempting
|
||||
# a heavy load that would fail and pollute startup logs. Use the same
|
||||
# resolver as the load path (#693) so a leaked engine id in
|
||||
# OMNIVOICE_MODEL can't make this model_info() probe fail and silently
|
||||
# disable warm-up (then the first /generate eats the full load).
|
||||
# Warm-up is gated on LOCAL availability only — never a Hub API
|
||||
# probe. The old `model_info(checkpoint)` probe proved the repo
|
||||
# exists on huggingface.co, NOT that this machine has it installed,
|
||||
# so on any networked machine with an uninstalled model (fresh
|
||||
# install, empty-cache CI/test run) every app boot silently pulled
|
||||
# the multi-GB checkpoint in a background thread the moment lifespan
|
||||
# started — violating this function's "if models aren't installed
|
||||
# yet, silently exits" contract. The cache-only check also never
|
||||
# constructs an HTTP session, so the #959 class (broken
|
||||
# ALL_PROXY/HTTPS_PROXY=socks5:// env raising at client
|
||||
# construction) can't false-negative it, and startup stays free of
|
||||
# network calls (local-first). Uses the same resolver as the load
|
||||
# path (#693) so a leaked engine id can't skew the probe.
|
||||
checkpoint = resolve_omnivoice_checkpoint()
|
||||
try:
|
||||
from huggingface_hub import model_info
|
||||
model_info(checkpoint, timeout=5)
|
||||
except Exception as probe_err:
|
||||
# The probe failing does NOT mean the model isn't installed — it
|
||||
# means the Hub API wasn't reachable from this process. The #959
|
||||
# class: under ALL_PROXY/HTTPS_PROXY=socks5:// without socksio,
|
||||
# hf_hub's get_session() raises ImportError AT CLIENT CONSTRUCTION;
|
||||
# same story for offline mode, DNS, or firewall failures. Fall back
|
||||
# to a cache-only probe (no HTTP session involved) and warm up
|
||||
# anyway when the model is locally present, instead of silently
|
||||
# skipping and letting the first /generate eat the full load.
|
||||
if not _checkpoint_in_local_cache(checkpoint):
|
||||
logger.info(
|
||||
"Preload skipped: %s not available locally (network probe "
|
||||
"failed: %s: %s).",
|
||||
checkpoint, type(probe_err).__name__, probe_err,
|
||||
)
|
||||
return
|
||||
logger.warning(
|
||||
"Network probe for %s failed (%s: %s) — model found in the "
|
||||
"local cache; warming up from cache.",
|
||||
checkpoint, type(probe_err).__name__, probe_err,
|
||||
if not _checkpoint_in_local_cache(checkpoint):
|
||||
logger.info(
|
||||
"Preload skipped: %s is not installed locally — the model "
|
||||
"will load (and download if requested) on first use.",
|
||||
checkpoint,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info("Preloading TTS model in background…")
|
||||
_last_used = time.time()
|
||||
|
||||
@@ -39,3 +39,59 @@ if not os.environ.get("OMNIVOICE_ENV_FILE"):
|
||||
os.environ["OMNIVOICE_ENV_FILE"] = os.path.join(
|
||||
os.environ["OMNIVOICE_DATA_DIR"], "user-env"
|
||||
)
|
||||
# TTS checkpoint sentinel — mirrors tests/conftest.py (whichever loads first
|
||||
# wins via setdefault). Without it, any test booting the real app lifespan
|
||||
# resolves the real k2-fsa/OmniVoice checkpoint and `preload_model()` could
|
||||
# kick off a multi-GB background download on a networked machine.
|
||||
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asr_model_installed(monkeypatch, request):
|
||||
"""Neutralize the no-ASR-installed preflight (asr_model_missing_error →
|
||||
None) for tests that exercise ASR-consumer *mechanics* (batch/dub/
|
||||
dictation) and assume ASR weights are present — the hermetic test env has
|
||||
no HF model cache, so the consumers would otherwise answer the typed
|
||||
``asr_model_missing`` 409 before the code under test even runs. The
|
||||
preflight has its own suite (tests/test_asr_model_missing.py). Opt in per
|
||||
module with ``pytestmark = pytest.mark.usefixtures("asr_model_installed")``.
|
||||
|
||||
Patches BOTH the freshly imported module and any module-typed alias the
|
||||
test module itself holds (``import services.asr_backend as ab``): in a
|
||||
full-suite run an earlier test can purge ``services.*`` from sys.modules,
|
||||
leaving the alias pointing at a STALE pre-purge module object whose
|
||||
globals a single sys.modules-based setattr would miss.
|
||||
(Mirror of the fixture in tests/conftest.py — conftests don't cross the
|
||||
tests/ ↔ backend/tests/ directory boundary.)"""
|
||||
import types
|
||||
|
||||
from services import asr_backend
|
||||
|
||||
targets = {id(asr_backend): asr_backend}
|
||||
test_module = getattr(request, "module", None)
|
||||
if test_module is not None:
|
||||
for val in vars(test_module).values():
|
||||
if (isinstance(val, types.ModuleType)
|
||||
and getattr(val, "__name__", "") == "services.asr_backend"):
|
||||
targets[id(val)] = val
|
||||
for mod in targets.values():
|
||||
monkeypatch.setattr(mod, "asr_model_missing_error", lambda **_kw: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_asr_installed_memo():
|
||||
"""The ASR preflight memoizes installed-POSITIVE repos process-wide
|
||||
(services.asr_backend._INSTALLED_REPO_MEMO). Tests stub ``is_cached`` both
|
||||
ways, so a memoized positive must never leak between tests. Touches the
|
||||
memo only when the module is already imported. (Mirror of the guard in
|
||||
tests/conftest.py.)"""
|
||||
mod = sys.modules.get("services.asr_backend")
|
||||
if mod is not None:
|
||||
getattr(mod, "_INSTALLED_REPO_MEMO", set()).clear()
|
||||
yield
|
||||
mod = sys.modules.get("services.asr_backend")
|
||||
if mod is not None:
|
||||
getattr(mod, "_INSTALLED_REPO_MEMO", set()).clear()
|
||||
|
||||
+63
-13
@@ -7,18 +7,33 @@ fastapi, and pydantic at module level.
|
||||
"""
|
||||
import io
|
||||
import pytest
|
||||
# These tests exercise ASR-consumer mechanics and assume ASR weights are
|
||||
# installed - neutralize the no-ASR preflight (its own suite:
|
||||
# tests/test_asr_model_missing.py).
|
||||
pytestmark = pytest.mark.usefixtures("asr_model_installed")
|
||||
|
||||
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before the batch router imports the REAL core.config (the
|
||||
# old sys.modules stub leaked at collection time and broke mixed runs).
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from api.routers.batch import router, _jobs, _set_progress
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state():
|
||||
"""Clear in-memory state between tests and disable the worker."""
|
||||
def batch():
|
||||
"""The batch module, imported at TEST time — never at collection time.
|
||||
|
||||
Clears in-memory state between tests and replaces the worker with a
|
||||
no-op so jobs stay queued. Everything in this file (including the app
|
||||
under test) MUST go through this one module object: earlier suite
|
||||
members (e.g. tests/smoke/test_boot_smoke.py) purge ``api.*`` from
|
||||
``sys.modules``, so a collection-time ``from api.routers.batch import
|
||||
router`` leaves the app serving STALE-module handlers while the fixture
|
||||
patches a fresh re-import — the stale handlers then start the REAL
|
||||
worker/pipeline, whose cancelled-but-swallowing task used to hang the
|
||||
TestClient portal teardown forever at ~97% of a full-suite run.
|
||||
"""
|
||||
import api.routers.batch as batch
|
||||
batch._jobs.clear()
|
||||
batch._queue = None
|
||||
@@ -42,15 +57,15 @@ def reset_state():
|
||||
batch._worker_task = asyncio.ensure_future(_noop())
|
||||
|
||||
batch._ensure_queue = _test_ensure_queue
|
||||
yield
|
||||
yield batch
|
||||
batch._ensure_queue = original_ensure
|
||||
batch._jobs.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
def client(batch):
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.include_router(batch.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -70,7 +85,7 @@ def _enqueue(client, video_bytes, langs="es", voice_id="", preserve_bg="true"):
|
||||
class TestEnqueue:
|
||||
def test_returns_job_id(self, client, fake_video):
|
||||
resp = _enqueue(client, fake_video, "es,fr")
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "job_id" in body
|
||||
assert body["status"] == "queued"
|
||||
@@ -146,9 +161,9 @@ class TestCancelJob:
|
||||
job = client.get(f"/batch/jobs/{r['job_id']}").json()
|
||||
assert job["status"] == "cancelled"
|
||||
|
||||
def test_cancel_already_done(self, client, fake_video):
|
||||
def test_cancel_already_done(self, client, batch, fake_video):
|
||||
r = _enqueue(client, fake_video).json()
|
||||
_jobs[r["job_id"]]["status"] = "done"
|
||||
batch._jobs[r["job_id"]]["status"] = "done"
|
||||
resp = client.post(f"/batch/jobs/{r['job_id']}/cancel")
|
||||
assert resp.json()["already"] == "done"
|
||||
|
||||
@@ -169,15 +184,50 @@ class TestDeleteJob:
|
||||
|
||||
|
||||
class TestSetProgress:
|
||||
def test_basic(self):
|
||||
def test_basic(self, batch):
|
||||
job = {}
|
||||
_set_progress(job, "transcribe", 50, segments_count=10)
|
||||
batch._set_progress(job, "transcribe", 50, segments_count=10)
|
||||
assert job["progress"]["stage"] == "transcribe"
|
||||
assert job["progress"]["percent"] == 50
|
||||
assert job["progress"]["segments_count"] == 10
|
||||
|
||||
def test_overwrite(self):
|
||||
def test_overwrite(self, batch):
|
||||
job = {"progress": {"stage": "extract", "percent": 100}}
|
||||
_set_progress(job, "generate", 25, current_lang="es")
|
||||
batch._set_progress(job, "generate", 25, current_lang="es")
|
||||
assert job["progress"]["stage"] == "generate"
|
||||
assert job["progress"]["current_lang"] == "es"
|
||||
|
||||
|
||||
class TestWorkerShutdown:
|
||||
def test_worker_task_terminates_on_cancel_mid_job(self, batch, monkeypatch):
|
||||
"""Regression guard: `_worker` used to swallow CancelledError and
|
||||
re-enter `_queue.get()`, leaving an immortal task. Event-loop
|
||||
teardown (app shutdown, TestClient per-request portal exit) then
|
||||
hung forever in `_cancel_all_tasks` — the full-suite freeze at ~97%.
|
||||
Cancellation arriving mid-pipeline must mark the job cancelled AND
|
||||
terminate the task."""
|
||||
import asyncio
|
||||
|
||||
async def scenario():
|
||||
started = asyncio.Event()
|
||||
|
||||
async def fake_pipeline(job_id, job):
|
||||
started.set()
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
monkeypatch.setattr(batch, "_run_batch_pipeline", fake_pipeline)
|
||||
batch._queue = asyncio.Queue()
|
||||
task = asyncio.ensure_future(batch._worker())
|
||||
batch._jobs["j1"] = {"status": "queued", "filename": "x.mp4"}
|
||||
await batch._queue.put("j1")
|
||||
await asyncio.wait_for(started.wait(), timeout=5)
|
||||
|
||||
task.cancel()
|
||||
done, _pending = await asyncio.wait({task}, timeout=2)
|
||||
assert task in done, (
|
||||
"worker task must terminate when cancelled mid-job "
|
||||
"(swallowing CancelledError makes shutdown hang forever)"
|
||||
)
|
||||
assert batch._jobs["j1"]["status"] == "cancelled"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -26,6 +26,17 @@ from core import run_sentinel
|
||||
@pytest.fixture()
|
||||
def sentinel_env(monkeypatch, tmp_path):
|
||||
"""Redirect every on-disk artifact into tmp_path and reset module state."""
|
||||
# Re-resolve the module at TEST time and heal this file's global: earlier
|
||||
# suite members (tests/smoke/test_boot_smoke.py) purge ``core.*`` from
|
||||
# sys.modules, so the collection-time import above goes STALE in a
|
||||
# combined `pytest tests/ backend/tests/` run — the endpoint under test
|
||||
# (freshly imported by the `client` fixture) then reads the REAL
|
||||
# CRASH_RECORD_PATH while this fixture patches the stale twin, and the
|
||||
# ack/notification roundtrips fail. CI runs the two trees in isolated
|
||||
# invocations and never sees this; local combined runs do.
|
||||
global run_sentinel
|
||||
import core.run_sentinel as _fresh_run_sentinel
|
||||
run_sentinel = _fresh_run_sentinel
|
||||
monkeypatch.setattr(run_sentinel, "SENTINEL_PATH", str(tmp_path / "run_sentinel.json"))
|
||||
monkeypatch.setattr(
|
||||
run_sentinel, "CRASH_RECORD_PATH", str(tmp_path / "last_run_crash.json")
|
||||
|
||||
@@ -18,7 +18,7 @@ Stack: Tauri v2 + FastAPI sidecar + PyInstaller. Target: ~500 MB signed + notari
|
||||
| Optional engine packs (VoxCPM2 CUDA, pyannote, MOSS-TTS) | Separate `.tar.gz` via GitHub Releases manifest | **No — first-run download if user opts in** |
|
||||
|
||||
Target DMG size: **~500 MB**.
|
||||
First-run model download: **~5 GB** one-time.
|
||||
First-run model download: **~2.4 GB** one-time (the TTS model is the only required download; ASR/transcription models are optional per-platform curated picks installed on demand from the wizard or Settings).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ the self-inventory missed `scripts/validate-install-docs.py`, the probe-judge +
|
||||
| Preset voice library (no reference audio) | B+ (20+ archetypes) | ✅ **50+ presets** (Kokoro/Qwen) | ❌ | ❌ | They win on count, we win on curation + degenerate-check |
|
||||
| Voice design from text description | B | 🟡 (personality descriptors) | ❌ | ❌ | We're ahead (#317 shipped a deterministic mapper) |
|
||||
| **Unlimited-length generation (chunk + crossfade)** | ❌ (no auto-chunking) | ✅ `chunked_tts.py` | ✅ (per-subtitle by design) | ❌ | **Gap.** Their crossfade chunker removes the length ceiling |
|
||||
| Paralinguistic tags (`[laugh]`, `[sigh]`) | ❌ | ✅ (Chatterbox Turbo) | ❌ | ❌ | Engine-dependent; we'd get it by adding Chatterbox Turbo |
|
||||
| Paralinguistic tags (`[laugh]`, `[sigh]`) | B (13 native reaction tags via ⊕ Insert; no `[breath]` yet) | ✅ (Chatterbox Turbo) | ❌ | ❌ | Near-parity — see docs/expressive-speech.md; CosyVoice 3 adds `[breath]`/`[laughter]` |
|
||||
| Delivery instructions ("whisper", "slowly") | B (instruct field) | ✅ (Qwen NL control) | ❌ | ❌ | Parity-ish |
|
||||
| Generation queue w/ cancel + SSE | B+ (job store, SSE replay) | ✅ | ✅ (9-queue pipeline) | n/a | Parity; our SSE reconnect-replay is ahead of voicebox |
|
||||
| Post-processing FX chain (reverb/pitch/comp) | B (effect chain exists) | ✅ **Pedalboard, per-profile presets** | ❌ | ❌ | Theirs is richer + has preset UX |
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Expressive speech: breaths, laughter, and style
|
||||
|
||||
How to direct a performance — laughter, sighs, pauses, whispering, emotion,
|
||||
and the community favorite: *"how do I make it take a sharp audible breath,
|
||||
like a person running out of breath?"* Some of this is supported today
|
||||
(engine-dependent), some is spec'd but not shipped yet. This page tells you
|
||||
exactly which is which, so you don't burn an evening on tags an engine
|
||||
ignores.
|
||||
|
||||
## The short version
|
||||
|
||||
| You want | Do this | Works on |
|
||||
|---|---|---|
|
||||
| A pause | Type `[pause]`, `[pause 500ms]`, or `[pause 1.5s]` in the text | Every engine |
|
||||
| Laughter or a sigh | ⊕ Insert → `[laughter]` / `[sigh]` | Default engine (OmniVoice) |
|
||||
| An audible breath **on demand** | `[breath]` in the text | CosyVoice 3 only (opt-in) — see [Breaths](#breaths-specifically) |
|
||||
| Whispering | Style → `whisper` (the voice-design/style field) | Default engine |
|
||||
| Emotion ("excited", "sad", graded intensity) | IndexTTS2's emotion controls (API), or CosyVoice 3 instruct | Opt-in engines only |
|
||||
| The same take again | Pin the seed / lock the profile | Default engine |
|
||||
|
||||
## Why bracket tags work at all (and when they don't)
|
||||
|
||||
Everything you type in the text box reaches the active engine **verbatim** —
|
||||
the pipeline goes out of its way not to break tags:
|
||||
|
||||
- The text-normalization pass (numbers, abbreviations) skips every `[…]` span
|
||||
(`backend/services/text_normalization.py`).
|
||||
- The long-text chunker never cuts inside a bracket tag
|
||||
(`backend/services/chunked_tts.py`, `_BRACKET_TAG_RE`).
|
||||
|
||||
The flip side is just as important: **unrecognized tags are not stripped.**
|
||||
An engine that doesn't know a tag receives it as literal text and will try to
|
||||
speak it. Pasting an ElevenLabs script full of `[excited]` / `[whispers]`
|
||||
degrades output on every engine we ship — those tags are on the roadmap (see
|
||||
[What's coming](#whats-coming)), not in the engines. Use only the tags listed
|
||||
for your engine below.
|
||||
|
||||
## What each engine can do today
|
||||
|
||||
### Every engine
|
||||
|
||||
- **`[pause Nms]` markers** — `[pause]` (350 ms default), `[pause 500ms]`,
|
||||
`[pause 1s]`, up to 10 s. Rendered as real stitched silence, so it works
|
||||
identically on all engines.
|
||||
- **Punctuation** — ellipses, dashes, exclamation marks, and short fragments
|
||||
genuinely shape pacing and intonation. Cheap, underrated.
|
||||
- **The reference clip is a performance direction.** Zero-shot cloning mirrors
|
||||
the *delivery* of the reference, not just the timbre — a flat reference
|
||||
clones flat, an animated one clones animated (see the tip in
|
||||
[generation-parameters.md](generation-parameters.md)). This is the most
|
||||
reliable expressive control in the app.
|
||||
- **Pronunciation overrides** — `[[Nuh-VAD-uh]]` inline, or the pronunciation
|
||||
dictionary. Not expression, but often what a "it says this weirdly" problem
|
||||
actually needs.
|
||||
|
||||
### Default engine (OmniVoice)
|
||||
|
||||
**Non-verbal tags.** The bundled model natively tokenizes 13 reaction tags
|
||||
(`omnivoice/models/omnivoice.py`, `_NONVERBAL_PATTERN`) — the ⊕ Insert button
|
||||
at the corner of the Script box lists them all:
|
||||
|
||||
`[laughter]` `[sigh]` `[confirmation-en]` `[question-en]` `[question-ah]`
|
||||
`[question-oh]` `[question-ei]` `[question-yi]` `[surprise-ah]`
|
||||
`[surprise-oh]` `[surprise-wa]` `[surprise-yo]` `[dissatisfaction-hnn]`
|
||||
|
||||
Honest expectations: `[laughter]` and `[sigh]` are the broadly useful ones;
|
||||
most of the interjection variants (`-ah`, `-yi`, `-hnn`) are tuned for
|
||||
Mandarin-flavored speech. How convincingly a tag renders varies with the
|
||||
voice — a tag that lands great on one reference clip can come out subdued on
|
||||
another. There is **no intensity control**, and **no `[breath]` tag** in this
|
||||
set.
|
||||
|
||||
**Whispering.** `whisper` is the one delivery style the instruct validator
|
||||
accepts (the taxonomy is Gender / Age / Pitch / Style / Accent / Dialect —
|
||||
see [voice-design.md](voice-design.md)). `[happy]` / `[sad]`-style emotion
|
||||
direction is **not** something the base model takes.
|
||||
|
||||
**Sampling knobs + seed.** The Voice workspace's Production Overrides panel
|
||||
exposes the full sampling surface (defaults in parentheses; details in
|
||||
[generation-parameters.md](generation-parameters.md)):
|
||||
|
||||
- `position_temperature` (5.0) and `class_temperature` (0.0) — 0 is greedy;
|
||||
higher is more random, which means more expressive variation *and* more
|
||||
artifacts.
|
||||
- `num_step` — the Voice page defaults to 16 (fast); Audiobook renders use 32
|
||||
(cleaner). Fewer steps = rougher, occasionally more "human-sounding" edges.
|
||||
- **Seed** — unpinned by default, so every render differs. The history rail
|
||||
shows the seed each take used; "Keep this seed" (Design tab) or locking a
|
||||
profile from history pins reference + seed, making the voice
|
||||
bit-reproducible.
|
||||
- `postprocess_output` (on) — removes long silences from the output. Turn it
|
||||
off when the silence *is* the performance.
|
||||
|
||||
**Longform-only tags.** Audiobook and Stories additionally parse SSML-lite —
|
||||
`[slow]…[/slow]`, `[fast]…[/fast]`, `[emphasis]…[/emphasis]`, `[spell]` —
|
||||
plus `[voice:NAME]` for multi-voice scripts
|
||||
(`backend/services/longform_parser.py`). These are not parsed on the Voice
|
||||
page.
|
||||
|
||||
### CosyVoice 3 (opt-in)
|
||||
|
||||
The most direct paralinguistic control in the app, if you're willing to
|
||||
install it. CosyVoice 3 honors, inline in the text:
|
||||
|
||||
- `[breath]` — an audible breath, exactly where you put it
|
||||
- `[laughter]`
|
||||
- `<strong>word</strong>` — emphasis
|
||||
|
||||
plus **natural-language instruct** ("speak with a Sichuan accent", "sound
|
||||
exhausted") — the backend appends the model's required `<|endofprompt|>`
|
||||
terminator for you (`backend/services/tts_backend.py`,
|
||||
`CosyVoiceBackend`). One catch: the Studio style field whitelists instruct to
|
||||
the default engine's taxonomy, so free-text instruct currently needs the API
|
||||
(`POST /generate` with an `instruct` form field, or `/ws/tts`).
|
||||
|
||||
Setup: clone + install [CosyVoice](https://github.com/FunAudioLLM/CosyVoice)
|
||||
(non-trivial: `git clone --recursive`, its requirements, SoX), then set
|
||||
`OMNIVOICE_COSYVOICE_MODEL` to the model directory and select it in
|
||||
Settings → Engines. CUDA or CPU; MPS is unverified upstream.
|
||||
|
||||
### VoxCPM2 (opt-in)
|
||||
|
||||
VoxCPM2's native convention is an instruct prefix inside the text itself:
|
||||
`(speaking fast, out of breath) I can't stop now.` The app maps the
|
||||
`instruct` field onto that prefix (`backend/services/tts_backend.py`,
|
||||
`VoxCPM2Backend.generate`), and because the convention is literally in-text,
|
||||
typing the parenthetical at the start of your text works too. Treat it as
|
||||
guidance, not a guarantee — adherence varies by voice and language.
|
||||
|
||||
### IndexTTS2 (opt-in)
|
||||
|
||||
The only engine with **graded** emotion control: an 8-value emotion vector
|
||||
(happy, angry, sad, afraid, disgusted, melancholic, surprised, calm), an
|
||||
emotion *reference clip* whose delivery is mimicked (with a blend strength),
|
||||
or a natural-language emotion description. Today these are exposed on the
|
||||
streaming WebSocket API (`/ws/tts` — `emo_vector`, `emo_audio`, `emo_alpha`,
|
||||
`emo_text` fields; `backend/api/routers/tts_stream.py`), **not** in the
|
||||
Studio UI yet.
|
||||
|
||||
## Breaths, specifically
|
||||
|
||||
The honest answer to *"how do I invoke a sharp inhale on demand?"*:
|
||||
|
||||
**On the default engine — you can't yet, not directly.** There is no
|
||||
`[breath]` or `[inhale]` token in its tag set; `[sigh]` is the nearest
|
||||
neighbor and it's an exhale. An engine-agnostic breath/reaction tag layer is
|
||||
spec'd ([specs/01-expressive-tts.md](specs/01-expressive-tts.md)) but not
|
||||
shipped — see below.
|
||||
|
||||
**The direct route: CosyVoice 3.** Its `[breath]` tag puts an audible breath
|
||||
exactly where you type it. If on-demand breaths matter to your work, this is
|
||||
the supported path today.
|
||||
|
||||
**The coax-it recipe (default engine).** Breaths *can* be elicited — this is
|
||||
exactly what v0.3.9 was doing by accident. Roughly in order of effectiveness:
|
||||
|
||||
1. **Put the breathing in the reference clip.** Record 8–15 s of yourself (or
|
||||
your speaker) genuinely winded — audible inhales between phrases. The
|
||||
clone mirrors the delivery. This alone gets most of the way there.
|
||||
2. **Write for it.** Short gasping fragments with pauses:
|
||||
`I can't… [pause 300ms] I can't keep… [pause 200ms] keep running.`
|
||||
3. **Turn off `postprocess_output`** (Production Overrides) so the silences —
|
||||
where breath artifacts live — aren't trimmed away.
|
||||
4. **Add randomness, then farm takes.** Raise `class_temperature` to 0.3–0.7
|
||||
(default is 0, fully greedy), keep `num_step` at 16, and regenerate a few
|
||||
times — the seed is unpinned, so each take differs.
|
||||
5. **Keep the winner.** When a take breathes right, its seed is on the
|
||||
history entry — lock the profile from there and every future generation
|
||||
uses the same reference + seed.
|
||||
|
||||
Tradeoffs, stated plainly: temperature cuts both ways (the same randomness
|
||||
that produces a great gasp produces slurred words and timbre drift), takes
|
||||
are non-repeatable until you pin the seed, and postprocess-off keeps *all*
|
||||
long silences, wanted or not. This is a workaround, not a feature — which is
|
||||
why the feature is spec'd.
|
||||
|
||||
## Why v0.3.9-style random breaths faded
|
||||
|
||||
Users of v0.3.9 remember outputs that would spontaneously breathe, gasp, and
|
||||
rustle — and noticed v0.3.15+ is smooth. Those breaths were never a feature:
|
||||
they were uncontrolled sampling variance (unpinned seed + the default
|
||||
`position_temperature` of 5.0) surviving an output chain that was, at the
|
||||
time, less tidy. Then the chain got deliberately cleaner:
|
||||
|
||||
- **v0.3.12** — the mastering pre-stage was cut down to highpass + compressor
|
||||
after a field report of hidden echo; every generation had been getting a
|
||||
small room reverb baked in, which made outputs sound roomier and
|
||||
breathier (#986).
|
||||
- **Silence post-processing** (`postprocess_output`, default on) removes long
|
||||
silences — the gaps where stray breath noise lived.
|
||||
- **v0.3.16** — VoxCPM2 reference clips get edge-silence trimming before
|
||||
conditioning, and outputs get a trailing-silence trim (#1055), so that
|
||||
engine stopped inheriting dead air and its artifacts.
|
||||
|
||||
Net effect: the default output is now clean by design, and expressiveness is
|
||||
becoming something you *ask for* (tags, instruct, the recipe above) rather
|
||||
than something that happens to you.
|
||||
|
||||
## What's coming
|
||||
|
||||
[Spec 01 — Expressive TTS](specs/01-expressive-tts.md) defines the plan: one
|
||||
engine-agnostic tag surface (`[excited]`, `[whispers]`, reaction tags like
|
||||
`[breath]`) that lowers to whatever the active engine can really do and
|
||||
**visibly degrades** where it can't, plus an Expression panel (emotion
|
||||
dropdown + intensity + emotion-reference clip) in the UI. The pronunciation
|
||||
phases have shipped (dictionary + `[[…]]` overrides); the inline
|
||||
emotion/reaction tag grammar and the Expression panel have not. No promised
|
||||
date — when it lands, this page gets updated in the same PR.
|
||||
@@ -68,6 +68,8 @@ asr_engines:
|
||||
readme: PyTorch Whisper
|
||||
- id: nemo-parakeet
|
||||
readme: Parakeet TDT
|
||||
- id: parakeet-mlx
|
||||
readme: Parakeet TDT v3 (MLX)
|
||||
- id: moonshine
|
||||
readme: Moonshine
|
||||
- id: funasr
|
||||
|
||||
@@ -30,6 +30,8 @@ audio = model.generate(text="Hello world", generation_config=config)
|
||||
| `class_temperature` | float | 0.0 | Temperature for token sampling at each step. 0 = greedy (deterministic). Higher values increase randomness. |
|
||||
| `layer_penalty_factor` | float | 5.0 | Penalty applied to deeper codebook layers, encouraging earlier (lower) layers to unmask first. |
|
||||
|
||||
> Using temperature (and seed pinning) to elicit expressive delivery — breaths, laughter, sighs — is covered in [expressive-speech.md](expressive-speech.md), including the tradeoffs.
|
||||
|
||||
## Duration & Speed
|
||||
|
||||
These accept a single value applied to all items, or a per-item list (useful in batch mode):
|
||||
|
||||
@@ -151,6 +151,13 @@ without the quarantine step.
|
||||
- **Apple Silicon (M-series):** OmniVoice automatically picks the `mlx-whisper`
|
||||
and `mlx-audio` backends where available — these use the Apple Neural Engine
|
||||
and Metal Performance Shaders for ~2× the throughput of the CPU path.
|
||||
Installing the **Parakeet TDT v3 (MLX)** model from **Settings → Models**
|
||||
additionally makes dictation/capture prefer the `parakeet-mlx` engine
|
||||
(25 European languages, word timestamps, ~2 GB unified memory) — it is never
|
||||
downloaded without that explicit install, and it is only auto-preferred when
|
||||
your system language is one of its 25 covered languages (other languages —
|
||||
CJK, Arabic, … — keep the multilingual Whisper engine so dictation coverage
|
||||
never regresses; pin `ASR_MODEL_PARAKEET_MLX` to force it).
|
||||
- **Intel Macs:** the local backend is **unsupported** — PyTorch no longer
|
||||
ships Intel-Mac wheels, so the Python environment can never install
|
||||
([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)). The UI
|
||||
|
||||
@@ -409,7 +409,9 @@ did was `generate:start (audio)`, a dub, or a dictation.
|
||||
faster-whisper **medium** or **small**, instead of large-v3. Biggest win on
|
||||
low-VRAM GPUs.
|
||||
2. **Free VRAM**: **Flush the TTS model** before dubbing so ASR isn't competing
|
||||
for memory, or
|
||||
for memory (top toolbar → Flush → "Unload all + flush", or per-model from
|
||||
Settings → Models — see [Flush caches / Unload resident model](../performance.md#flush-caches--unload-resident-model)
|
||||
for exactly what it frees and the API equivalents for scripts), or
|
||||
3. **Run ASR on CPU** (slower but reliable) if your GPU is small.
|
||||
4. **Test with a 10-second clip** first — if that returns quickly, it confirms a
|
||||
compute/VRAM limit rather than a true hang.
|
||||
@@ -580,6 +582,11 @@ unaffected and works normally.
|
||||
|
||||
**Linked issue:** [#1013](https://github.com/debpalash/OmniVoice-Studio/issues/1013)
|
||||
|
||||
> **Tip:** current builds surface the live OS grant state in-app — **Settings →
|
||||
> Permissions** shows whether the microphone (and, on macOS, Accessibility) is
|
||||
> granted, denied, or not asked yet, with an **Open Settings** button that
|
||||
> deep-links the exact OS pane described above.
|
||||
|
||||
## Dub: "translation engine needs the optional … package"
|
||||
|
||||
**Symptom:** in the Dub tab, translating fails with e.g. *"The 'google'
|
||||
|
||||
@@ -124,6 +124,14 @@ plainly):
|
||||
diarization can separate them — but a clean solo clip is still the
|
||||
best input.
|
||||
|
||||
If a great clip still isn't close enough and you have *hours* of
|
||||
recordings, the step up isn't a longer reference — zero-shot
|
||||
conditioning stops using audio past a short window — it's offline
|
||||
fine-tuning of the bundled model on your own dataset: see
|
||||
[training / fine-tuning](../training.md) and
|
||||
[data preparation](../data_preparation.md). Technical, command-line,
|
||||
GPU-required — but it's the trained-on-your-voice path.
|
||||
|
||||
## Your first clone
|
||||
|
||||
1. Launch the app and pick the **Voice Clone** card on the Launchpad
|
||||
|
||||
+91
-6
@@ -10,18 +10,39 @@ Silicon M2 — your hardware will differ, but the *ratios* hold.
|
||||
Before touching any knob, check these — they account for most slowness reports:
|
||||
|
||||
1. **A voice profile with an empty Transcript field.** Cloning needs the
|
||||
reference clip's transcript. If the profile doesn't have one, the app
|
||||
transcribes the clip — since v0.3.15 that happens **once** and is saved onto
|
||||
the profile, but a profile that somehow keeps an empty transcript (e.g.
|
||||
imported data) pays an ASR pass per generation. Open the voice's editor and
|
||||
confirm the Transcript box shows text.
|
||||
reference clip's transcript. If the profile doesn't have one, the app runs a
|
||||
full Whisper transcription of the clip — and before v0.3.15 it did that on
|
||||
**every single generate** (the "TTS got much slower after updating, CPU
|
||||
pegged at 100%" regression, #1032). Since v0.3.15 the auto-transcription
|
||||
runs once and is saved onto the profile, but a profile that still has an
|
||||
empty transcript (e.g. imported or hand-edited data) keeps paying an ASR
|
||||
pass per generation. **Fix:** open the voice's editor and check the
|
||||
Transcript box — if it's empty, type or paste what the reference clip says
|
||||
(or just generate once on v0.3.15+ and confirm the box filled itself in).
|
||||
2. **The first generation after a (re)start is always the slowest.** Model
|
||||
weights load lazily (~8 s), CUDA builds torch.compile kernels, Apple Silicon
|
||||
warms Metal kernels. Judge speed from the *second* generation onward.
|
||||
3. **Memory pressure.** On a 16 GB unified-memory machine, a browser with 40
|
||||
tabs next to a dub means the OS pages the model in and out — or kills the
|
||||
backend outright ("Can't reach the local backend"). Check Settings →
|
||||
Models for what's resident, and Settings → Performance for free RAM.
|
||||
Models for what's resident, and Settings → Performance for free RAM. See
|
||||
[Flush caches / Unload resident model](#flush-caches--unload-resident-model)
|
||||
for freeing memory without a restart.
|
||||
4. **You're generating on CPU without realizing it.** A driver update, a
|
||||
CUDA/torch mismatch, or simply running on hardware with no supported GPU
|
||||
path silently drops you to CPU — everything works, just several times
|
||||
slower. Three places tell you the truth:
|
||||
- **Settings → Performance → Device & compute** shows the live compute
|
||||
device (`cuda` / `mps` / `cpu`), a "GPU active" badge, and RAM/VRAM
|
||||
readouts.
|
||||
- **Settings → About → Run self-check** (the `/system/diagnose` endpoint)
|
||||
warns explicitly: *"cpu (no GPU acceleration detected)"* with a hint
|
||||
about drivers.
|
||||
- **Settings → Engines** shows a routing badge per engine — "GPU active",
|
||||
"CPU fallback", or "CPU" — with the *reason* shown as small text under
|
||||
the badge (full text on hover).
|
||||
Note: **GPU acceleration on Windows is NVIDIA/CUDA-only** — AMD and Intel
|
||||
GPUs run CPU-only there (see [Windows install notes](install/windows.md)).
|
||||
|
||||
## What a generation actually spends time on
|
||||
|
||||
@@ -68,6 +89,70 @@ torch.compile" (shown on Windows), for the rare setup where a partial Triton
|
||||
install makes the probe pass but the compile attempt itself crash — see
|
||||
[Windows install notes](install/windows.md).
|
||||
|
||||
## Flush caches / Unload resident model
|
||||
|
||||
This is the feature the VRAM-starved timeout error ("TTS generate exceeded
|
||||
300s … Flush caches / Unload the resident model") points at. It frees
|
||||
RAM/VRAM **without restarting the app**, and it never loses data — an
|
||||
unloaded model simply reloads lazily (~8 s) on the next generation.
|
||||
|
||||
**Where it lives:**
|
||||
|
||||
- **Top toolbar → Flush** (the button next to the model-status badge). The
|
||||
dropdown lists every model currently in memory — the TTS model, its
|
||||
co-loaded ASR, the diarization pipeline, and any resident engines or
|
||||
sidecars — with its device and VRAM use, and a per-model **Unload** button
|
||||
where unloading is possible (WhisperX is released together with the TTS
|
||||
model, so it has no button of its own). An engine left resident after you
|
||||
switched away from it is marked *"not active — safe to unload"*. Below the
|
||||
list are the two bulk actions:
|
||||
- **Flush caches** — runs a multi-pass garbage collection and releases the
|
||||
accelerator's cached memory (CUDA/MPS/XPU `empty_cache`). Models stay
|
||||
loaded, so there's no reload cost; this recovers cache/fragmentation
|
||||
memory only.
|
||||
- **Unload all + flush** — the above **plus** fully unloads the resident
|
||||
TTS model. Frees the most memory; the next generation pays the ~8 s
|
||||
reload.
|
||||
- **Settings → Models** — rows whose weights are resident right now show an
|
||||
"In memory" badge with the same per-model **Unload** button.
|
||||
|
||||
**From a script** (the local API on port 3900), the same operations:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:3900/system/flush-memory" # flush caches
|
||||
curl -X POST "http://127.0.0.1:3900/system/flush-memory?unload_model=true" # + unload TTS model
|
||||
curl "http://127.0.0.1:3900/model/loaded" # what's resident
|
||||
# unload one model — ids: tts | diarization | sidecar:<id> | sidecars
|
||||
curl -X POST "http://127.0.0.1:3900/model/unload/tts"
|
||||
```
|
||||
|
||||
**When to use it:**
|
||||
|
||||
- **After a VRAM-starved 503 timeout** — a resident model and your generate
|
||||
were contending for GPU memory. Unload all + flush, then retry.
|
||||
- **Before a dub on a tight-memory machine** — transcription needs room the
|
||||
resident TTS model is holding (on Apple Silicon the app does this
|
||||
automatically, see `OMNIVOICE_UNIFIED_OFFLOAD_HEADROOM_GB` above).
|
||||
- **After switching engines** — with `OMNIVOICE_SINGLE_ENGINE_RESIDENT=0`,
|
||||
or for sidecar engines, the previous engine can stay in memory; the
|
||||
dropdown shows it and marks it safe to unload.
|
||||
- **Mid batch-run on a small GPU** — an occasional
|
||||
`POST /system/flush-memory` between jobs keeps cache growth from
|
||||
starving later generations.
|
||||
|
||||
**When it won't help:** many generate errors are *not* memory problems, and
|
||||
their messages say so explicitly ("the Flush button won't help here") —
|
||||
missing env vars, network failures during a model download, a broken native
|
||||
component. Believe the message; Flush only fixes memory contention. Also
|
||||
note the app already frees memory on its own when idle
|
||||
(`OMNIVOICE_IDLE_TIMEOUT_S`) — Flush is for when you need the memory *now*,
|
||||
between jobs.
|
||||
|
||||
If the timeout error keeps recurring even right after an unload, see
|
||||
[troubleshooting §14](install/troubleshooting.md#14-cant-reach-the-local-backend-during-generation--transcription--dubbing)
|
||||
— the same starvation class has more remedies there (smaller ASR model,
|
||||
CPU ASR, the crash-isolated ASR engine).
|
||||
|
||||
## Platform notes
|
||||
|
||||
- **Apple Silicon**: everything runs on the GPU via MPS/MLX. One generation at
|
||||
|
||||
@@ -66,6 +66,8 @@ internally — you can write in English, Chinese, or a mix of both.
|
||||
|---------|---------|
|
||||
| whisper | 耳语 |
|
||||
|
||||
> `whisper` is the only delivery style the base model accepts — emotion tags like `[happy]`/`[sad]` are not part of this taxonomy. For everything expressive (breaths, laughter, pauses, emotion, and which engines support what), see [expressive-speech.md](expressive-speech.md).
|
||||
|
||||
### English Accent
|
||||
|
||||
Only effective when the synthesis text is in English.
|
||||
|
||||
@@ -77,7 +77,9 @@ windows-core = "0.61"
|
||||
# Version pinned to match what `tauri` itself already resolves to (0.61.x) so
|
||||
# `WebviewWindow::hwnd()`'s return type and our syscalls share the exact same
|
||||
# `HWND` type — no second copy of the crate enters the dependency graph.
|
||||
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
|
||||
# Win32_System_Registry: check_microphone reads the CapabilityAccessManager
|
||||
# ConsentStore mic toggle (RegGetValueW) for the permissions UX.
|
||||
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Registry"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -309,6 +309,198 @@ pub fn open_accessibility_settings() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── OS permission probes (microphone / input monitoring) ─────────────────
|
||||
//
|
||||
// Cross-platform-honest: these never guess a grant state they can't know.
|
||||
// `check_microphone` returns one of "granted" | "denied" | "prompt" |
|
||||
// "unknown" — "unknown" means the OS gives us no readable answer (Linux has
|
||||
// no per-app mic TCC; older Windows lacks the ConsentStore key), and the JS
|
||||
// side must not treat it as either granted or denied.
|
||||
|
||||
/// macOS microphone grant via `[AVCaptureDevice authorizationStatusForMediaType:
|
||||
/// AVMediaTypeAudio]`. Hand-rolled ObjC-runtime FFI, same spirit as
|
||||
/// `accessibility_trusted()` above: three runtime symbols + one framework
|
||||
/// constant, not worth a crate.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn microphone_auth_status() -> &'static str {
|
||||
use std::os::raw::{c_char, c_void};
|
||||
|
||||
#[link(name = "objc")]
|
||||
extern "C" {
|
||||
fn objc_getClass(name: *const c_char) -> *mut c_void;
|
||||
fn sel_registerName(name: *const c_char) -> *mut c_void;
|
||||
// Deliberately signature-less: objc_msgSend is variadic-by-convention
|
||||
// and must be cast to the concrete fn type per call site.
|
||||
fn objc_msgSend();
|
||||
}
|
||||
// Linking AVFoundation is what makes the AVCaptureDevice class and the
|
||||
// AVMediaTypeAudio NSString constant exist at runtime.
|
||||
#[link(name = "AVFoundation", kind = "framework")]
|
||||
extern "C" {
|
||||
#[allow(non_upper_case_globals)]
|
||||
static AVMediaTypeAudio: *mut c_void;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let cls = objc_getClass(c"AVCaptureDevice".as_ptr());
|
||||
if cls.is_null() {
|
||||
return "unknown";
|
||||
}
|
||||
let sel = sel_registerName(c"authorizationStatusForMediaType:".as_ptr());
|
||||
let msg_send: extern "C" fn(*mut c_void, *mut c_void, *mut c_void) -> isize =
|
||||
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
|
||||
// AVAuthorizationStatus: 0 notDetermined, 1 restricted, 2 denied,
|
||||
// 3 authorized. Anything newer/unexpected is honestly "unknown".
|
||||
match msg_send(cls, sel, AVMediaTypeAudio) {
|
||||
0 => "prompt",
|
||||
1 | 2 => "denied",
|
||||
3 => "granted",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows microphone consent from the CapabilityAccessManager ConsentStore.
|
||||
/// A desktop (unpackaged) app's getUserMedia is gated by TWO per-user (HKCU)
|
||||
/// toggles: the master "Microphone access" switch (the ConsentStore key
|
||||
/// itself) AND "Let desktop apps access your microphone" (the `NonPackaged`
|
||||
/// subkey). Reading only the master used to report "granted" while the
|
||||
/// desktop-app toggle silently blocked capture — so the probe reads the whole
|
||||
/// effective chain: denied if EITHER is Deny, granted only when BOTH read
|
||||
/// Allow, otherwise honestly "unknown" (key missing on older builds, or an
|
||||
/// unexpected value).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn microphone_consent_from_registry() -> &'static str {
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows::Win32::System::Registry::{RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_SZ};
|
||||
|
||||
// "Allow" / "Deny" / "Prompt" — 16 UTF-16 units is plenty; RegGetValueW
|
||||
// writes a NUL-terminated string and `size` is in bytes. None = key or
|
||||
// value missing / unreadable.
|
||||
fn read_consent(subkey: PCWSTR) -> Option<String> {
|
||||
let mut buf = [0u16; 16];
|
||||
let mut size = (buf.len() * std::mem::size_of::<u16>()) as u32;
|
||||
let status = unsafe {
|
||||
RegGetValueW(
|
||||
HKEY_CURRENT_USER,
|
||||
subkey,
|
||||
w!("Value"),
|
||||
RRF_RT_REG_SZ,
|
||||
None,
|
||||
Some(buf.as_mut_ptr().cast()),
|
||||
Some(&mut size),
|
||||
)
|
||||
};
|
||||
if status != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
Some(String::from_utf16_lossy(&buf[..len]))
|
||||
}
|
||||
|
||||
let master = read_consent(w!(
|
||||
r"Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone"
|
||||
));
|
||||
let non_packaged = read_consent(w!(
|
||||
r"Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone\NonPackaged"
|
||||
));
|
||||
|
||||
let is_deny = |v: &Option<String>| matches!(v.as_deref(), Some("Deny"));
|
||||
let is_allow = |v: &Option<String>| matches!(v.as_deref(), Some("Allow"));
|
||||
if is_deny(&master) || is_deny(&non_packaged) {
|
||||
return "denied";
|
||||
}
|
||||
if is_allow(&master) && is_allow(&non_packaged) {
|
||||
return "granted";
|
||||
}
|
||||
// Either toggle missing (older Windows builds) or an unexpected value —
|
||||
// don't guess.
|
||||
"unknown"
|
||||
}
|
||||
|
||||
/// Microphone permission state: "granted" | "denied" | "prompt" | "unknown".
|
||||
/// macOS reads the TCC grant via AVFoundation; Windows reads the per-user
|
||||
/// ConsentStore toggle; Linux is always "unknown" (PulseAudio/PipeWire has no
|
||||
/// per-app mic permission and we don't use the portal).
|
||||
#[tauri::command]
|
||||
pub fn check_microphone() -> String {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
microphone_auth_status().to_string()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
microphone_consent_from_registry().to_string()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-link into the OS microphone-privacy pane. Errors use the same
|
||||
/// `kind:detail` convention as the paste commands ("settings:" kind) so the
|
||||
/// JS side can switch on `err.split(':')[0]`.
|
||||
#[tauri::command]
|
||||
pub fn open_microphone_settings() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| kind_err("settings", format!("failed to open microphone settings: {e}")))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// `start` is a cmd builtin — there's no ms-settings executable to
|
||||
// spawn directly. CREATE_NO_WINDOW stops the cmd console flash
|
||||
// (same pattern as the nvidia-smi probe in setup.rs).
|
||||
use std::os::windows::process::CommandExt;
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "ms-settings:privacy-microphone"])
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| kind_err("settings", format!("failed to open microphone settings: {e}")))
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
// No per-app mic permission pane exists on Linux — an xdg-open target
|
||||
// would be a guess that varies by desktop. Err so the JS side can show
|
||||
// "open your system sound settings" instead of pretending we did.
|
||||
Err(kind_err(
|
||||
"settings",
|
||||
"no microphone permission pane on this OS; open your system sound settings",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-link into macOS Privacy → Input Monitoring (the grant global-shortcut
|
||||
/// key listening needs on newer macOS). macOS-only: no such pane exists
|
||||
/// elsewhere, so other OSes get a "settings:" Err rather than a silent no-op.
|
||||
#[tauri::command]
|
||||
pub fn open_input_monitoring_settings() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| {
|
||||
kind_err("settings", format!("failed to open input monitoring settings: {e}"))
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
Err(kind_err(
|
||||
"settings",
|
||||
"input monitoring settings are macOS-only",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
|
||||
// macOS: fail loud BEFORE touching the clipboard if Accessibility isn't
|
||||
|
||||
@@ -410,6 +410,9 @@ pub fn run() {
|
||||
commands::simulate_type,
|
||||
commands::check_accessibility,
|
||||
commands::open_accessibility_settings,
|
||||
commands::check_microphone,
|
||||
commands::open_microphone_settings,
|
||||
commands::open_input_monitoring_settings,
|
||||
commands::set_tray_recording,
|
||||
commands::quit_app,
|
||||
commands::save_text_file,
|
||||
|
||||
@@ -371,7 +371,14 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
|
||||
typeof detail === 'string' && detail.toLowerCase().includes('api key') ? 'apikey' : 'pin';
|
||||
window.dispatchEvent(new CustomEvent('ov:auth-required', { detail: { mode } }));
|
||||
}
|
||||
throw new ApiError(`${res.status} ${res.statusText}: ${detail}`, {
|
||||
// Structured details (e.g. the typed asr_model_missing 409) carry a
|
||||
// human-readable `message` — use it for the Error message instead of
|
||||
// letting the object stringify to "[object Object]".
|
||||
const msg =
|
||||
typeof detail === 'string'
|
||||
? detail
|
||||
: ((detail as { message?: string })?.message ?? JSON.stringify(detail));
|
||||
throw new ApiError(`${res.status} ${res.statusText}: ${msg}`, {
|
||||
status: res.status,
|
||||
detail,
|
||||
});
|
||||
|
||||
@@ -32,8 +32,21 @@ interface KnownModel {
|
||||
required: boolean;
|
||||
note?: string;
|
||||
installed: boolean;
|
||||
/** Truncated download on disk (config landed, weight shard didn't). */
|
||||
incomplete?: boolean;
|
||||
size_on_disk_bytes: number;
|
||||
nb_files: number;
|
||||
/** False when the model can't run on this host (`platforms` in models.yaml). */
|
||||
supported?: boolean;
|
||||
/** Curated "best for your system" pick (`curated_on` in models.yaml) —
|
||||
* drives the recommended badge in the wizard and Settings model store. */
|
||||
curated?: boolean;
|
||||
platforms?: string[];
|
||||
/** Dictation runtime marker (`engine: sherpa-onnx` in models.yaml). */
|
||||
engine?: string;
|
||||
dictation_id?: string;
|
||||
/** Dictation mode: 'offline' | 'streaming'. */
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface ModelList {
|
||||
@@ -43,6 +56,8 @@ export interface ModelList {
|
||||
/** Free space on the cache volume — surfaced in the Model Store header so an
|
||||
* "Install all" can't silently overrun the disk. */
|
||||
disk_free_gb?: number;
|
||||
/** Host platform tags (e.g. ['darwin', 'darwin-arm64']). */
|
||||
platform_tags?: string[];
|
||||
}
|
||||
|
||||
export async function listModels(): Promise<ModelList> {
|
||||
|
||||
@@ -7,7 +7,10 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { wsUrl as buildWsUrl, apiFetch } from '../api/client';
|
||||
import { addTranscription } from '../pages/Transcriptions';
|
||||
import { micErrorMessage } from '../utils/micError';
|
||||
import { describeMicError, detectPlatform, micErrorMessage, micHintKey } from '../utils/micError';
|
||||
import { checkMicrophone, openMicrophoneSettings } from '../utils/permissions';
|
||||
import { showMicDeniedGuide } from '../utils/micDeniedToast';
|
||||
import { asrMissingPayload, toastAsrModelMissing } from '../utils/asrModelMissing';
|
||||
import { createWaveform } from './captureWaveform';
|
||||
|
||||
// True inside the Tauri shell (desktop app / widget window); false in the
|
||||
@@ -688,6 +691,23 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
// Pre-flight: when the OS itself reports the mic grant as DENIED,
|
||||
// getUserMedia can only throw an opaque NotAllowedError — skip it and
|
||||
// show the guided path (per-OS hint + Open Settings deep-link) instead.
|
||||
// 'prompt'/'granted'/'unknown' proceed exactly as before (getUserMedia
|
||||
// raises the OS prompt; micError.js stays the reactive fallback), and
|
||||
// outside Tauri checkMicrophone() is always 'unknown' → unchanged.
|
||||
if ((await checkMicrophone()) === 'denied') {
|
||||
showMicDeniedGuide(t);
|
||||
setTrayRecording(false);
|
||||
setErrorInfo({
|
||||
kind: 'mic',
|
||||
message: t(micHintKey(detectPlatform())),
|
||||
deniedByOs: true,
|
||||
});
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 },
|
||||
@@ -876,7 +896,18 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
}
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
if (sherpaModeRef.current || aecModeRef.current) {
|
||||
if (asrMissingPayload(msg)) {
|
||||
// Typed preflight: no ASR model installed. The POST fallback
|
||||
// would hit the same 409, so don't re-send — render the
|
||||
// download CTA and resolve the pill into its error state.
|
||||
wsHadFinalRef.current = true;
|
||||
stopCaptureGraph();
|
||||
setTrayRecording(false);
|
||||
setModelStatus(null);
|
||||
toastAsrModelMissing(asrMissingPayload(msg));
|
||||
setErrorInfo({ kind: 'transcription', message: t('asr_missing.message') });
|
||||
setState('error');
|
||||
} else if (sherpaModeRef.current || aecModeRef.current) {
|
||||
// Raw-PCM paths have no WebM blob to re-POST — surface the
|
||||
// backend's error instead of leaving the pill wedged in
|
||||
// "Transcribing…" forever.
|
||||
@@ -1010,6 +1041,17 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
recorder.start(250);
|
||||
mediaRecorderRef.current = recorder;
|
||||
}
|
||||
// The session may already have RESOLVED while the mic graph was being
|
||||
// set up (the awaits above): a connect-time WS error frame (e.g. the
|
||||
// typed asr_model_missing preflight) or an Esc-cancel sets
|
||||
// wsHadFinalRef and renders the truthful terminal state. Entering
|
||||
// 'recording' now would clobber that state and — with the socket gone —
|
||||
// strand the next Stop on "Transcribing…" forever. Release the capture
|
||||
// inputs and leave the pill alone.
|
||||
if (wsHadFinalRef.current) {
|
||||
stopCaptureGraph();
|
||||
return;
|
||||
}
|
||||
startTimeRef.current = Date.now();
|
||||
setTrayRecording(true);
|
||||
setWaveOn(pcmMode);
|
||||
@@ -1026,7 +1068,13 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
// "no device" / "device busy" / anything else (#323).
|
||||
toast.error(micErrorMessage(t, err), { duration: 6000 });
|
||||
setTrayRecording(false);
|
||||
setErrorInfo({ kind: 'mic', message: String(err?.message || err) });
|
||||
setErrorInfo({
|
||||
kind: 'mic',
|
||||
message: String(err?.message || err),
|
||||
// Permission-denied errors (describeMicError sets a hintKey only for
|
||||
// those) get the pill's Open-Settings action inside Tauri.
|
||||
deniedByOs: !!describeMicError(err).hintKey,
|
||||
});
|
||||
setState('error');
|
||||
}
|
||||
}, [applyResult, finalizeSession, liveType, stopCaptureGraph, t]);
|
||||
@@ -1089,6 +1137,15 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
await applyResult(data);
|
||||
} catch (err) {
|
||||
if (wsHadFinalRef.current) return;
|
||||
const missing = asrMissingPayload(err);
|
||||
if (missing) {
|
||||
// Typed 409: no ASR model installed → download CTA, not a dead end.
|
||||
toastAsrModelMissing(missing);
|
||||
setErrorInfo({ kind: 'transcription', message: t('asr_missing.message') });
|
||||
setState('error');
|
||||
setTranscript('');
|
||||
return;
|
||||
}
|
||||
toast.error(t('capture.transcription_failed', { message: err.message }));
|
||||
setErrorInfo({ kind: 'transcription', message: err.message });
|
||||
setState('error');
|
||||
@@ -1138,6 +1195,10 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
}
|
||||
|
||||
const showA11yAction = state === 'setup' || (state === 'error' && errorInfo?.kind === 'a11y');
|
||||
// OS-level mic denial gets its own Open-Settings deep-link (Tauri only —
|
||||
// a browser denial has no OS pane we can open).
|
||||
const showMicAction =
|
||||
state === 'error' && errorInfo?.kind === 'mic' && errorInfo?.deniedByOs && inTauri();
|
||||
|
||||
return (
|
||||
<div className={`capture-pill capture-pill--${state}`} role="status" aria-live="polite">
|
||||
@@ -1188,6 +1249,21 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Microphone action — OS-denied mic errors deep-link the mic pane */}
|
||||
{showMicAction && (
|
||||
<button
|
||||
className="shrink-0 cursor-pointer whitespace-nowrap rounded-full border-0 bg-white/[0.1] px-2.5 py-1 text-[11px] font-medium text-white/90 transition-[background] duration-[0.15s] hover:bg-white/[0.18]"
|
||||
onClick={async () => {
|
||||
if (!(await openMicrophoneSettings())) {
|
||||
// Linux: no mic-privacy pane — point at system sound settings.
|
||||
toast(t('capture.mic_hint_linux'), { icon: 'ℹ️', duration: 8000 });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('permissions.open_settings')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dismiss — done/error/setup */}
|
||||
{(state === 'done' || state === 'error' || state === 'setup') && (
|
||||
<button
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Play, Pause, Keyboard, Mic, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, apiFetch } from '../api/client';
|
||||
import { asrMissingPayload, toastAsrModelMissing } from '../utils/asrModelMissing';
|
||||
import { Button } from '../ui';
|
||||
|
||||
// Shared status-pill base; per-state color/bg/border appended below. The gruvbox
|
||||
@@ -180,9 +181,18 @@ export default function DictationDemo({ embedded = false }) {
|
||||
[script.id]: { state: 'ok', text: json.text || '', error: '' },
|
||||
}));
|
||||
} catch (e) {
|
||||
// Typed 409 on a TTS-only install: no ASR model on disk. Render the
|
||||
// human message + the one-click download CTA instead of the raw
|
||||
// "409 Conflict: …" string.
|
||||
const missing = asrMissingPayload(e);
|
||||
if (missing) toastAsrModelMissing(missing);
|
||||
setTranscripts((prev) => ({
|
||||
...prev,
|
||||
[script.id]: { state: 'fail', text: '', error: e?.message || String(e) },
|
||||
[script.id]: {
|
||||
state: 'fail',
|
||||
text: '',
|
||||
error: missing ? t('asr_missing.message') : e?.message || String(e),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RotateCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, Button } from '../ui';
|
||||
import usePermissions from '../hooks/usePermissions';
|
||||
import { detectPlatform, micHintKey } from '../utils/micError';
|
||||
import { openAccessibilitySettings, openMicrophoneSettings } from '../utils/permissions';
|
||||
|
||||
/**
|
||||
* PermissionChecks — OS-permission rows for the SetupWizard's System Check
|
||||
* step: Microphone (all platforms) and Accessibility (macOS only), in the
|
||||
* same LED-row grammar as PreflightPanel. Renders nothing outside the Tauri
|
||||
* shell (browser web UI / Docker have no OS grants to probe).
|
||||
*
|
||||
* Status → chip: granted = ok, denied = warn (+ an Open Settings deep-link),
|
||||
* prompt/unknown = neutral. Status refreshes on window focus (the user comes
|
||||
* back from System Settings and the chip is already up to date) plus an
|
||||
* explicit Recheck button.
|
||||
*/
|
||||
|
||||
// Same LED classes as SetupWizard's CHECK_LED (kept local — the wizard is
|
||||
// concurrently edited and doesn't export them).
|
||||
const LED = {
|
||||
ok: 'bg-success shadow-[0_0_5px_1px_color-mix(in_srgb,var(--color-success)_50%,transparent)]',
|
||||
warn: 'bg-warn shadow-[0_0_5px_1px_color-mix(in_srgb,var(--color-warn)_50%,transparent)]',
|
||||
neutral: 'bg-fg-subtle/40',
|
||||
};
|
||||
|
||||
/** Map a mic-style status string to { led, badgeTone, labelKey }. */
|
||||
export function permissionChip(status) {
|
||||
if (status === 'granted') {
|
||||
return { led: 'ok', tone: 'success', labelKey: 'permissions.status_granted' };
|
||||
}
|
||||
if (status === 'denied') {
|
||||
return { led: 'warn', tone: 'warn', labelKey: 'permissions.status_denied' };
|
||||
}
|
||||
if (status === 'prompt') {
|
||||
return { led: 'neutral', tone: 'neutral', labelKey: 'permissions.status_prompt' };
|
||||
}
|
||||
return { led: 'neutral', tone: 'neutral', labelKey: 'permissions.status_unknown' };
|
||||
}
|
||||
|
||||
function CheckRow({ id, label, status, detail, onOpenSettings, t }) {
|
||||
const chip = permissionChip(status);
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md px-2.5 py-2" data-testid={`perm-row-${id}`}>
|
||||
<span
|
||||
className={cn('mt-1 h-1.5 w-1.5 shrink-0 rounded-full', LED[chip.led])}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold">
|
||||
{label}
|
||||
<Badge tone={chip.tone} data-testid={`perm-chip-${id}`}>
|
||||
{t(chip.labelKey)}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-xs leading-snug text-fg-muted">{detail}</span>
|
||||
{status === 'denied' && onOpenSettings && (
|
||||
<span>
|
||||
<Button variant="ghost" size="sm" onClick={onOpenSettings}>
|
||||
{t('permissions.open_settings')}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PermissionChecks({ platform = detectPlatform() }) {
|
||||
const { t } = useTranslation();
|
||||
const { available, mic, a11y, recheck } = usePermissions();
|
||||
if (!available) return null; // browser/dev — nothing to probe, no noise
|
||||
|
||||
return (
|
||||
<section className="mt-5 flex flex-col gap-2.5" data-testid="permission-checks">
|
||||
<h2 className="m-0 flex items-center gap-2 font-mono text-[0.62rem] font-semibold uppercase tracking-[0.18em] text-fg-muted">
|
||||
<span>{t('permissions.title')}</span>
|
||||
<span
|
||||
className="h-px flex-1 bg-gradient-to-r from-border-strong to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={recheck} leading={<RotateCw size={12} />}>
|
||||
{t('setup.recheck')}
|
||||
</Button>
|
||||
</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] items-start gap-x-6 gap-y-2">
|
||||
<CheckRow
|
||||
id="microphone"
|
||||
label={t('permissions.microphone')}
|
||||
status={mic}
|
||||
// Granted → say what it's for; anything else → the per-OS fix path.
|
||||
detail={mic === 'granted' ? t('permissions.microphone_why') : t(micHintKey(platform))}
|
||||
onOpenSettings={async () => {
|
||||
if (!(await openMicrophoneSettings())) {
|
||||
// Linux: no mic-privacy pane — the row hint already points at
|
||||
// the system sound settings, nothing else to open.
|
||||
recheck();
|
||||
}
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
{platform === 'mac' && (
|
||||
<CheckRow
|
||||
id="accessibility"
|
||||
label={t('permissions.accessibility')}
|
||||
status={a11y ? 'granted' : 'denied'}
|
||||
detail={a11y ? t('permissions.accessibility_why') : t('capture.a11y_setup')}
|
||||
onOpenSettings={() => openAccessibilitySettings()}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,21 @@ export function isPlatformPick(model, platformTags) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A model is "recommended" for this host when the backend marks it curated
|
||||
* (`curated_on` in models.yaml — the same signal GET /setup/recommendations
|
||||
* and the Settings model store use), so the wizard and the store can never
|
||||
* disagree about what "recommended" means. Falls back to the platform-tag
|
||||
* heuristic for older backends whose /models rows carry no `curated` flag.
|
||||
* Required models are excluded — they already wear the stronger chip.
|
||||
* Pure + exported for unit tests.
|
||||
*/
|
||||
export function isRecommendedPick(model, platformTags) {
|
||||
if (!model || model.required) return false;
|
||||
if (typeof model.curated === 'boolean') return model.curated;
|
||||
return isPlatformPick(model, platformTags);
|
||||
}
|
||||
|
||||
/** Overall progress from the backend's authoritative `aggregate` SSE event.
|
||||
* This is the TRUSTWORTHY source: under parallel/segmented fetch the per-file
|
||||
* tqdm events are unreliable (big weight shards may report total/rate as 0),
|
||||
@@ -193,7 +208,7 @@ const LED_TONE = {
|
||||
// Chip Badge tone per chip category.
|
||||
const CHIP_TONE = { req: 'brand', rec: 'success', eng: 'neutral', opt: 'neutral' };
|
||||
|
||||
function Row({ led, name, chip, chipTone, size, action, sub }) {
|
||||
function Row({ led, name, chip, chipTone, chipTitle, size, action, sub }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-bg-elev-3">
|
||||
<span
|
||||
@@ -204,7 +219,7 @@ function Row({ led, name, chip, chipTone, size, action, sub }) {
|
||||
<span className="flex items-center gap-2 text-sm font-semibold">
|
||||
{name}
|
||||
{chip && (
|
||||
<Badge tone={CHIP_TONE[chipTone] || 'neutral'} size="xs">
|
||||
<Badge tone={CHIP_TONE[chipTone] || 'neutral'} size="xs" title={chipTitle}>
|
||||
{chip}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -310,12 +325,13 @@ export default function WizardLibrary() {
|
||||
const supported = models.filter((m) => m.supported !== false);
|
||||
const required = supported.filter((m) => m.required);
|
||||
const optionalAll = supported.filter((m) => !m.required);
|
||||
// Platform-tuned optionals lead (shown by default); the universal long tail
|
||||
// still folds behind a quiet count.
|
||||
const platformPicks = optionalAll.filter((m) => isPlatformPick(m, platformTags));
|
||||
const tail = optionalAll.filter((m) => !isPlatformPick(m, platformTags));
|
||||
// Curated "best for your system" optionals lead (shown by default with the
|
||||
// recommended chip — same `curated` signal the Settings model store badges);
|
||||
// the universal long tail still folds behind a quiet count.
|
||||
const platformPicks = optionalAll.filter((m) => isRecommendedPick(m, platformTags));
|
||||
const tail = optionalAll.filter((m) => !isRecommendedPick(m, platformTags));
|
||||
|
||||
const modelRow = (m, chip, chipTone, note) => {
|
||||
const modelRow = (m, chip, chipTone, note, chipTitle) => {
|
||||
const p = progress[m.repo_id];
|
||||
// A failed install PERSISTS (P1-A): show the mirror-aware reason + a Retry
|
||||
// instead of the row silently vanishing.
|
||||
@@ -346,6 +362,7 @@ export default function WizardLibrary() {
|
||||
name={m.label}
|
||||
chip={chip}
|
||||
chipTone={chipTone}
|
||||
chipTitle={chipTitle}
|
||||
size={fmtGB(m.size_gb)}
|
||||
sub={
|
||||
errored ? (
|
||||
@@ -393,10 +410,17 @@ export default function WizardLibrary() {
|
||||
<div className="flex max-h-[min(56vh,620px)] flex-col gap-1 overflow-y-auto">
|
||||
{required.map((m) => modelRow(m, t('firstrun.chip_required', 'required'), 'req'))}
|
||||
|
||||
{/* Optional models tuned for THIS machine — shown by default with the
|
||||
catalog note explaining why (e.g. "5× faster on Apple Silicon"). */}
|
||||
{/* Curated optionals for THIS machine — shown by default with the
|
||||
catalog note explaining why (e.g. "5× faster on Apple Silicon").
|
||||
The chip tooltip spells out that these are optional. */}
|
||||
{platformPicks.map((m) =>
|
||||
modelRow(m, t('firstrun.chip_recommended', 'recommended'), 'rec', m.note),
|
||||
modelRow(
|
||||
m,
|
||||
t('firstrun.chip_recommended', 'recommended'),
|
||||
'rec',
|
||||
m.note,
|
||||
t('firstrun.chip_recommended_title', 'Recommended for this machine — optional'),
|
||||
),
|
||||
)}
|
||||
|
||||
{(engines?.backends ?? []).map((b) => (
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Cpu, RefreshCw, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -13,22 +6,23 @@ import { openExternal } from '../../api/external';
|
||||
import { setupDownloadStreamUrl } from '../../api/setup';
|
||||
import { listLoadedModels, unloadLoadedModel } from '../../api/system';
|
||||
import { useModels, useRecommendations, useInstallModel, useDeleteModel } from '../../api/hooks';
|
||||
import { Button, Segmented } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { SettingsSection, SettingsInput, SETTINGS_SECTION_SURFACE } from './primitives';
|
||||
import { askConfirm } from './native';
|
||||
import { fmtBytes } from './models/format';
|
||||
import { computeRowRuntime } from './models/runtime';
|
||||
import { reduceModelDownloadEvent, isAutoPurgeTerminal } from './models/downloadReducer';
|
||||
import { makeModelColumns } from './models/columns';
|
||||
import { groupModels } from './models/sections';
|
||||
import RecoBanner from './models/RecoBanner';
|
||||
import ModelsTable from './models/ModelsTable';
|
||||
|
||||
const MODEL_ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm'];
|
||||
import ModelSection from './models/ModelSection';
|
||||
|
||||
/**
|
||||
* Model store — list every known HF model, show install state, let the
|
||||
* user install / reinstall / delete individual models. Per-model download
|
||||
* progress is pulled from the shared /setup/download-stream SSE.
|
||||
* Model store — every known HF model, grouped by capability (TTS / ASR /
|
||||
* Dictation / Diarisation), with install state and install / reinstall /
|
||||
* delete per row. The curated "for your system" preset leads (RecoBanner);
|
||||
* platform-incompatible rows collapse behind a per-section toggle. Per-model
|
||||
* download progress is pulled from the shared /setup/download-stream SSE.
|
||||
*/
|
||||
export default function ModelStoreTab({ info, modelBadge }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -61,11 +55,7 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
const [rowState, setRowState] = useState({});
|
||||
const [query, setQuery] = useState('');
|
||||
const [installingReco, setInstallingReco] = useState(false);
|
||||
const [activeRole, setActiveRole] = useState(null);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
const [columnFilters, setColumnFilters] = useState([]);
|
||||
const esRef = React.useRef(null);
|
||||
const tableBodyRef = React.useRef(null);
|
||||
// Track download speed per repo: { [repo_id]: { lastBytes, lastTime, speed } }
|
||||
const speedRef = React.useRef({});
|
||||
// Tick counter — forces re-render every second while a download is active
|
||||
@@ -316,25 +306,20 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
};
|
||||
|
||||
const allModels = React.useMemo(() => data?.models || [], [data]);
|
||||
const groups = allModels.reduce((acc, m) => {
|
||||
const k = (m.role || 'other').toLowerCase();
|
||||
(acc[k] = acc[k] || []).push(m);
|
||||
return acc;
|
||||
}, {});
|
||||
const roles = Object.keys(groups).sort((a, b) => {
|
||||
const ai = MODEL_ROLE_ORDER.indexOf(a),
|
||||
bi = MODEL_ROLE_ORDER.indexOf(b);
|
||||
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
|
||||
});
|
||||
// 'all' is a virtual role — shows every model regardless of category.
|
||||
const currentRole =
|
||||
activeRole === 'all' ? 'all' : activeRole && groups[activeRole] ? activeRole : 'all';
|
||||
|
||||
const allInstalled = allModels.filter((m) => m.installed).length;
|
||||
|
||||
useEffect(() => {
|
||||
setColumnFilters(currentRole === 'all' ? [] : [{ id: 'role', value: currentRole }]);
|
||||
}, [currentRole]);
|
||||
// Grouped catalog: TTS / ASR (offline transcription) / Dictation (streaming)
|
||||
// / Diarisation, with the search query applied per-section (pure helper —
|
||||
// matches the same fields the old global filter did).
|
||||
const sections = React.useMemo(() => groupModels(allModels, query), [allModels, query]);
|
||||
const MODEL_SECTION_LABEL = useMemo(
|
||||
() => ({
|
||||
tts: t('models.section_tts'),
|
||||
asr: t('models.section_asr'),
|
||||
dictation: t('models.section_dictation'),
|
||||
diarisation: t('models.section_diarisation'),
|
||||
other: t('models.section_other'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const getRowRuntime = React.useCallback(
|
||||
(m) => computeRowRuntime(m, rowState, busy),
|
||||
@@ -370,43 +355,6 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: allModels,
|
||||
columns,
|
||||
getRowId: (row) => row.repo_id,
|
||||
state: {
|
||||
sorting,
|
||||
globalFilter: query,
|
||||
columnFilters,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setQuery,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
globalFilterFn: (row, _columnId, value) => {
|
||||
const q = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!q) return true;
|
||||
const m = row.original;
|
||||
return [m.repo_id, m.label, m.note, m.role]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q));
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
const tableRows = table.getRowModel().rows;
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: tableRows.length,
|
||||
getScrollElement: () => tableBodyRef.current,
|
||||
// Matches the compact two-line .models-row min-height (52px) — rows with
|
||||
// a live progress/error block re-measure and grow past this.
|
||||
estimateSize: () => 54,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<SettingsSection icon={Cpu} title={t('settings.models')}>
|
||||
@@ -531,29 +479,12 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
installingReco={installingReco}
|
||||
setInstallingReco={setInstallingReco}
|
||||
onInstallRecommended={onInstallRecommended}
|
||||
onInstall={onInstall}
|
||||
getRowRuntime={getRowRuntime}
|
||||
diskFreeGb={data.disk_free_gb}
|
||||
/>
|
||||
|
||||
<div className="my-[var(--space-2)] flex items-center gap-[var(--space-2)] max-[580px]:flex-col max-[580px]:items-stretch">
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={currentRole}
|
||||
onChange={setActiveRole}
|
||||
className="mb-[6px] mt-[4px]"
|
||||
items={[
|
||||
{
|
||||
value: 'all',
|
||||
label: `All ${allInstalled}/${allModels.length}`,
|
||||
},
|
||||
...roles.map((r) => {
|
||||
const installed = groups[r].filter((m) => m.installed).length;
|
||||
return {
|
||||
value: r,
|
||||
label: `${MODEL_ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`,
|
||||
};
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
<SettingsInput
|
||||
type="search"
|
||||
className="max-w-none flex-1 text-[length:var(--text-xs)] min-w-[120px]"
|
||||
@@ -564,18 +495,33 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelsTable
|
||||
table={table}
|
||||
tableRows={tableRows}
|
||||
rowVirtualizer={rowVirtualizer}
|
||||
tableBodyRef={tableBodyRef}
|
||||
getRowRuntime={getRowRuntime}
|
||||
t={t}
|
||||
onClearFilters={() => {
|
||||
setQuery('');
|
||||
setActiveRole('all');
|
||||
}}
|
||||
/>
|
||||
{sections.map((group) => (
|
||||
<ModelSection
|
||||
key={group.key}
|
||||
sectionKey={group.key}
|
||||
title={MODEL_SECTION_LABEL[group.key] || group.key}
|
||||
group={group}
|
||||
columns={columns}
|
||||
getRowRuntime={getRowRuntime}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{/* Global empty state — every section filtered out. Same actionable
|
||||
"Clear filters" affordance the table-level empty state used to carry. */}
|
||||
{sections.length === 0 && allModels.length > 0 && (
|
||||
<div className="models-table__empty">
|
||||
<span>{t('models.no_matches')}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
className="ml-[8px]"
|
||||
onClick={() => setQuery('')}
|
||||
data-testid="models-clear-filters"
|
||||
>
|
||||
{t('models.clear_filters')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertCircle, CheckCircle, Keyboard, LockKeyhole, Mic, RotateCw } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Badge, Button } from '../../ui';
|
||||
import { SettingsSection, SettingRow } from './primitives';
|
||||
import usePermissions from '../../hooks/usePermissions';
|
||||
import { detectPlatform, micHintKey } from '../../utils/micError';
|
||||
import { openAccessibilitySettings, openMicrophoneSettings } from '../../utils/permissions';
|
||||
|
||||
/**
|
||||
* PermissionsPanel — Settings → Permissions: live OS-permission status for
|
||||
* the grants OmniVoice's default features actually use, with a deep-link
|
||||
* into the exact OS settings pane when one is denied.
|
||||
*
|
||||
* • Microphone — dictation + voice recording (all platforms; Linux has no
|
||||
* per-app mic grant, so the status is honestly "Unknown" there).
|
||||
* • Accessibility (macOS only) — lets dictation paste/type the transcript
|
||||
* into other apps.
|
||||
*
|
||||
* Input Monitoring is deliberately NOT listed: the global dictation shortcut
|
||||
* registers via tauri-plugin-global-shortcut (Carbon hotkey registration on
|
||||
* macOS), which works without that grant.
|
||||
*
|
||||
* Status re-probes on window focus (usePermissions) — flip the toggle in
|
||||
* System Settings, come back, the chip is already green — plus an explicit
|
||||
* Recheck action. Outside the Tauri shell there is no OS state to read, so
|
||||
* the panel explains that instead of guessing.
|
||||
*/
|
||||
|
||||
function StatusChip({ id, status, t }) {
|
||||
if (status === 'granted') {
|
||||
return (
|
||||
<Badge tone="success" data-testid={`perm-chip-${id}`}>
|
||||
<CheckCircle size={11} /> {t('permissions.status_granted')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === 'denied') {
|
||||
return (
|
||||
<Badge tone="warn" data-testid={`perm-chip-${id}`}>
|
||||
<AlertCircle size={11} /> {t('permissions.status_denied')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge tone="neutral" data-testid={`perm-chip-${id}`}>
|
||||
{t(status === 'prompt' ? 'permissions.status_prompt' : 'permissions.status_unknown')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PermissionsPanel({ platform = detectPlatform() }) {
|
||||
const { t } = useTranslation();
|
||||
const { available, mic, a11y, recheck } = usePermissions();
|
||||
|
||||
const openMicSettings = async () => {
|
||||
if (!(await openMicrophoneSettings())) {
|
||||
// Linux: no per-app mic-privacy pane exists — point at the system
|
||||
// sound settings instead of pretending the deep-link worked.
|
||||
toast(t('capture.mic_hint_linux'), { icon: 'ℹ️', duration: 8000 });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={LockKeyhole}
|
||||
title={t('permissions.title')}
|
||||
description={t('permissions.desc')}
|
||||
actions={
|
||||
available ? (
|
||||
<Button variant="ghost" size="sm" onClick={recheck} leading={<RotateCw size={12} />}>
|
||||
{t('setup.recheck')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{!available ? (
|
||||
<p className="m-0 font-sans text-[var(--text-sm)] leading-[1.6] text-[var(--chrome-fg-muted)]">
|
||||
{t('permissions.web_note')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow
|
||||
icon={Mic}
|
||||
title={t('permissions.microphone')}
|
||||
// Denied → the actionable per-OS path beats the generic "why".
|
||||
subtitle={mic === 'denied' ? t(micHintKey(platform)) : t('permissions.microphone_why')}
|
||||
control={
|
||||
<>
|
||||
<StatusChip id="microphone" status={mic} t={t} />
|
||||
{mic === 'denied' && (
|
||||
<Button variant="ghost" size="sm" onClick={openMicSettings}>
|
||||
{t('permissions.open_settings')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{platform === 'mac' && (
|
||||
<SettingRow
|
||||
icon={Keyboard}
|
||||
title={t('permissions.accessibility')}
|
||||
subtitle={a11y ? t('permissions.accessibility_why') : t('capture.a11y_setup')}
|
||||
control={
|
||||
<>
|
||||
<StatusChip id="accessibility" status={a11y ? 'granted' : 'denied'} t={t} />
|
||||
{!a11y && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openAccessibilitySettings()}>
|
||||
{t('permissions.open_settings')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Button } from '../../../ui';
|
||||
import ModelsTable from './ModelsTable';
|
||||
|
||||
/**
|
||||
* One role section of the grouped Model Store catalog (TTS / ASR / Dictation /
|
||||
* Diarisation). Hosts its own table instance + virtualizer over the section's
|
||||
* rows; platform-incompatible rows (`supported === false`) stay behind a
|
||||
* per-section "Show incompatible (N)" toggle (default collapsed) instead of
|
||||
* rendering greyed-out inline. All per-row functionality (install / delete /
|
||||
* progress / incomplete-repair) rides the shared column definitions.
|
||||
*/
|
||||
export default function ModelSection({ sectionKey, title, group, columns, getRowRuntime, t }) {
|
||||
const { compatible, incompatible } = group;
|
||||
const [showIncompatible, setShowIncompatible] = useState(false);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
const tableBodyRef = useRef(null);
|
||||
|
||||
const data = useMemo(
|
||||
() => (showIncompatible ? [...compatible, ...incompatible] : compatible),
|
||||
[compatible, incompatible, showIncompatible],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getRowId: (row) => row.repo_id,
|
||||
state: {
|
||||
sorting,
|
||||
// Inside a role section the Role column is redundant noise — hide it.
|
||||
columnVisibility: { role: false },
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
const tableRows = table.getRowModel().rows;
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: tableRows.length,
|
||||
getScrollElement: () => tableBodyRef.current,
|
||||
estimateSize: () => 54,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const installed = group.models.filter((m) => m.installed).length;
|
||||
|
||||
return (
|
||||
<div className="mt-[var(--space-3)]" data-testid={`models-section-${sectionKey}`}>
|
||||
<div className="mb-[4px] flex items-baseline gap-[var(--space-2)] px-[2px]">
|
||||
<span className="text-[length:var(--text-sm)] font-semibold text-[var(--chrome-fg)]">
|
||||
{title}
|
||||
</span>
|
||||
<span className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-2xs)] text-[var(--chrome-fg-dim)]">
|
||||
{installed}/{group.models.length}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{incompatible.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowIncompatible((v) => !v)}
|
||||
leading={showIncompatible ? <EyeOff size={11} /> : <Eye size={11} />}
|
||||
title={t('models.incompatible_title')}
|
||||
data-testid={`models-incompatible-toggle-${sectionKey}`}
|
||||
>
|
||||
{showIncompatible
|
||||
? t('models.hide_incompatible', { count: incompatible.length })
|
||||
: t('models.show_incompatible', { count: incompatible.length })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{tableRows.length > 0 && (
|
||||
<ModelsTable
|
||||
table={table}
|
||||
tableRows={tableRows}
|
||||
rowVirtualizer={rowVirtualizer}
|
||||
tableBodyRef={tableBodyRef}
|
||||
getRowRuntime={getRowRuntime}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import { RefreshCw, CheckCircle } from 'lucide-react';
|
||||
import { RefreshCw, CheckCircle, Download } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Button } from '../../../ui';
|
||||
|
||||
/**
|
||||
* Recommendation banner — shows the device's recommended model set and lets the
|
||||
* user kick off the required / all installs. Purely presentational; all state
|
||||
* and mutations are supplied by the host ModelStoreTab.
|
||||
* "For your system" banner — the device's curated model preset (GET
|
||||
* /setup/recommendations): device label as title, the preset rationale as
|
||||
* caption, one-click install per row, plus required-only and
|
||||
* download-all-remaining bulk actions. Purely presentational; all state and
|
||||
* mutations are supplied by the host ModelStoreTab.
|
||||
*/
|
||||
export default function RecoBanner({
|
||||
reco,
|
||||
@@ -15,6 +17,12 @@ export default function RecoBanner({
|
||||
installingReco,
|
||||
setInstallingReco,
|
||||
onInstallRecommended,
|
||||
// One-click single-model install (host's withBusy flow). Optional — legacy
|
||||
// callers without it render the rows without the per-row action.
|
||||
onInstall,
|
||||
// Live per-repo runtime (SSE download state) so a row's install button flips
|
||||
// to a busy state while its download runs. Optional.
|
||||
getRowRuntime,
|
||||
// Free space (GB) on the model-cache volume, from GET /models — gives the
|
||||
// download buttons context and warns BEFORE a doomed multi-GB download.
|
||||
diskFreeGb = null,
|
||||
@@ -33,6 +41,15 @@ export default function RecoBanner({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Any per-row install in flight? Bulk actions are disabled while one runs
|
||||
// (and rows are disabled while a bulk runs) so the same repo can't be
|
||||
// double-installed from both paths.
|
||||
const anyRowActive =
|
||||
!!getRowRuntime &&
|
||||
reco.models.some((m) => {
|
||||
const rt = getRowRuntime(m);
|
||||
return !!(rt?.showBar || rt?.isInstalling || rt?.rowBusy);
|
||||
});
|
||||
return (
|
||||
<div className="mb-[var(--space-2)] flex flex-col items-stretch gap-[var(--space-2)] rounded-[var(--chrome-radius-pill)] [border:1px_solid] [border-left-width:2px] [border-color:color-mix(in_srgb,#f3a5b6_25%,transparent)] [border-left-color:#f3a5b6] bg-[linear-gradient(135deg,color-mix(in_srgb,#f3a5b6_4%,transparent),color-mix(in_srgb,#d3869b_2%,transparent))] px-[var(--space-4)] pb-[var(--space-4)] pt-[var(--space-3)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] shadow-[0_0_12px_color-mix(in_srgb,#f3a5b6_6%,transparent)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -63,7 +80,7 @@ export default function RecoBanner({
|
||||
setInstallingReco(false);
|
||||
}
|
||||
}}
|
||||
disabled={installingReco}
|
||||
disabled={installingReco || anyRowActive}
|
||||
leading={installingReco ? <RefreshCw size={12} className="spinner" /> : null}
|
||||
>
|
||||
{installingReco
|
||||
@@ -76,12 +93,22 @@ export default function RecoBanner({
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onInstallRecommended}
|
||||
disabled={installingReco}
|
||||
disabled={installingReco || anyRowActive}
|
||||
title={t('models.download_all_remaining_title')}
|
||||
>
|
||||
{t('models.all_size', { size: reco.download_gb_remaining })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Why these picks — the backend's per-platform rationale. */}
|
||||
{reco.rationale && (
|
||||
<p
|
||||
className="m-0 -mt-[2px] text-[length:var(--text-xs)] leading-[1.5] text-[var(--chrome-fg-muted)]"
|
||||
data-testid="reco-rationale"
|
||||
>
|
||||
{reco.rationale}
|
||||
</p>
|
||||
)}
|
||||
{/* Disk context next to the download actions: how much room the
|
||||
download has, and a plain warning when it won't fit. */}
|
||||
{diskFreeGb != null && (
|
||||
@@ -105,24 +132,51 @@ export default function RecoBanner({
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-x-[var(--space-5)] gap-y-0 text-[length:var(--text-sm)] leading-[1.6]">
|
||||
{reco.models.map((m) => (
|
||||
<span
|
||||
key={m.repo_id}
|
||||
className={`inline-flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap ${
|
||||
m.installed ? 'text-[var(--chrome-fg)]' : 'text-[var(--chrome-fg-muted)]'
|
||||
}`}
|
||||
>
|
||||
{m.installed ? '✓' : '○'} {m.label}
|
||||
<span className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-2xs)] text-[var(--chrome-fg-dim)]">
|
||||
{m.size_gb}
|
||||
</span>
|
||||
{m.required && (
|
||||
<span className="rounded-[999px] [border:1px_solid_color-mix(in_srgb,#d3869b_30%,transparent)] px-[3px] py-0 text-[length:var(--text-2xs)] uppercase leading-[1.5] tracking-[0.04em] text-[#d3869b]">
|
||||
{t('models.req_tag')}
|
||||
{reco.models.map((m) => {
|
||||
const rt = getRowRuntime?.(m);
|
||||
const rowActive = !!(rt?.showBar || rt?.isInstalling || rt?.rowBusy);
|
||||
return (
|
||||
<span
|
||||
key={m.repo_id}
|
||||
className={`inline-flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap ${
|
||||
m.installed ? 'text-[var(--chrome-fg)]' : 'text-[var(--chrome-fg-muted)]'
|
||||
}`}
|
||||
>
|
||||
{m.installed ? '✓' : '○'} {m.label}
|
||||
<span className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-2xs)] text-[var(--chrome-fg-dim)]">
|
||||
{m.size_gb}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{m.required && (
|
||||
<span className="rounded-[999px] [border:1px_solid_color-mix(in_srgb,#d3869b_30%,transparent)] px-[3px] py-0 text-[length:var(--text-2xs)] uppercase leading-[1.5] tracking-[0.04em] text-[#d3869b]">
|
||||
{t('models.req_tag')}
|
||||
</span>
|
||||
)}
|
||||
{/* One-click install for a single missing pick (reuses the host's
|
||||
install flow — progress shows on the catalog row below too). */}
|
||||
{!m.installed &&
|
||||
onInstall &&
|
||||
(rowActive ? (
|
||||
<RefreshCw
|
||||
size={10}
|
||||
className="spinner shrink-0 text-[var(--chrome-accent)]"
|
||||
aria-label={t('models.downloading')}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="icon"
|
||||
iconSize="sm"
|
||||
onClick={() => onInstall(m.repo_id)}
|
||||
disabled={installingReco}
|
||||
title={t('models.install_btn')}
|
||||
aria-label={t('models.reco_install_one', { label: m.label })}
|
||||
data-testid={`reco-install-${m.repo_id}`}
|
||||
>
|
||||
<Download size={11} />
|
||||
</Button>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,6 +51,17 @@ export function makeModelColumns({
|
||||
</span>
|
||||
{m.label}
|
||||
{m.required && <span className="models-row__tag">{t('models.required_tag')}</span>}
|
||||
{/* Curated "best for your system" pick (curated_on in models.yaml).
|
||||
Required rows already carry the stronger "required" tag. */}
|
||||
{!m.required && m.curated && (
|
||||
<span
|
||||
className="models-row__tag models-row__tag--rec"
|
||||
title={t('models.recommended_title')}
|
||||
data-testid={`model-recommended-${m.repo_id}`}
|
||||
>
|
||||
{t('voicePanel.badge_recommended')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="models-row__repo"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Pure helpers for the grouped Model Store catalog. No React — exported for
|
||||
* unit tests (modelStoreGrouping.test.jsx) and shared by ModelStoreTab.
|
||||
*/
|
||||
|
||||
/** Section order the catalog renders in. */
|
||||
export const MODEL_SECTION_ORDER = ['tts', 'asr', 'dictation', 'diarisation', 'other'];
|
||||
|
||||
/**
|
||||
* Classify a /models row into a catalog section.
|
||||
*
|
||||
* Dictation is the live-streaming subset of ASR (`engine: sherpa-onnx` or a
|
||||
* `tag` of offline/streaming in models.yaml) — the models the dictation UI
|
||||
* consumes — split out so "offline transcription" and "live dictation" read
|
||||
* as the two distinct capabilities they are.
|
||||
*/
|
||||
export function modelSectionKey(m) {
|
||||
const role = (m?.role || '').toLowerCase();
|
||||
if (role === 'tts') return 'tts';
|
||||
if (role === 'asr') return m?.engine === 'sherpa-onnx' || m?.tag ? 'dictation' : 'asr';
|
||||
if (role === 'diarisation' || role === 'diarization') return 'diarisation';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* The Model Store search predicate — same fields the old TanStack global
|
||||
* filter matched (repo_id, label, note, role) so search behavior is unchanged
|
||||
* by the grouped layout.
|
||||
*/
|
||||
export function matchesModelQuery(m, query) {
|
||||
const q = String(query || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!q) return true;
|
||||
return [m.repo_id, m.label, m.note, m.role]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group models into ordered sections, applying the search query. Each entry:
|
||||
* { key, models (query-matched, catalog order), compatible, incompatible }.
|
||||
* Sections with no matching rows are omitted; `supported === false` rows land
|
||||
* in `incompatible` (rendered behind the per-section "Show incompatible"
|
||||
* toggle instead of inline greyed rows).
|
||||
*/
|
||||
export function groupModels(models, query) {
|
||||
const by = new Map(MODEL_SECTION_ORDER.map((k) => [k, []]));
|
||||
for (const m of models || []) {
|
||||
if (!matchesModelQuery(m, query)) continue;
|
||||
by.get(modelSectionKey(m)).push(m);
|
||||
}
|
||||
return MODEL_SECTION_ORDER.filter((k) => by.get(k).length > 0).map((key) => {
|
||||
const rows = by.get(key);
|
||||
return {
|
||||
key,
|
||||
models: rows,
|
||||
compatible: rows.filter((m) => m.supported !== false),
|
||||
incompatible: rows.filter((m) => m.supported === false),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Wifi,
|
||||
Share2,
|
||||
KeyRound,
|
||||
LockKeyhole,
|
||||
Sparkles,
|
||||
ArrowDownToLine,
|
||||
ShieldCheck,
|
||||
@@ -225,6 +226,26 @@ export const GROUPS = [
|
||||
],
|
||||
keywordKeys: ['settings.storage_usage', 'settings.reset', 'settings.uninstall'],
|
||||
},
|
||||
{
|
||||
id: 'permissions',
|
||||
// Lives in the permissions.* i18n namespace (not settings.*) so the
|
||||
// whole feature's strings ship as one additive block per locale.
|
||||
labelKey: 'permissions.title',
|
||||
defaultLabel: 'Permissions',
|
||||
icon: LockKeyhole,
|
||||
keywords: [
|
||||
'permission',
|
||||
'permissions',
|
||||
'microphone access',
|
||||
'mic access',
|
||||
'accessibility',
|
||||
'privacy & security',
|
||||
'os permissions',
|
||||
'grant',
|
||||
'tcc',
|
||||
],
|
||||
keywordKeys: ['permissions.microphone', 'permissions.accessibility'],
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
labelKey: 'settings.network',
|
||||
|
||||
@@ -20,6 +20,7 @@ import { streamDropError } from '../utils/backendCrash';
|
||||
import { playPing } from '../utils/media';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { asrMissingPayload, toastAsrModelMissing } from '../utils/asrModelMissing';
|
||||
import { addBreadcrumb } from '../utils/breadcrumbs';
|
||||
import { recordValueMoment } from '../utils/donationMoments';
|
||||
import i18next from 'i18next';
|
||||
@@ -239,7 +240,12 @@ export default function useDubWorkflow({
|
||||
if (m && m.detail) {
|
||||
lastErrorDetail = m.detail;
|
||||
close();
|
||||
reject(new Error(m.detail));
|
||||
// Typed "no ASR model installed" preflight (TTS-only install):
|
||||
// tag the rejection so the catch sites can render the one-click
|
||||
// download CTA instead of the generic report toast.
|
||||
const err = new Error(m.detail);
|
||||
if (m.error === 'asr_model_missing') err.asrModelMissing = m;
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -482,6 +488,12 @@ export default function useDubWorkflow({
|
||||
useAppStore.getState().dismissPill();
|
||||
} else if (isExpiredDubJobError(err)) {
|
||||
_resetStaleDubSession();
|
||||
} else if (asrMissingPayload(err)) {
|
||||
// Typed preflight: no ASR model installed → download CTA, not a report.
|
||||
setDubError(t('asr_missing.message'));
|
||||
setDubStep('idle');
|
||||
toastAsrModelMissing(asrMissingPayload(err));
|
||||
useAppStore.getState().errorPill(t('asr_missing.message'));
|
||||
} else {
|
||||
setDubError(err.message);
|
||||
setDubStep('idle');
|
||||
@@ -569,6 +581,11 @@ export default function useDubWorkflow({
|
||||
useAppStore.getState().dismissPill();
|
||||
} else if (isExpiredDubJobError(err)) {
|
||||
_resetStaleDubSession();
|
||||
} else if (asrMissingPayload(err)) {
|
||||
setDubError(t('asr_missing.message'));
|
||||
setDubStep('idle');
|
||||
toastAsrModelMissing(asrMissingPayload(err));
|
||||
useAppStore.getState().errorPill(t('asr_missing.message'));
|
||||
} else {
|
||||
setDubError(err.message);
|
||||
setDubStep('idle');
|
||||
@@ -623,6 +640,10 @@ export default function useDubWorkflow({
|
||||
setDubStep('idle');
|
||||
} else if (isExpiredDubJobError(err)) {
|
||||
_resetStaleDubSession();
|
||||
} else if (asrMissingPayload(err)) {
|
||||
setDubError(t('asr_missing.message'));
|
||||
setDubStep('idle');
|
||||
toastAsrModelMissing(asrMissingPayload(err));
|
||||
} else {
|
||||
setDubError(err.message);
|
||||
setDubStep('idle');
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* usePermissions — live OS-permission status (microphone + macOS
|
||||
* Accessibility) for the SetupWizard system check and Settings → Permissions.
|
||||
*
|
||||
* Probes once on mount and re-probes on every window focus — the polished
|
||||
* pattern: the user clicks "Open Settings", flips the toggle in the OS pane,
|
||||
* and the status chip is already green when they come back. `recheck` is
|
||||
* also exposed for an explicit button.
|
||||
*
|
||||
* Outside the Tauri shell `available` is false and the probes are skipped
|
||||
* entirely (mic stays 'unknown', a11y stays true) so browser/dev mounts
|
||||
* no-op gracefully.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { checkMicrophone, checkAccessibility, inTauri } from '../utils/permissions';
|
||||
|
||||
export default function usePermissions() {
|
||||
const available = inTauri();
|
||||
const [mic, setMic] = useState('unknown'); // granted | denied | prompt | unknown
|
||||
const [a11y, setA11y] = useState(true);
|
||||
|
||||
const recheck = useCallback(async () => {
|
||||
if (!available) return;
|
||||
const [micState, a11yState] = await Promise.all([checkMicrophone(), checkAccessibility()]);
|
||||
setMic(micState);
|
||||
setA11y(a11yState);
|
||||
}, [available]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) return undefined;
|
||||
recheck();
|
||||
// Returning from System Settings refocuses the app window — refresh so
|
||||
// a just-flipped grant shows up without hunting for a Recheck button.
|
||||
const onFocus = () => recheck();
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, [available, recheck]);
|
||||
|
||||
return { available, mic, a11y, recheck };
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cleanAudio as apiCleanAudio } from '../api/system';
|
||||
import { micErrorMessage } from '../utils/micError';
|
||||
import { checkMicrophone } from '../utils/permissions';
|
||||
import { showMicDeniedGuide } from '../utils/micDeniedToast';
|
||||
|
||||
export default function useRecording(ingestRefAudio) {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,6 +21,14 @@ export default function useRecording(ingestRefAudio) {
|
||||
const recordingTimerRef = useRef(null);
|
||||
|
||||
const startRecording = async () => {
|
||||
// Pre-flight: an OS-denied mic grant means getUserMedia can only throw an
|
||||
// opaque NotAllowedError — skip it and show the guided path (per-OS hint
|
||||
// + Open Settings) instead. 'prompt'/'granted'/'unknown' proceed as today
|
||||
// (outside Tauri checkMicrophone() is always 'unknown' → unchanged).
|
||||
if ((await checkMicrophone()) === 'denied') {
|
||||
showMicDeniedGuide(t);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "إعادة التعيين إلى الوضع الافتراضي",
|
||||
"listening_label": "جاري الاستماع…",
|
||||
"transcribing_label": "جارٍ النسخ…",
|
||||
"a11y_setup": "اسمح بتسهيلات الاستخدام حتى يتمكّن الإملاء من الكتابة نيابةً عنك",
|
||||
"pasted": "تم لصقه",
|
||||
"no_speech": "لم يتم اكتشاف أي كلام",
|
||||
"mic_denied": "تم رفض الوصول إلى الميكروفون",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "تثبيت النماذج",
|
||||
"pick_engines": "اختر المحركات",
|
||||
"system_check_desc": "دقق في ذاكرة الوصول العشوائي (RAM) والقرص ووحدة معالجة الرسومات (GPU) وffmpeg والشبكة. يتم وضع علامة على أدوات الحظر مقدمًا حتى تعرفها قبل التنزيل.",
|
||||
"install_models_desc": "تنزيل ~5 جيجابايت من الأوزان — TTS + Whisper. النماذج المطلوبة أولاً، والنماذج الاختيارية لاحقاً.",
|
||||
"install_models_desc": "نزّل ~2.4 جيجابايت من الأوزان — نموذج TTS وحده هو المطلوب. أضف نماذج التفريغ المختارة لعتادك الآن أو لاحقاً من الإعدادات.",
|
||||
"pick_engines_desc": "اختر الواجهات الخلفية TTS / ASR / LLM. تعمل الإعدادات الافتراضية خارج الصندوق - قم بتخصيصها في أي وقت في الإعدادات.",
|
||||
"hero_desc": "الدبلجة واستنساخ الصوت وتصميم الصوت - كلها تعمل محليًا على جهازك.",
|
||||
"first_run": "يستغرق التشغيل الأول من 5 إلى 10 دقائق للتنزيل. بعد ذلك، يكون كل إطلاق فوريًا وغير متصل بالإنترنت بشكل كامل.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "فشل التثبيت: {{error}}",
|
||||
"view_on_hf": "عرض على HuggingFace",
|
||||
"install_btn": "تثبيت",
|
||||
"section_tts": "TTS (توليد الكلام)",
|
||||
"section_asr": "ASR (نسخ دون اتصال)",
|
||||
"section_dictation": "الإملاء (بث مباشر)",
|
||||
"section_diarisation": "تمييز المتحدثين",
|
||||
"section_other": "أخرى",
|
||||
"show_incompatible": "إظهار غير المتوافقة ({{count}})",
|
||||
"hide_incompatible": "إخفاء غير المتوافقة ({{count}})",
|
||||
"incompatible_title": "نماذج لا تعمل على هذا النظام",
|
||||
"recommended_title": "اختيار مخصص لهذا الجهاز — اختياري",
|
||||
"download_all_remaining_title": "تنزيل جميع النماذج الموصى بها المتبقية",
|
||||
"reco_install_one": "تثبيت {{label}}",
|
||||
"reinstall_btn": "أعد التثبيت",
|
||||
"downloading": "جاري التحميل",
|
||||
"deleting": "حذف",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "تم حفظ رمز Hugging Face",
|
||||
"hf_token_error": "تعذّر حفظ الرمز — أعد المحاولة أو عيّنه لاحقًا في الإعدادات → بيانات الاعتماد.",
|
||||
"chip_recommended": "موصى به",
|
||||
"chip_recommended_title": "موصى به لهذا الجهاز — اختياري",
|
||||
"size_left": "{{size}} اليسار",
|
||||
"hf_token_card_title": "أضف رمز Hugging Face المميز مجانًا لإجراء تنزيلات أسرع",
|
||||
"hf_token_get": "ليس لديك رمز مميز؟ احصل على واحدة مجانًا →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "الترخيص أو الشراكات أو أي شيء خاص.",
|
||||
"website": "موقع الكتروني",
|
||||
"website_desc": "المزيد عن المشروع والصانع."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "الأذونات",
|
||||
"desc": "يعمل OmniVoice بالكامل على جهازك — تُستخدم أذونات النظام هذه فقط للميزات المذكورة هنا، ولا يغادر الصوت جهازك أبدًا.",
|
||||
"microphone": "الميكروفون",
|
||||
"microphone_why": "يُستخدم للإملاء وتسجيل الصوت. تتم معالجة الصوت محليًا.",
|
||||
"accessibility": "تسهيلات الاستخدام",
|
||||
"accessibility_why": "يتيح للإملاء لصق النص أو كتابته في التطبيقات الأخرى.",
|
||||
"status_granted": "ممنوح",
|
||||
"status_denied": "مرفوض",
|
||||
"status_prompt": "لم يُطلب بعد",
|
||||
"status_unknown": "غير معروف",
|
||||
"open_settings": "فتح الإعدادات",
|
||||
"web_note": "لا يمكن قراءة حالة الأذونات إلا في تطبيق سطح المكتب — في المتصفح، يدير المتصفح نفسه الوصول إلى الميكروفون."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "لا يوجد نموذج لتحويل الكلام إلى نص مثبّت — تحتاج هذه الميزة إلى نموذج لتفريغ الصوت.",
|
||||
"download": "تنزيل {{label}} ({{size}} غيغابايت)",
|
||||
"started": "جارٍ تنزيل {{label}} — تابع التقدم في الإعدادات ← النماذج، ثم حاول مرة أخرى.",
|
||||
"install_failed": "تعذّر بدء التنزيل: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Auf Standard zurücksetzen",
|
||||
"listening_label": "Zuhören…",
|
||||
"transcribing_label": "Transkribieren…",
|
||||
"a11y_setup": "Bedienungshilfen erlauben, damit das Diktat für Sie tippen kann",
|
||||
"pasted": "Eingefügt",
|
||||
"no_speech": "Keine Sprache erkannt",
|
||||
"mic_denied": "Mikrofonzugriff verweigert",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Modelle installieren",
|
||||
"pick_engines": "Wählen Sie Motoren aus",
|
||||
"system_check_desc": "Prüfen Sie RAM, Festplatte, GPU, ffmpeg und Netzwerk. Blocker werden im Voraus gekennzeichnet, sodass Sie vor dem Herunterladen Bescheid wissen.",
|
||||
"install_models_desc": "Laden Sie ca. 5 GB Gewichte herunter – TTS + Whisper. Zuerst die erforderlichen Modelle, später die optionalen.",
|
||||
"install_models_desc": "Laden Sie ca. 2,4 GB Gewichte herunter – nur das TTS-Modell ist erforderlich. Fügen Sie für Ihre Hardware kuratierte Transkriptionsmodelle jetzt oder jederzeit später in den Einstellungen hinzu.",
|
||||
"pick_engines_desc": "Wählen Sie TTS-/ASR-/LLM-Backends. Die Standardeinstellungen sind sofort einsatzbereit und können jederzeit in den Einstellungen angepasst werden.",
|
||||
"hero_desc": "Synchronisation, Klonen von Stimmen und Stimmendesign – alles läuft lokal auf Ihrem Computer.",
|
||||
"first_run": "Der Download der ersten Ausführung dauert 5–10 Minuten. Danach erfolgt jeder Start sofort und vollständig offline.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Installation fehlgeschlagen: {{error}}",
|
||||
"view_on_hf": "Auf HuggingFace ansehen",
|
||||
"install_btn": "Installieren",
|
||||
"section_tts": "TTS (Sprachsynthese)",
|
||||
"section_asr": "ASR (Offline-Transkription)",
|
||||
"section_dictation": "Diktat (Streaming)",
|
||||
"section_diarisation": "Sprechertrennung",
|
||||
"section_other": "Sonstige",
|
||||
"show_incompatible": "Inkompatible anzeigen ({{count}})",
|
||||
"hide_incompatible": "Inkompatible ausblenden ({{count}})",
|
||||
"incompatible_title": "Modelle, die auf diesem System nicht laufen",
|
||||
"recommended_title": "Kuratierte Auswahl für diesen Rechner — optional",
|
||||
"download_all_remaining_title": "Alle verbleibenden empfohlenen Modelle herunterladen",
|
||||
"reco_install_one": "{{label}} installieren",
|
||||
"reinstall_btn": "Neu installieren",
|
||||
"downloading": "Herunterladen",
|
||||
"deleting": "löschen",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Hugging-Face-Token gespeichert",
|
||||
"hf_token_error": "Token konnte nicht gespeichert werden — erneut versuchen oder später in Einstellungen → Zugangsdaten setzen.",
|
||||
"chip_recommended": "empfohlen",
|
||||
"chip_recommended_title": "Für diesen Rechner empfohlen — optional",
|
||||
"size_left": "{{size}} übrig",
|
||||
"hf_token_card_title": "Fügen Sie für schnellere Downloads ein kostenloses Hugging Face-Token hinzu",
|
||||
"hf_token_get": "Sie haben kein Token? Holen Sie sich eins gratis →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Lizenzen, Partnerschaften oder irgendetwas Privates.",
|
||||
"website": "Website",
|
||||
"website_desc": "Mehr über das Projekt und den Macher."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Berechtigungen",
|
||||
"desc": "OmniVoice läuft vollständig auf Ihrem Gerät — diese Systemberechtigungen werden nur für die hier aufgeführten Funktionen genutzt, Audio verlässt Ihren Rechner nie.",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_why": "Wird für Diktat und Sprachaufnahmen genutzt. Audio wird lokal verarbeitet.",
|
||||
"accessibility": "Bedienungshilfen",
|
||||
"accessibility_why": "Erlaubt dem Diktat, den Text in andere Apps einzufügen oder zu tippen.",
|
||||
"status_granted": "Erteilt",
|
||||
"status_denied": "Verweigert",
|
||||
"status_prompt": "Noch nicht angefragt",
|
||||
"status_unknown": "Unbekannt",
|
||||
"open_settings": "Einstellungen öffnen",
|
||||
"web_note": "Der Berechtigungsstatus ist nur in der Desktop-App auslesbar — im Browser verwaltet der Browser selbst den Mikrofonzugriff."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Es ist kein Sprache-zu-Text-Modell installiert — diese Funktion benötigt eines zum Transkribieren.",
|
||||
"download": "{{label}} herunterladen ({{size}} GB)",
|
||||
"started": "{{label}} wird heruntergeladen — Fortschritt unter Einstellungen → Modelle, danach erneut versuchen.",
|
||||
"install_failed": "Download konnte nicht gestartet werden: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,7 +1481,7 @@
|
||||
"media_engine_detect_failed": "No system copy found — retry the download or choose the file manually.",
|
||||
"media_engine_choose_file": "Choose file…",
|
||||
"media_engine_ready": "Media engine configured.",
|
||||
"install_models_desc": "Download ~5 GB of weights — TTS + Whisper. Required models first, optional ones later.",
|
||||
"install_models_desc": "Download ~2.4 GB of weights — only the TTS model is required. Add transcription models curated for your hardware now, or anytime from Settings.",
|
||||
"pick_engines_desc": "Choose TTS / ASR / LLM backends. Defaults work out of the box — customize anytime in Settings.",
|
||||
"hero_desc": "Dubbing, voice cloning, and voice design — all running locally on your machine.",
|
||||
"first_run": "First run takes 5–10 minutes to download. After that, every launch is instant and fully offline.",
|
||||
@@ -2238,6 +2238,17 @@
|
||||
"disk_free_title": "Free space on the model-cache volume",
|
||||
"view_on_hf": "View on HuggingFace",
|
||||
"install_btn": "Install",
|
||||
"section_tts": "TTS",
|
||||
"section_asr": "ASR (offline transcription)",
|
||||
"section_dictation": "Dictation (streaming)",
|
||||
"section_diarisation": "Diarisation",
|
||||
"section_other": "Other",
|
||||
"show_incompatible": "Show incompatible ({{count}})",
|
||||
"hide_incompatible": "Hide incompatible ({{count}})",
|
||||
"incompatible_title": "Models that can't run on this system",
|
||||
"recommended_title": "Curated pick for this machine — optional",
|
||||
"download_all_remaining_title": "Download all remaining recommended models",
|
||||
"reco_install_one": "Install {{label}}",
|
||||
"reinstall_btn": "Reinstall",
|
||||
"downloading": "downloading",
|
||||
"deleting": "deleting",
|
||||
@@ -2633,6 +2644,7 @@
|
||||
"chip_required": "required",
|
||||
"chip_optional": "optional",
|
||||
"chip_recommended": "recommended",
|
||||
"chip_recommended_title": "Recommended for this machine — optional",
|
||||
"chip_engine": "engine",
|
||||
"lib_download": "Download",
|
||||
"lib_downloading": "downloading…",
|
||||
@@ -2700,5 +2712,25 @@
|
||||
"aec_row_title": "Enable echo cancellation for dictation",
|
||||
"aec_experimental": "Experimental",
|
||||
"aec_hint": "Cancels OmniVoice's own playback out of the microphone so you can dictate while a preview, dub, or video is playing — without the transcript picking up what the app is saying. Adds a small amount of audio processing; leave it off if you never dictate over playback."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Permissions",
|
||||
"desc": "OmniVoice runs entirely on your device — these OS permissions are used only for the features listed here, and audio never leaves your machine.",
|
||||
"microphone": "Microphone",
|
||||
"microphone_why": "Used for dictation and voice recording. Audio is processed locally.",
|
||||
"accessibility": "Accessibility",
|
||||
"accessibility_why": "Lets dictation paste or type the transcript into other apps.",
|
||||
"status_granted": "Granted",
|
||||
"status_denied": "Denied",
|
||||
"status_prompt": "Not asked yet",
|
||||
"status_unknown": "Unknown",
|
||||
"open_settings": "Open Settings",
|
||||
"web_note": "OS permission status is only readable in the desktop app — in the browser, microphone access is managed by the browser itself."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "No speech-to-text model is installed — this feature needs one to transcribe audio.",
|
||||
"download": "Download {{label}} ({{size}} GB)",
|
||||
"started": "Downloading {{label}} — watch progress in Settings → Models, then try again.",
|
||||
"install_failed": "Couldn't start the download: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Restablecer los valores predeterminados",
|
||||
"listening_label": "Escuchando…",
|
||||
"transcribing_label": "Transcribiendo…",
|
||||
"a11y_setup": "Permite Accesibilidad para que el dictado pueda escribir por ti",
|
||||
"pasted": "Pegado",
|
||||
"no_speech": "No se detectó voz",
|
||||
"mic_denied": "Acceso al micrófono denegado",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Instalar modelos",
|
||||
"pick_engines": "elegir motores",
|
||||
"system_check_desc": "Sondee RAM, disco, GPU, ffmpeg y red. Los bloqueadores se marcan por adelantado para que sepas antes de descargarlos.",
|
||||
"install_models_desc": "Descargue ~5 GB de pesas: TTS + Whisper. Primero los modelos obligatorios, después los opcionales.",
|
||||
"install_models_desc": "Descargue ~2,4 GB de pesos: solo se requiere el modelo TTS. Añada modelos de transcripción seleccionados para su hardware ahora o más tarde desde Ajustes.",
|
||||
"pick_engines_desc": "Elija backends TTS / ASR / LLM. Los valores predeterminados funcionan de inmediato: personalícelos en cualquier momento en Configuración.",
|
||||
"hero_desc": "Doblaje, clonación de voz y diseño de voz: todo ejecutándose localmente en su máquina.",
|
||||
"first_run": "La primera ejecución tarda entre 5 y 10 minutos en descargarse. Después de eso, cada lanzamiento es instantáneo y completamente fuera de línea.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Error de instalación: {{error}}",
|
||||
"view_on_hf": "Ver en HuggingFace",
|
||||
"install_btn": "Instalar",
|
||||
"section_tts": "TTS (síntesis de voz)",
|
||||
"section_asr": "ASR (transcripción sin conexión)",
|
||||
"section_dictation": "Dictado (streaming)",
|
||||
"section_diarisation": "Diarización",
|
||||
"section_other": "Otros",
|
||||
"show_incompatible": "Mostrar incompatibles ({{count}})",
|
||||
"hide_incompatible": "Ocultar incompatibles ({{count}})",
|
||||
"incompatible_title": "Modelos que no funcionan en este sistema",
|
||||
"recommended_title": "Selección para esta máquina — opcional",
|
||||
"download_all_remaining_title": "Descargar todos los modelos recomendados restantes",
|
||||
"reco_install_one": "Instalar {{label}}",
|
||||
"reinstall_btn": "Reinstalar",
|
||||
"downloading": "descargando",
|
||||
"deleting": "eliminando",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Token de Hugging Face guardado",
|
||||
"hf_token_error": "No se pudo guardar el token — reintenta o configúralo luego en Ajustes → Credenciales.",
|
||||
"chip_recommended": "recomendado",
|
||||
"chip_recommended_title": "Recomendado para esta máquina — opcional",
|
||||
"size_left": "{{size}} izquierda",
|
||||
"hf_token_card_title": "Agregue un token de Hugging Face gratuito para descargas más rápidas",
|
||||
"hf_token_get": "¿No tienes una ficha? Consigue uno gratis →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Licencias, asociaciones o cualquier cosa privada.",
|
||||
"website": "Sitio web",
|
||||
"website_desc": "Más sobre el proyecto y el creador."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Permisos",
|
||||
"desc": "OmniVoice se ejecuta íntegramente en su equipo: estos permisos del sistema solo se usan para las funciones listadas aquí y el audio nunca sale de su máquina.",
|
||||
"microphone": "Micrófono",
|
||||
"microphone_why": "Se usa para el dictado y la grabación de voz. El audio se procesa localmente.",
|
||||
"accessibility": "Accesibilidad",
|
||||
"accessibility_why": "Permite que el dictado pegue o escriba el texto en otras aplicaciones.",
|
||||
"status_granted": "Concedido",
|
||||
"status_denied": "Denegado",
|
||||
"status_prompt": "Aún no solicitado",
|
||||
"status_unknown": "Desconocido",
|
||||
"open_settings": "Abrir configuración",
|
||||
"web_note": "El estado de los permisos solo puede leerse en la aplicación de escritorio; en el navegador, el acceso al micrófono lo gestiona el propio navegador."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "No hay ningún modelo de voz a texto instalado — esta función necesita uno para transcribir audio.",
|
||||
"download": "Descargar {{label}} ({{size}} GB)",
|
||||
"started": "Descargando {{label}} — sigue el progreso en Ajustes → Modelos y vuelve a intentarlo.",
|
||||
"install_failed": "No se pudo iniciar la descarga: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Réinitialiser aux valeurs par défaut",
|
||||
"listening_label": "A l'écoute…",
|
||||
"transcribing_label": "Transcription…",
|
||||
"a11y_setup": "Autorisez l'accessibilité pour que la dictée puisse écrire à votre place",
|
||||
"pasted": "Collé",
|
||||
"no_speech": "Aucune parole détectée",
|
||||
"mic_denied": "Accès au micro refusé",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Installer des modèles",
|
||||
"pick_engines": "Choisir des moteurs",
|
||||
"system_check_desc": "Sondez la RAM, le disque, le GPU, ffmpeg et le réseau. Les bloqueurs sont signalés à l’avance afin que vous le sachiez avant de télécharger.",
|
||||
"install_models_desc": "Téléchargez ~5 Go de poids — TTS + Whisper. Modèles obligatoires en premier, modèles facultatifs plus tard.",
|
||||
"install_models_desc": "Téléchargez ~2,4 Go de poids — seul le modèle TTS est requis. Ajoutez des modèles de transcription adaptés à votre matériel maintenant, ou plus tard depuis les Réglages.",
|
||||
"pick_engines_desc": "Choisissez les backends TTS / ASR / LLM. Les valeurs par défaut fonctionnent immédiatement : personnalisez-les à tout moment dans les paramètres.",
|
||||
"hero_desc": "Doublage, clonage vocal et conception vocale, le tout exécuté localement sur votre ordinateur.",
|
||||
"first_run": "La première exécution prend 5 à 10 minutes pour le téléchargement. Après cela, chaque lancement est instantané et entièrement hors ligne.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Échec de l'installation : {{error}}",
|
||||
"view_on_hf": "Voir sur HuggingFace",
|
||||
"install_btn": "Installer",
|
||||
"section_tts": "TTS (synthèse vocale)",
|
||||
"section_asr": "ASR (transcription hors ligne)",
|
||||
"section_dictation": "Dictée (streaming)",
|
||||
"section_diarisation": "Diarisation",
|
||||
"section_other": "Autres",
|
||||
"show_incompatible": "Afficher les incompatibles ({{count}})",
|
||||
"hide_incompatible": "Masquer les incompatibles ({{count}})",
|
||||
"incompatible_title": "Modèles incompatibles avec ce système",
|
||||
"recommended_title": "Sélection pour cette machine — optionnel",
|
||||
"download_all_remaining_title": "Télécharger tous les modèles recommandés restants",
|
||||
"reco_install_one": "Installer {{label}}",
|
||||
"reinstall_btn": "Réinstaller",
|
||||
"downloading": "téléchargement",
|
||||
"deleting": "suppression",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Jeton Hugging Face enregistré",
|
||||
"hf_token_error": "Impossible d’enregistrer le jeton — réessayez ou configurez-le plus tard dans Réglages → Identifiants.",
|
||||
"chip_recommended": "recommandé",
|
||||
"chip_recommended_title": "Recommandé pour cette machine — optionnel",
|
||||
"size_left": "{{size}} reste",
|
||||
"hf_token_card_title": "Ajoutez un jeton Hugging Face gratuit pour des téléchargements plus rapides",
|
||||
"hf_token_get": "Vous n'avez pas de jeton ? Obtenez-en un gratuitement →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Licences, partenariats ou tout ce qui est privé.",
|
||||
"website": "Site Web",
|
||||
"website_desc": "En savoir plus sur le projet et le créateur."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Autorisations",
|
||||
"desc": "OmniVoice s'exécute entièrement sur votre appareil — ces autorisations système ne servent qu'aux fonctions listées ici, et l'audio ne quitte jamais votre machine.",
|
||||
"microphone": "Microphone",
|
||||
"microphone_why": "Utilisé pour la dictée et l'enregistrement vocal. L'audio est traité localement.",
|
||||
"accessibility": "Accessibilité",
|
||||
"accessibility_why": "Permet à la dictée de coller ou de saisir le texte dans d'autres applications.",
|
||||
"status_granted": "Accordée",
|
||||
"status_denied": "Refusée",
|
||||
"status_prompt": "Pas encore demandée",
|
||||
"status_unknown": "Inconnue",
|
||||
"open_settings": "Ouvrir les réglages",
|
||||
"web_note": "L'état des autorisations n'est lisible que dans l'application de bureau — dans le navigateur, l'accès au micro est géré par le navigateur lui-même."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Aucun modèle de reconnaissance vocale n'est installé — cette fonction en a besoin pour transcrire l'audio.",
|
||||
"download": "Télécharger {{label}} ({{size}} Go)",
|
||||
"started": "Téléchargement de {{label}} — suivez la progression dans Réglages → Modèles, puis réessayez.",
|
||||
"install_failed": "Impossible de démarrer le téléchargement : {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "डिफ़ॉल्ट पर रीसेट करें",
|
||||
"listening_label": "सुन रहा हूँ...",
|
||||
"transcribing_label": "प्रतिलेखन...",
|
||||
"a11y_setup": "एक्सेसिबिलिटी की अनुमति दें ताकि श्रुतलेख आपके लिए टाइप कर सके",
|
||||
"pasted": "चिपकाया गया",
|
||||
"no_speech": "कोई भाषण नहीं मिला",
|
||||
"mic_denied": "माइक का उपयोग अस्वीकृत",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "मॉडल स्थापित करें",
|
||||
"pick_engines": "इंजन चुनें",
|
||||
"system_check_desc": "रैम, डिस्क, जीपीयू, एफएफएमपीईजी और नेटवर्क की जांच करें। अवरोधकों को अग्रिम रूप से चिह्नित किया जाता है ताकि आप डाउनलोड करने से पहले जान सकें।",
|
||||
"install_models_desc": "~5 जीबी वजन डाउनलोड करें - टीटीएस + व्हिस्पर। आवश्यक मॉडल पहले, वैकल्पिक मॉडल बाद में।",
|
||||
"install_models_desc": "~2.4 जीबी वेट डाउनलोड करें — केवल टीटीएस मॉडल आवश्यक है। अपने हार्डवेयर के लिए चुने गए ट्रांसक्रिप्शन मॉडल अभी जोड़ें, या बाद में सेटिंग्स से।",
|
||||
"pick_engines_desc": "टीटीएस/एएसआर/एलएलएम बैकएंड चुनें। डिफ़ॉल्ट बॉक्स से बाहर काम करते हैं - सेटिंग्स में किसी भी समय अनुकूलित करें।",
|
||||
"hero_desc": "डबिंग, वॉइस क्लोनिंग, और वॉइस डिज़ाइन - सभी आपकी मशीन पर स्थानीय रूप से चल रहे हैं।",
|
||||
"first_run": "पहले रन को डाउनलोड होने में 5-10 मिनट लगते हैं। उसके बाद, प्रत्येक लॉन्च तुरंत और पूरी तरह से ऑफ़लाइन होता है।",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "इंस्टॉल विफल: {{error}}",
|
||||
"view_on_hf": "हगिंगफेस पर देखें",
|
||||
"install_btn": "स्थापित करें",
|
||||
"section_tts": "TTS (वाक् संश्लेषण)",
|
||||
"section_asr": "ASR (ऑफ़लाइन ट्रांसक्रिप्शन)",
|
||||
"section_dictation": "श्रुतलेखन (स्ट्रीमिंग)",
|
||||
"section_diarisation": "वक्ता पृथक्करण",
|
||||
"section_other": "अन्य",
|
||||
"show_incompatible": "असंगत दिखाएँ ({{count}})",
|
||||
"hide_incompatible": "असंगत छिपाएँ ({{count}})",
|
||||
"incompatible_title": "मॉडल जो इस सिस्टम पर नहीं चलते",
|
||||
"recommended_title": "इस मशीन के लिए चुना गया — वैकल्पिक",
|
||||
"download_all_remaining_title": "शेष सभी अनुशंसित मॉडल डाउनलोड करें",
|
||||
"reco_install_one": "{{label}} स्थापित करें",
|
||||
"reinstall_btn": "पुनः स्थापित करें",
|
||||
"downloading": "डाउनलोड हो रहा है",
|
||||
"deleting": "हटाना",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Hugging Face टोकन सहेजा गया",
|
||||
"hf_token_error": "टोकन सहेजा नहीं जा सका — फिर कोशिश करें या बाद में सेटिंग्स → क्रेडेंशियल्स में सेट करें।",
|
||||
"chip_recommended": "अनुशंसित",
|
||||
"chip_recommended_title": "इस मशीन के लिए अनुशंसित — वैकल्पिक",
|
||||
"size_left": "{{size}}बाएं",
|
||||
"hf_token_card_title": "तेज़ डाउनलोड के लिए निःशुल्क हगिंग फेस टोकन जोड़ें",
|
||||
"hf_token_get": "टोकन नहीं है? एक निःशुल्क प्राप्त करें →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "लाइसेंसिंग, साझेदारी, या कुछ भी निजी।",
|
||||
"website": "वेबसाइट",
|
||||
"website_desc": "परियोजना और निर्माता के बारे में अधिक जानकारी."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "अनुमतियाँ",
|
||||
"desc": "OmniVoice पूरी तरह आपके डिवाइस पर चलता है — ये सिस्टम अनुमतियाँ केवल यहाँ सूचीबद्ध सुविधाओं के लिए उपयोग होती हैं, और ऑडियो कभी आपकी मशीन से बाहर नहीं जाता।",
|
||||
"microphone": "माइक्रोफ़ोन",
|
||||
"microphone_why": "डिक्टेशन और वॉयस रिकॉर्डिंग के लिए उपयोग होता है। ऑडियो लोकल रूप से प्रोसेस होता है।",
|
||||
"accessibility": "एक्सेसिबिलिटी",
|
||||
"accessibility_why": "डिक्टेशन को अन्य ऐप्स में टेक्स्ट पेस्ट करने या टाइप करने देता है।",
|
||||
"status_granted": "अनुमति मिली",
|
||||
"status_denied": "अस्वीकृत",
|
||||
"status_prompt": "अभी तक नहीं पूछा गया",
|
||||
"status_unknown": "अज्ञात",
|
||||
"open_settings": "सेटिंग्स खोलें",
|
||||
"web_note": "अनुमतियों की स्थिति केवल डेस्कटॉप ऐप में पढ़ी जा सकती है — ब्राउज़र में माइक्रोफ़ोन एक्सेस ब्राउज़र स्वयं प्रबंधित करता है।"
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "कोई स्पीच-टू-टेक्स्ट मॉडल इंस्टॉल नहीं है — ऑडियो ट्रांसक्राइब करने के लिए इस सुविधा को एक मॉडल चाहिए।",
|
||||
"download": "{{label}} डाउनलोड करें ({{size}} GB)",
|
||||
"started": "{{label}} डाउनलोड हो रहा है — प्रगति सेटिंग्स → मॉडल में देखें, फिर दोबारा कोशिश करें।",
|
||||
"install_failed": "डाउनलोड शुरू नहीं हो सका: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Atur ulang ke default",
|
||||
"listening_label": "Mendengarkan…",
|
||||
"transcribing_label": "Mentranskripsikan…",
|
||||
"a11y_setup": "Izinkan Aksesibilitas agar dikte dapat mengetik untuk Anda",
|
||||
"pasted": "Ditempel",
|
||||
"no_speech": "Tidak ada ucapan yang terdeteksi",
|
||||
"mic_denied": "Akses mikrofon ditolak",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Instal model",
|
||||
"pick_engines": "Pilih mesin",
|
||||
"system_check_desc": "Selidiki RAM, disk, GPU, ffmpeg, dan jaringan. Pemblokir ditandai terlebih dahulu sehingga Anda mengetahuinya sebelum mengunduh.",
|
||||
"install_models_desc": "Unduh bobot ~5 GB — TTS + Whisper. Model wajib terlebih dahulu, model opsional kemudian.",
|
||||
"install_models_desc": "Unduh bobot ~2,4 GB — hanya model TTS yang wajib. Tambahkan model transkripsi pilihan untuk perangkat keras Anda sekarang atau nanti dari Pengaturan.",
|
||||
"pick_engines_desc": "Pilih backend TTS / ASR / LLM. Standarnya langsung dapat digunakan — sesuaikan kapan saja di Pengaturan.",
|
||||
"hero_desc": "Sulih suara, kloning suara, dan desain suara — semuanya berjalan secara lokal di mesin Anda.",
|
||||
"first_run": "Pengunduhan pertama memerlukan waktu 5–10 menit. Setelah itu, setiap peluncuran bersifat instan dan sepenuhnya offline.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Pemasangan gagal: {{error}}",
|
||||
"view_on_hf": "Lihat di HuggingFace",
|
||||
"install_btn": "Instal",
|
||||
"section_tts": "TTS (sintesis suara)",
|
||||
"section_asr": "ASR (transkripsi offline)",
|
||||
"section_dictation": "Dikte (streaming)",
|
||||
"section_diarisation": "Diarisasi",
|
||||
"section_other": "Lainnya",
|
||||
"show_incompatible": "Tampilkan yang tidak kompatibel ({{count}})",
|
||||
"hide_incompatible": "Sembunyikan yang tidak kompatibel ({{count}})",
|
||||
"incompatible_title": "Model yang tidak dapat berjalan di sistem ini",
|
||||
"recommended_title": "Pilihan untuk mesin ini — opsional",
|
||||
"download_all_remaining_title": "Unduh semua model rekomendasi yang tersisa",
|
||||
"reco_install_one": "Instal {{label}}",
|
||||
"reinstall_btn": "Instal ulang",
|
||||
"downloading": "mengunduh",
|
||||
"deleting": "menghapus",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Token Hugging Face tersimpan",
|
||||
"hf_token_error": "Gagal menyimpan token — coba lagi atau atur nanti di Pengaturan → Kredensial.",
|
||||
"chip_recommended": "direkomendasikan",
|
||||
"chip_recommended_title": "Direkomendasikan untuk mesin ini — opsional",
|
||||
"size_left": "{{size}} kiri",
|
||||
"hf_token_card_title": "Tambahkan token Hugging Face gratis untuk pengunduhan lebih cepat",
|
||||
"hf_token_get": "Tidak punya tokennya? Dapatkan satu gratis →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Perizinan, kemitraan, atau apa pun yang bersifat pribadi.",
|
||||
"website": "Situs web",
|
||||
"website_desc": "Lebih lanjut tentang proyek dan pembuatnya."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Izin",
|
||||
"desc": "OmniVoice berjalan sepenuhnya di perangkat Anda — izin sistem ini hanya digunakan untuk fitur yang tercantum di sini, dan audio tidak pernah meninggalkan komputer Anda.",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_why": "Digunakan untuk dikte dan perekaman suara. Audio diproses secara lokal.",
|
||||
"accessibility": "Aksesibilitas",
|
||||
"accessibility_why": "Memungkinkan dikte menempel atau mengetik teks ke aplikasi lain.",
|
||||
"status_granted": "Diberikan",
|
||||
"status_denied": "Ditolak",
|
||||
"status_prompt": "Belum diminta",
|
||||
"status_unknown": "Tidak diketahui",
|
||||
"open_settings": "Buka Pengaturan",
|
||||
"web_note": "Status izin hanya dapat dibaca di aplikasi desktop — di browser, akses mikrofon dikelola oleh browser itu sendiri."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Tidak ada model ucapan-ke-teks yang terpasang — fitur ini membutuhkannya untuk mentranskripsi audio.",
|
||||
"download": "Unduh {{label}} ({{size}} GB)",
|
||||
"started": "Mengunduh {{label}} — pantau progresnya di Pengaturan → Model, lalu coba lagi.",
|
||||
"install_failed": "Tidak dapat memulai unduhan: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Ripristina le impostazioni predefinite",
|
||||
"listening_label": "Ascoltando...",
|
||||
"transcribing_label": "Trascrizione…",
|
||||
"a11y_setup": "Consenti Accessibilità così la dettatura può digitare per te",
|
||||
"pasted": "Incollato",
|
||||
"no_speech": "Nessun parlato rilevato",
|
||||
"mic_denied": "Accesso al microfono negato",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Installa modelli",
|
||||
"pick_engines": "Scegli i motori",
|
||||
"system_check_desc": "Analizza RAM, disco, GPU, ffmpeg e rete. I bloccanti vengono contrassegnati in anticipo in modo da saperlo prima del download.",
|
||||
"install_models_desc": "Scarica ~5 GB di pesi: TTS + Whisper. Prima i modelli obbligatori, poi quelli opzionali.",
|
||||
"install_models_desc": "Scarica ~2,4 GB di pesi: è richiesto solo il modello TTS. Aggiungi i modelli di trascrizione curati per il tuo hardware ora o in seguito dalle Impostazioni.",
|
||||
"pick_engines_desc": "Scegli i backend TTS/ASR/LLM. Le impostazioni predefinite funzionano immediatamente: personalizza in qualsiasi momento nelle Impostazioni.",
|
||||
"hero_desc": "Duplicazione, clonazione e progettazione vocale: tutto in esecuzione localmente sul tuo computer.",
|
||||
"first_run": "Il download della prima esecuzione richiede 5-10 minuti. Successivamente, ogni lancio è istantaneo e completamente offline.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Installazione non riuscita: {{error}}",
|
||||
"view_on_hf": "Visualizza su HuggingFace",
|
||||
"install_btn": "Installa",
|
||||
"section_tts": "TTS (sintesi vocale)",
|
||||
"section_asr": "ASR (trascrizione offline)",
|
||||
"section_dictation": "Dettatura (streaming)",
|
||||
"section_diarisation": "Diarizzazione",
|
||||
"section_other": "Altri",
|
||||
"show_incompatible": "Mostra incompatibili ({{count}})",
|
||||
"hide_incompatible": "Nascondi incompatibili ({{count}})",
|
||||
"incompatible_title": "Modelli non eseguibili su questo sistema",
|
||||
"recommended_title": "Selezione curata per questa macchina — opzionale",
|
||||
"download_all_remaining_title": "Scarica tutti i modelli consigliati rimanenti",
|
||||
"reco_install_one": "Installa {{label}}",
|
||||
"reinstall_btn": "Reinstallare",
|
||||
"downloading": "scaricamento",
|
||||
"deleting": "eliminazione",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Token Hugging Face salvato",
|
||||
"hf_token_error": "Impossibile salvare il token — riprova o impostalo dopo in Impostazioni → Credenziali.",
|
||||
"chip_recommended": "consigliato",
|
||||
"chip_recommended_title": "Consigliato per questa macchina — opzionale",
|
||||
"size_left": "{{size}} a sinistra",
|
||||
"hf_token_card_title": "Aggiungi un token Hugging Face gratuito per download più rapidi",
|
||||
"hf_token_get": "Non hai un gettone? Ottienine uno gratis →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Licenze, partnership o qualsiasi cosa privata.",
|
||||
"website": "Sito web",
|
||||
"website_desc": "Maggiori informazioni sul progetto e sul produttore."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Autorizzazioni",
|
||||
"desc": "OmniVoice funziona interamente sul tuo dispositivo — queste autorizzazioni di sistema servono solo per le funzioni elencate qui e l'audio non lascia mai il tuo computer.",
|
||||
"microphone": "Microfono",
|
||||
"microphone_why": "Usato per la dettatura e la registrazione vocale. L'audio è elaborato in locale.",
|
||||
"accessibility": "Accessibilità",
|
||||
"accessibility_why": "Consente alla dettatura di incollare o digitare il testo in altre app.",
|
||||
"status_granted": "Concessa",
|
||||
"status_denied": "Negata",
|
||||
"status_prompt": "Non ancora richiesta",
|
||||
"status_unknown": "Sconosciuta",
|
||||
"open_settings": "Apri impostazioni",
|
||||
"web_note": "Lo stato delle autorizzazioni è leggibile solo nell'app desktop — nel browser l'accesso al microfono è gestito dal browser stesso."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Nessun modello di riconoscimento vocale è installato — questa funzione ne richiede uno per trascrivere l'audio.",
|
||||
"download": "Scarica {{label}} ({{size}} GB)",
|
||||
"started": "Download di {{label}} in corso — segui l'avanzamento in Impostazioni → Modelli, poi riprova.",
|
||||
"install_failed": "Impossibile avviare il download: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "デフォルトにリセットする",
|
||||
"listening_label": "聞いています…",
|
||||
"transcribing_label": "文字起こし中…",
|
||||
"a11y_setup": "音声入力が代わりに入力できるよう、アクセシビリティを許可してください",
|
||||
"pasted": "貼り付けた",
|
||||
"no_speech": "音声が検出されませんでした",
|
||||
"mic_denied": "マイクアクセスが拒否されました",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "モデルのインストール",
|
||||
"pick_engines": "エンジンを選択する",
|
||||
"system_check_desc": "RAM、ディスク、GPU、ffmpeg、およびネットワークを調査します。ブロッカーには事前にフラグが付けられるため、ダウンロードする前にわかります。",
|
||||
"install_models_desc": "約 5 GB のウェイトをダウンロードします — TTS + Whisper。必須のモデルを最初に、オプションのモデルを後にします。",
|
||||
"install_models_desc": "約 2.4 GB のウェイトをダウンロードします — 必須なのは TTS モデルのみです。ハードウェアに合わせた文字起こしモデルは、今すぐ、または後から設定で追加できます。",
|
||||
"pick_engines_desc": "TTS / ASR / LLM バックエンドを選択します。デフォルトはそのまま使用できます。設定でいつでもカスタマイズできます。",
|
||||
"hero_desc": "ダビング、音声クローン、音声デザインはすべてマシン上でローカルに実行されます。",
|
||||
"first_run": "最初の実行のダウンロードには 5 ~ 10 分かかります。その後は、すべての起動が瞬時に行われ、完全にオフラインになります。",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "インストールに失敗しました: {{error}}",
|
||||
"view_on_hf": "ハギングフェイスで見る",
|
||||
"install_btn": "インストール",
|
||||
"section_tts": "TTS(音声合成)",
|
||||
"section_asr": "ASR(オフライン文字起こし)",
|
||||
"section_dictation": "ディクテーション(ストリーミング)",
|
||||
"section_diarisation": "話者分離",
|
||||
"section_other": "その他",
|
||||
"show_incompatible": "非対応を表示 ({{count}})",
|
||||
"hide_incompatible": "非対応を非表示 ({{count}})",
|
||||
"incompatible_title": "このシステムでは動作しないモデル",
|
||||
"recommended_title": "このマシン向けの厳選モデル — 任意",
|
||||
"download_all_remaining_title": "残りの推奨モデルをすべてダウンロード",
|
||||
"reco_install_one": "{{label}} をインストール",
|
||||
"reinstall_btn": "再インストール",
|
||||
"downloading": "ダウンロード",
|
||||
"deleting": "削除する",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Hugging Face トークンを保存しました",
|
||||
"hf_token_error": "トークンを保存できませんでした。再試行するか、後で設定 → 認証情報から設定してください。",
|
||||
"chip_recommended": "推奨される",
|
||||
"chip_recommended_title": "このマシンに推奨 — 任意",
|
||||
"size_left": "{{size}} 残っています",
|
||||
"hf_token_card_title": "ダウンロードを高速化するために無料の Hugging Face トークンを追加します",
|
||||
"hf_token_get": "トークンをお持ちでない場合は、 1 つ無料で入手 →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "ライセンス、パートナーシップ、その他プライベートなもの。",
|
||||
"website": "ウェブサイト",
|
||||
"website_desc": "プロジェクトとメーカーについて詳しく説明します。"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "権限",
|
||||
"desc": "OmniVoice はすべてデバイス上で動作します。これらの OS 権限はここに記載された機能にのみ使用され、音声がマシンの外に出ることはありません。",
|
||||
"microphone": "マイク",
|
||||
"microphone_why": "音声入力と録音に使用します。音声はローカルで処理されます。",
|
||||
"accessibility": "アクセシビリティ",
|
||||
"accessibility_why": "音声入力が他のアプリにテキストを貼り付け・入力できるようにします。",
|
||||
"status_granted": "許可済み",
|
||||
"status_denied": "拒否",
|
||||
"status_prompt": "未確認",
|
||||
"status_unknown": "不明",
|
||||
"open_settings": "設定を開く",
|
||||
"web_note": "権限の状態はデスクトップアプリでのみ取得できます。ブラウザーではマイクへのアクセスはブラウザー自体が管理します。"
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "音声認識モデルがインストールされていません — この機能で文字起こしを行うにはモデルが必要です。",
|
||||
"download": "{{label}} をダウンロード({{size}} GB)",
|
||||
"started": "{{label}} をダウンロード中 — 設定 → モデルで進行状況を確認し、完了後にもう一度お試しください。",
|
||||
"install_failed": "ダウンロードを開始できませんでした: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "기본값으로 재설정",
|
||||
"listening_label": "듣는 중…",
|
||||
"transcribing_label": "스크립트 작성 중…",
|
||||
"a11y_setup": "받아쓰기가 대신 입력할 수 있도록 손쉬운 사용을 허용하세요",
|
||||
"pasted": "붙여넣음",
|
||||
"no_speech": "음성이 감지되지 않았습니다.",
|
||||
"mic_denied": "마이크 액세스가 거부되었습니다.",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "모델 설치",
|
||||
"pick_engines": "엔진 선택",
|
||||
"system_check_desc": "RAM, 디스크, GPU, ffmpeg 및 네트워크를 프로브합니다. 차단기는 미리 표시되어 있으므로 다운로드하기 전에 알 수 있습니다.",
|
||||
"install_models_desc": "최대 5GB의 가중치 다운로드 — TTS + Whisper. 필수 모델이 먼저이고 선택 모델이 나중에 표시됩니다.",
|
||||
"install_models_desc": "약 2.4GB의 가중치 다운로드 — TTS 모델만 필수입니다. 하드웨어에 맞게 선별된 전사 모델은 지금 또는 나중에 설정에서 추가하세요.",
|
||||
"pick_engines_desc": "TTS/ASR/LLM 백엔드를 선택합니다. 기본값은 즉시 적용됩니다. 설정에서 언제든지 맞춤설정할 수 있습니다.",
|
||||
"hero_desc": "더빙, 음성 복제, 음성 디자인 등이 모두 컴퓨터에서 로컬로 실행됩니다.",
|
||||
"first_run": "처음 실행하면 다운로드하는 데 5~10분 정도 걸립니다. 그 이후에는 모든 출시가 즉시 이루어지며 완전히 오프라인 상태가 됩니다.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "설치 실패: {{error}}",
|
||||
"view_on_hf": "HuggingFace에서 보기",
|
||||
"install_btn": "설치",
|
||||
"section_tts": "TTS (음성 합성)",
|
||||
"section_asr": "ASR (오프라인 전사)",
|
||||
"section_dictation": "받아쓰기 (스트리밍)",
|
||||
"section_diarisation": "화자 분리",
|
||||
"section_other": "기타",
|
||||
"show_incompatible": "호환되지 않는 항목 표시 ({{count}})",
|
||||
"hide_incompatible": "호환되지 않는 항목 숨기기 ({{count}})",
|
||||
"incompatible_title": "이 시스템에서 실행할 수 없는 모델",
|
||||
"recommended_title": "이 컴퓨터에 맞춘 추천 — 선택 사항",
|
||||
"download_all_remaining_title": "남은 추천 모델 모두 다운로드",
|
||||
"reco_install_one": "{{label}} 설치",
|
||||
"reinstall_btn": "재설치",
|
||||
"downloading": "다운로드 중",
|
||||
"deleting": "삭제 중",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Hugging Face 토큰 저장됨",
|
||||
"hf_token_error": "토큰을 저장하지 못했습니다 — 다시 시도하거나 나중에 설정 → 자격 증명에서 설정하세요.",
|
||||
"chip_recommended": "추천",
|
||||
"chip_recommended_title": "이 컴퓨터에 추천 — 선택 사항",
|
||||
"size_left": "{{size}} 남음",
|
||||
"hf_token_card_title": "더 빠른 다운로드를 위해 무료 Hugging Face 토큰을 추가하세요.",
|
||||
"hf_token_get": "토큰이 없나요? 하나를 무료로 받으세요 →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "라이센스, 파트너십 또는 개인적인 모든 것.",
|
||||
"website": "웹사이트",
|
||||
"website_desc": "프로젝트와 제작자에 대해 자세히 알아보세요."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "권한",
|
||||
"desc": "OmniVoice는 전적으로 사용자의 기기에서 실행됩니다. 이 OS 권한들은 여기에 나열된 기능에만 사용되며 오디오는 절대 기기를 떠나지 않습니다.",
|
||||
"microphone": "마이크",
|
||||
"microphone_why": "받아쓰기와 음성 녹음에 사용됩니다. 오디오는 로컬에서 처리됩니다.",
|
||||
"accessibility": "손쉬운 사용",
|
||||
"accessibility_why": "받아쓰기가 다른 앱에 텍스트를 붙여넣거나 입력할 수 있게 합니다.",
|
||||
"status_granted": "허용됨",
|
||||
"status_denied": "거부됨",
|
||||
"status_prompt": "아직 요청되지 않음",
|
||||
"status_unknown": "알 수 없음",
|
||||
"open_settings": "설정 열기",
|
||||
"web_note": "권한 상태는 데스크톱 앱에서만 읽을 수 있습니다. 브라우저에서는 브라우저가 직접 마이크 접근을 관리합니다."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "음성 인식 모델이 설치되어 있지 않습니다 — 이 기능으로 오디오를 전사하려면 모델이 필요합니다.",
|
||||
"download": "{{label}} 다운로드 ({{size}} GB)",
|
||||
"started": "{{label}} 다운로드 중 — 설정 → 모델에서 진행 상황을 확인한 뒤 다시 시도하세요.",
|
||||
"install_failed": "다운로드를 시작할 수 없습니다: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Resetten naar standaard",
|
||||
"listening_label": "Luisteren…",
|
||||
"transcribing_label": "Transcriberen…",
|
||||
"a11y_setup": "Sta Toegankelijkheid toe zodat dicteren voor je kan typen",
|
||||
"pasted": "Geplakt",
|
||||
"no_speech": "Geen spraak gedetecteerd",
|
||||
"mic_denied": "Microfoontoegang geweigerd",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Modellen installeren",
|
||||
"pick_engines": "Kies motoren",
|
||||
"system_check_desc": "Onderzoek RAM, schijf, GPU, ffmpeg en netwerk. Blokkers worden vooraf gemarkeerd, zodat u het weet voordat u gaat downloaden.",
|
||||
"install_models_desc": "Download ~5 GB aan gewichten — TTS + Whisper. Vereiste modellen eerst, optionele modellen later.",
|
||||
"install_models_desc": "Download ~2,4 GB aan gewichten — alleen het TTS-model is vereist. Voeg transcriptiemodellen op maat van uw hardware nu toe, of later via Instellingen.",
|
||||
"pick_engines_desc": "Kies voor TTS/ASR/LLM-backends. De standaardinstellingen werken kant-en-klaar en kunnen op elk gewenst moment worden aangepast via Instellingen.",
|
||||
"hero_desc": "Dubben, stemklonen en stemontwerp: allemaal lokaal op uw machine.",
|
||||
"first_run": "Het duurt 5 tot 10 minuten om de eerste keer te downloaden. Daarna is elke lancering direct en volledig offline.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Installatie mislukt: {{error}}",
|
||||
"view_on_hf": "Bekijk op KnuffelFace",
|
||||
"install_btn": "Installeren",
|
||||
"section_tts": "TTS (spraaksynthese)",
|
||||
"section_asr": "ASR (offline transcriptie)",
|
||||
"section_dictation": "Dictee (streaming)",
|
||||
"section_diarisation": "Diarisatie",
|
||||
"section_other": "Overig",
|
||||
"show_incompatible": "Incompatibele tonen ({{count}})",
|
||||
"hide_incompatible": "Incompatibele verbergen ({{count}})",
|
||||
"incompatible_title": "Modellen die niet op dit systeem werken",
|
||||
"recommended_title": "Aanbevolen selectie voor deze machine — optioneel",
|
||||
"download_all_remaining_title": "Alle resterende aanbevolen modellen downloaden",
|
||||
"reco_install_one": "{{label}} installeren",
|
||||
"reinstall_btn": "Opnieuw installeren",
|
||||
"downloading": "downloaden",
|
||||
"deleting": "verwijderen",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Hugging Face-token opgeslagen",
|
||||
"hf_token_error": "Token kon niet worden opgeslagen — probeer opnieuw of stel het later in via Instellingen → Inloggegevens.",
|
||||
"chip_recommended": "aanbevolen",
|
||||
"chip_recommended_title": "Aanbevolen voor deze machine — optioneel",
|
||||
"size_left": "{{size}} over",
|
||||
"hf_token_card_title": "Voeg een gratis Hugging Face-token toe voor snellere downloads",
|
||||
"hf_token_get": "Heeft u geen token? Ontvang er één gratis →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Licenties, partnerschappen of iets privés.",
|
||||
"website": "Website",
|
||||
"website_desc": "Meer over het project en de maker."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Machtigingen",
|
||||
"desc": "OmniVoice draait volledig op je apparaat — deze systeemmachtigingen worden alleen gebruikt voor de functies die hier staan, en audio verlaat nooit je computer.",
|
||||
"microphone": "Microfoon",
|
||||
"microphone_why": "Gebruikt voor dicteren en spraakopnamen. Audio wordt lokaal verwerkt.",
|
||||
"accessibility": "Toegankelijkheid",
|
||||
"accessibility_why": "Laat dicteren de tekst in andere apps plakken of typen.",
|
||||
"status_granted": "Verleend",
|
||||
"status_denied": "Geweigerd",
|
||||
"status_prompt": "Nog niet gevraagd",
|
||||
"status_unknown": "Onbekend",
|
||||
"open_settings": "Instellingen openen",
|
||||
"web_note": "De machtigingsstatus is alleen leesbaar in de desktop-app — in de browser beheert de browser zelf de microfoontoegang."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Er is geen spraak-naar-tekstmodel geïnstalleerd — deze functie heeft er een nodig om audio te transcriberen.",
|
||||
"download": "{{label}} downloaden ({{size}} GB)",
|
||||
"started": "{{label}} wordt gedownload — volg de voortgang in Instellingen → Modellen en probeer het daarna opnieuw.",
|
||||
"install_failed": "Kon de download niet starten: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Przywróć ustawienia domyślne",
|
||||
"listening_label": "Słucham…",
|
||||
"transcribing_label": "Transkrypcja…",
|
||||
"a11y_setup": "Zezwól na Dostępność, aby dyktowanie mogło pisać za Ciebie",
|
||||
"pasted": "Wklejony",
|
||||
"no_speech": "Nie wykryto mowy",
|
||||
"mic_denied": "Odmowa dostępu do mikrofonu",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Zainstaluj modele",
|
||||
"pick_engines": "Wybierz silniki",
|
||||
"system_check_desc": "Sprawdź pamięć RAM, dysk, procesor graficzny, ffmpeg i sieć. Blokery są oznaczone od razu, więc wiesz o tym przed pobraniem.",
|
||||
"install_models_desc": "Pobierz ~5 GB odważników — TTS + Whisper. Najpierw wymagane modele, później opcjonalne.",
|
||||
"install_models_desc": "Pobierz ~2,4 GB wag — wymagany jest tylko model TTS. Dodaj modele transkrypcji dobrane do Twojego sprzętu teraz lub później w Ustawieniach.",
|
||||
"pick_engines_desc": "Wybierz backendy TTS / ASR / LLM. Ustawienia domyślne działają od razu po wyjęciu z pudełka — dostosuj je w dowolnym momencie w Ustawieniach.",
|
||||
"hero_desc": "Dubbing, klonowanie głosu i projektowanie głosu — wszystko to działa lokalnie na Twoim komputerze.",
|
||||
"first_run": "Pobranie pierwszego uruchomienia zajmuje 5–10 minut. Następnie każde uruchomienie jest natychmiastowe i całkowicie offline.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Instalacja nie powiodła się: {{error}}",
|
||||
"view_on_hf": "Zobacz na HuggingFace",
|
||||
"install_btn": "Zainstaluj",
|
||||
"section_tts": "TTS (synteza mowy)",
|
||||
"section_asr": "ASR (transkrypcja offline)",
|
||||
"section_dictation": "Dyktowanie (strumieniowe)",
|
||||
"section_diarisation": "Diaryzacja",
|
||||
"section_other": "Inne",
|
||||
"show_incompatible": "Pokaż niekompatybilne ({{count}})",
|
||||
"hide_incompatible": "Ukryj niekompatybilne ({{count}})",
|
||||
"incompatible_title": "Modele, które nie działają na tym systemie",
|
||||
"recommended_title": "Dobrane dla tej maszyny — opcjonalne",
|
||||
"download_all_remaining_title": "Pobierz wszystkie pozostałe zalecane modele",
|
||||
"reco_install_one": "Zainstaluj {{label}}",
|
||||
"reinstall_btn": "Zainstaluj ponownie",
|
||||
"downloading": "pobieranie",
|
||||
"deleting": "usuwanie",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Token Hugging Face zapisany",
|
||||
"hf_token_error": "Nie udało się zapisać tokenu — spróbuj ponownie lub ustaw go później w Ustawieniach → Dane logowania.",
|
||||
"chip_recommended": "zalecane",
|
||||
"chip_recommended_title": "Zalecane dla tej maszyny — opcjonalne",
|
||||
"size_left": "{{size}} wyszedł",
|
||||
"hf_token_card_title": "Dodaj darmowy token Przytulonej Twarzy, aby przyspieszyć pobieranie",
|
||||
"hf_token_get": "Nie masz tokena? Zdobądź jeden za darmo →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Licencje, partnerstwa lub cokolwiek prywatnego.",
|
||||
"website": "Strona internetowa",
|
||||
"website_desc": "Więcej o projekcie i twórcy."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Uprawnienia",
|
||||
"desc": "OmniVoice działa w całości na Twoim urządzeniu — te uprawnienia systemowe służą wyłącznie funkcjom wymienionym tutaj, a dźwięk nigdy nie opuszcza Twojego komputera.",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_why": "Używany do dyktowania i nagrywania głosu. Dźwięk jest przetwarzany lokalnie.",
|
||||
"accessibility": "Dostępność",
|
||||
"accessibility_why": "Pozwala dyktowaniu wklejać lub wpisywać tekst w innych aplikacjach.",
|
||||
"status_granted": "Przyznane",
|
||||
"status_denied": "Odmówiono",
|
||||
"status_prompt": "Jeszcze nie zapytano",
|
||||
"status_unknown": "Nieznany",
|
||||
"open_settings": "Otwórz ustawienia",
|
||||
"web_note": "Stan uprawnień można odczytać tylko w aplikacji desktopowej — w przeglądarce dostępem do mikrofonu zarządza sama przeglądarka."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Nie zainstalowano modelu zamiany mowy na tekst — ta funkcja wymaga go do transkrypcji dźwięku.",
|
||||
"download": "Pobierz {{label}} ({{size}} GB)",
|
||||
"started": "Pobieranie {{label}} — postęp znajdziesz w Ustawienia → Modele, potem spróbuj ponownie.",
|
||||
"install_failed": "Nie udało się rozpocząć pobierania: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Redefinir para o padrão",
|
||||
"listening_label": "Ouvindo…",
|
||||
"transcribing_label": "Transcrevendo…",
|
||||
"a11y_setup": "Permita Acessibilidade para que o ditado possa digitar por você",
|
||||
"pasted": "Colado",
|
||||
"no_speech": "Nenhuma fala detectada",
|
||||
"mic_denied": "Acesso ao microfone negado",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Instalar modelos",
|
||||
"pick_engines": "Escolha motores",
|
||||
"system_check_desc": "Teste RAM, disco, GPU, ffmpeg e rede. Os bloqueadores são sinalizados antecipadamente para que você saiba antes de fazer o download.",
|
||||
"install_models_desc": "Baixe aproximadamente 5 GB de pesos – TTS + Whisper. Primeiro os modelos obrigatórios, depois os opcionais.",
|
||||
"install_models_desc": "Baixe ~2,4 GB de pesos — apenas o modelo TTS é obrigatório. Adicione modelos de transcrição selecionados para seu hardware agora ou depois nas Configurações.",
|
||||
"pick_engines_desc": "Escolha back-ends TTS/ASR/LLM. Os padrões funcionam imediatamente: personalize a qualquer momento nas Configurações.",
|
||||
"hero_desc": "Dublagem, clonagem de voz e design de voz — tudo executado localmente em sua máquina.",
|
||||
"first_run": "A primeira execução leva de 5 a 10 minutos para ser baixada. Depois disso, cada lançamento é instantâneo e totalmente offline.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Falha na instalação: {{error}}",
|
||||
"view_on_hf": "Ver no HuggingFace",
|
||||
"install_btn": "Instalar",
|
||||
"section_tts": "TTS (síntese de voz)",
|
||||
"section_asr": "ASR (transcrição offline)",
|
||||
"section_dictation": "Ditado (streaming)",
|
||||
"section_diarisation": "Diarização",
|
||||
"section_other": "Outros",
|
||||
"show_incompatible": "Mostrar incompatíveis ({{count}})",
|
||||
"hide_incompatible": "Ocultar incompatíveis ({{count}})",
|
||||
"incompatible_title": "Modelos que não funcionam neste sistema",
|
||||
"recommended_title": "Seleção para esta máquina — opcional",
|
||||
"download_all_remaining_title": "Baixar todos os modelos recomendados restantes",
|
||||
"reco_install_one": "Instalar {{label}}",
|
||||
"reinstall_btn": "Reinstale",
|
||||
"downloading": "baixando",
|
||||
"deleting": "excluindo",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Token do Hugging Face salvo",
|
||||
"hf_token_error": "Não foi possível salvar o token — tente de novo ou configure depois em Configurações → Credenciais.",
|
||||
"chip_recommended": "recomendado",
|
||||
"chip_recommended_title": "Recomendado para esta máquina — opcional",
|
||||
"size_left": "{{size}} esquerda",
|
||||
"hf_token_card_title": "Adicione um token Hugging Face gratuito para downloads mais rápidos",
|
||||
"hf_token_get": "Não tem um token? Ganhe um grátis →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Licenciamento, parcerias ou qualquer coisa privada.",
|
||||
"website": "Site",
|
||||
"website_desc": "Mais sobre o projeto e o criador."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Permissões",
|
||||
"desc": "O OmniVoice roda inteiramente no seu dispositivo — estas permissões do sistema são usadas apenas para os recursos listados aqui, e o áudio nunca sai da sua máquina.",
|
||||
"microphone": "Microfone",
|
||||
"microphone_why": "Usado para ditado e gravação de voz. O áudio é processado localmente.",
|
||||
"accessibility": "Acessibilidade",
|
||||
"accessibility_why": "Permite que o ditado cole ou digite o texto em outros aplicativos.",
|
||||
"status_granted": "Concedida",
|
||||
"status_denied": "Negada",
|
||||
"status_prompt": "Ainda não solicitada",
|
||||
"status_unknown": "Desconhecida",
|
||||
"open_settings": "Abrir configurações",
|
||||
"web_note": "O status das permissões só pode ser lido no aplicativo de desktop — no navegador, o acesso ao microfone é gerenciado pelo próprio navegador."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Nenhum modelo de fala para texto está instalado — este recurso precisa de um para transcrever áudio.",
|
||||
"download": "Baixar {{label}} ({{size}} GB)",
|
||||
"started": "Baixando {{label}} — acompanhe o progresso em Configurações → Modelos e tente novamente.",
|
||||
"install_failed": "Não foi possível iniciar o download: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Сбросить настройки по умолчанию",
|
||||
"listening_label": "Слушаю…",
|
||||
"transcribing_label": "Расшифровка…",
|
||||
"a11y_setup": "Разрешите Универсальный доступ, чтобы диктовка могла печатать за вас",
|
||||
"pasted": "Вставлено",
|
||||
"no_speech": "Речь не обнаружена",
|
||||
"mic_denied": "Доступ к микрофону запрещен",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Установить модели",
|
||||
"pick_engines": "Выберите двигатели",
|
||||
"system_check_desc": "Проверьте оперативную память, диск, графический процессор, ffmpeg и сеть. Блокировщики помечаются заранее, поэтому вы узнаете об этом перед загрузкой.",
|
||||
"install_models_desc": "Скачать ~5 ГБ весов — TTS + Whisper. Сначала нужные модели, потом дополнительные.",
|
||||
"install_models_desc": "Скачайте ~2,4 ГБ весов — обязательна только модель TTS. Добавьте модели транскрипции, подобранные под ваше оборудование, сейчас или позже в Настройках.",
|
||||
"pick_engines_desc": "Выберите серверные части TTS/ASR/LLM. Значения по умолчанию работают «из коробки» — измените их в любое время в настройках.",
|
||||
"hero_desc": "Дублирование, клонирование голоса и оформление голоса — все это выполняется локально на вашем компьютере.",
|
||||
"first_run": "Загрузка первого запуска занимает 5–10 минут. После этого каждый запуск происходит мгновенно и полностью оффлайн.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Не удалось установить: {{error}}",
|
||||
"view_on_hf": "Посмотреть на HuggingFace",
|
||||
"install_btn": "Установить",
|
||||
"section_tts": "TTS (синтез речи)",
|
||||
"section_asr": "ASR (офлайн-транскрипция)",
|
||||
"section_dictation": "Диктовка (потоковая)",
|
||||
"section_diarisation": "Диаризация",
|
||||
"section_other": "Другое",
|
||||
"show_incompatible": "Показать несовместимые ({{count}})",
|
||||
"hide_incompatible": "Скрыть несовместимые ({{count}})",
|
||||
"incompatible_title": "Модели, которые не работают на этой системе",
|
||||
"recommended_title": "Подобрано для этой машины — необязательно",
|
||||
"download_all_remaining_title": "Скачать все оставшиеся рекомендуемые модели",
|
||||
"reco_install_one": "Установить {{label}}",
|
||||
"reinstall_btn": "Переустановить",
|
||||
"downloading": "загрузка",
|
||||
"deleting": "удаление",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Токен Hugging Face сохранён",
|
||||
"hf_token_error": "Не удалось сохранить токен — повторите или задайте позже в Настройках → Учётные данные.",
|
||||
"chip_recommended": "рекомендуется",
|
||||
"chip_recommended_title": "Рекомендуется для этой машины — необязательно",
|
||||
"size_left": "{{size}} осталось",
|
||||
"hf_token_card_title": "Добавьте бесплатный токен Hugging Face для более быстрой загрузки.",
|
||||
"hf_token_get": "У вас нет жетона? Получите один бесплатно →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Лицензирование, партнерство или что-то частное.",
|
||||
"website": "Веб-сайт",
|
||||
"website_desc": "Подробнее о проекте и создателе."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Разрешения",
|
||||
"desc": "OmniVoice работает полностью на вашем устройстве — эти системные разрешения используются только для перечисленных здесь функций, и аудио никогда не покидает ваш компьютер.",
|
||||
"microphone": "Микрофон",
|
||||
"microphone_why": "Используется для диктовки и записи голоса. Аудио обрабатывается локально.",
|
||||
"accessibility": "Универсальный доступ",
|
||||
"accessibility_why": "Позволяет диктовке вставлять или вводить текст в другие приложения.",
|
||||
"status_granted": "Разрешено",
|
||||
"status_denied": "Отклонено",
|
||||
"status_prompt": "Ещё не запрошено",
|
||||
"status_unknown": "Неизвестно",
|
||||
"open_settings": "Открыть настройки",
|
||||
"web_note": "Статус разрешений доступен только в настольном приложении — в браузере доступом к микрофону управляет сам браузер."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Модель распознавания речи не установлена — для транскрипции аудио она необходима.",
|
||||
"download": "Скачать {{label}} ({{size}} ГБ)",
|
||||
"started": "Скачивание {{label}} — следите за прогрессом в Настройки → Модели, затем повторите попытку.",
|
||||
"install_failed": "Не удалось начать загрузку: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Återställ till standard",
|
||||
"listening_label": "Lyssnar...",
|
||||
"transcribing_label": "Transkriberar...",
|
||||
"a11y_setup": "Tillåt Hjälpmedel så att diktering kan skriva åt dig",
|
||||
"pasted": "Klistras in",
|
||||
"no_speech": "Inget tal upptäckt",
|
||||
"mic_denied": "Mikrofonåtkomst nekad",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Installera modeller",
|
||||
"pick_engines": "Välj motorer",
|
||||
"system_check_desc": "Probe RAM, disk, GPU, ffmpeg och nätverk. Blockerare flaggas i förväg så att du vet innan du laddar ner.",
|
||||
"install_models_desc": "Ladda ner ~5 GB vikter — TTS + Whisper. Nödvändiga modeller först, valfria senare.",
|
||||
"install_models_desc": "Ladda ner ~2,4 GB vikter — endast TTS-modellen krävs. Lägg till transkriptionsmodeller utvalda för din hårdvara nu eller senare via Inställningar.",
|
||||
"pick_engines_desc": "Välj TTS / ASR / LLM backends. Standardinställningarna fungerar direkt – anpassa när som helst i Inställningar.",
|
||||
"hero_desc": "Dubbning, röstkloning och röstdesign – allt körs lokalt på din maskin.",
|
||||
"first_run": "Första körningen tar 5–10 minuter att ladda ner. Efter det är varje lansering omedelbar och helt offline.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Installationen misslyckades: {{error}}",
|
||||
"view_on_hf": "Se på HuggingFace",
|
||||
"install_btn": "Installera",
|
||||
"section_tts": "TTS (talsyntes)",
|
||||
"section_asr": "ASR (offlinetranskribering)",
|
||||
"section_dictation": "Diktering (strömmande)",
|
||||
"section_diarisation": "Diarisering",
|
||||
"section_other": "Övrigt",
|
||||
"show_incompatible": "Visa inkompatibla ({{count}})",
|
||||
"hide_incompatible": "Dölj inkompatibla ({{count}})",
|
||||
"incompatible_title": "Modeller som inte fungerar på det här systemet",
|
||||
"recommended_title": "Utvalt för den här datorn — valfritt",
|
||||
"download_all_remaining_title": "Ladda ner alla återstående rekommenderade modeller",
|
||||
"reco_install_one": "Installera {{label}}",
|
||||
"reinstall_btn": "Installera om",
|
||||
"downloading": "laddar ner",
|
||||
"deleting": "radera",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Hugging Face-token sparad",
|
||||
"hf_token_error": "Kunde inte spara token — försök igen eller ange den senare i Inställningar → Uppgifter.",
|
||||
"chip_recommended": "rekommenderas",
|
||||
"chip_recommended_title": "Rekommenderas för den här datorn — valfritt",
|
||||
"size_left": "{{size}} kvar",
|
||||
"hf_token_card_title": "Lägg till ett gratis Hugging Face-token för snabbare nedladdningar",
|
||||
"hf_token_get": "Har du ingen token? Få en gratis →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Licensiering, partnerskap eller något privat.",
|
||||
"website": "Webbplats",
|
||||
"website_desc": "Mer om projektet och skaparen."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Behörigheter",
|
||||
"desc": "OmniVoice körs helt på din enhet — dessa systembehörigheter används bara för funktionerna som listas här, och ljud lämnar aldrig din dator.",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_why": "Används för diktering och röstinspelning. Ljudet bearbetas lokalt.",
|
||||
"accessibility": "Hjälpmedel",
|
||||
"accessibility_why": "Låter dikteringen klistra in eller skriva texten i andra appar.",
|
||||
"status_granted": "Beviljad",
|
||||
"status_denied": "Nekad",
|
||||
"status_prompt": "Inte tillfrågad ännu",
|
||||
"status_unknown": "Okänd",
|
||||
"open_settings": "Öppna inställningar",
|
||||
"web_note": "Behörighetsstatus kan bara läsas i skrivbordsappen — i webbläsaren hanterar webbläsaren själv mikrofonåtkomsten."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Ingen tal-till-text-modell är installerad — den här funktionen behöver en för att transkribera ljud.",
|
||||
"download": "Ladda ner {{label}} ({{size}} GB)",
|
||||
"started": "Laddar ner {{label}} — följ förloppet i Inställningar → Modeller och försök sedan igen.",
|
||||
"install_failed": "Kunde inte starta nedladdningen: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "รีเซ็ตเป็นค่าเริ่มต้น",
|
||||
"listening_label": "กำลังฟัง...",
|
||||
"transcribing_label": "กำลังถอดเสียง...",
|
||||
"a11y_setup": "อนุญาตการช่วยการเข้าถึงเพื่อให้การป้อนตามคำบอกพิมพ์แทนคุณได้",
|
||||
"pasted": "วางแล้ว",
|
||||
"no_speech": "ไม่พบคำพูด",
|
||||
"mic_denied": "การเข้าถึงไมค์ถูกปฏิเสธ",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "ติดตั้งโมเดล",
|
||||
"pick_engines": "เลือกเครื่องยนต์",
|
||||
"system_check_desc": "โพรบ RAM, ดิสก์, GPU, ffmpeg และเครือข่าย ตัวบล็อกจะถูกตั้งค่าสถานะล่วงหน้าเพื่อให้คุณทราบก่อนที่จะดาวน์โหลด",
|
||||
"install_models_desc": "ดาวน์โหลดน้ำหนักประมาณ 5 GB — TTS + Whisper รุ่นที่จำเป็นก่อน แล้วรุ่นเสริมในภายหลัง",
|
||||
"install_models_desc": "ดาวน์โหลดน้ำหนักประมาณ 2.4 GB — จำเป็นต้องใช้เฉพาะโมเดล TTS เท่านั้น เพิ่มโมเดลถอดความที่คัดสรรสำหรับฮาร์ดแวร์ของคุณตอนนี้ หรือภายหลังจากการตั้งค่า",
|
||||
"pick_engines_desc": "เลือกแบ็กเอนด์ TTS / ASR / LLM ค่าเริ่มต้นใช้งานได้ทันที — ปรับแต่งได้ทุกเมื่อในการตั้งค่า",
|
||||
"hero_desc": "Dubbing, voice cloning, and voice design — all running locally on your machine.",
|
||||
"first_run": "การรันครั้งแรกใช้เวลาดาวน์โหลด 5–10 นาที หลังจากนั้น ทุกการเปิดตัวจะเป็นทันทีและออฟไลน์โดยสมบูรณ์",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "การติดตั้งล้มเหลว: {{error}}",
|
||||
"view_on_hf": "ดูบน HuggingFace",
|
||||
"install_btn": "ติดตั้ง",
|
||||
"section_tts": "TTS (สังเคราะห์เสียงพูด)",
|
||||
"section_asr": "ASR (ถอดความออฟไลน์)",
|
||||
"section_dictation": "ป้อนตามคำบอก (สตรีมมิง)",
|
||||
"section_diarisation": "แยกผู้พูด",
|
||||
"section_other": "อื่น ๆ",
|
||||
"show_incompatible": "แสดงรุ่นที่เข้ากันไม่ได้ ({{count}})",
|
||||
"hide_incompatible": "ซ่อนรุ่นที่เข้ากันไม่ได้ ({{count}})",
|
||||
"incompatible_title": "โมเดลที่ไม่สามารถทำงานบนระบบนี้",
|
||||
"recommended_title": "คัดสรรสำหรับเครื่องนี้ — ไม่บังคับ",
|
||||
"download_all_remaining_title": "ดาวน์โหลดโมเดลแนะนำที่เหลือทั้งหมด",
|
||||
"reco_install_one": "ติดตั้ง {{label}}",
|
||||
"reinstall_btn": "ติดตั้งใหม่",
|
||||
"downloading": "กำลังดาวน์โหลด",
|
||||
"deleting": "กำลังลบ",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "บันทึกโทเคน Hugging Face แล้ว",
|
||||
"hf_token_error": "บันทึกโทเคนไม่สำเร็จ — ลองใหม่ หรือไปตั้งค่าภายหลังที่ การตั้งค่า → ข้อมูลรับรอง",
|
||||
"chip_recommended": "แนะนำ",
|
||||
"chip_recommended_title": "แนะนำสำหรับเครื่องนี้ — ไม่บังคับ",
|
||||
"size_left": "{{size}} ออกไป",
|
||||
"hf_token_card_title": "เพิ่มโทเค็น Hugging Face ฟรีเพื่อการดาวน์โหลดที่เร็วขึ้น",
|
||||
"hf_token_get": "ไม่ได้มีโทเค็น? รับฟรีหนึ่งรายการ →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "ใบอนุญาต ห้างหุ้นส่วน หรือสิ่งใดก็ตามที่เป็นส่วนตัว",
|
||||
"website": "เว็บไซต์",
|
||||
"website_desc": "ข้อมูลเพิ่มเติมเกี่ยวกับโครงการและผู้สร้าง"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "สิทธิ์การเข้าถึง",
|
||||
"desc": "OmniVoice ทำงานทั้งหมดบนอุปกรณ์ของคุณ — สิทธิ์ระบบเหล่านี้ใช้เฉพาะกับฟีเจอร์ที่ระบุไว้ที่นี่ และเสียงจะไม่ออกจากเครื่องของคุณเลย",
|
||||
"microphone": "ไมโครโฟน",
|
||||
"microphone_why": "ใช้สำหรับการป้อนตามคำบอกและการอัดเสียง เสียงถูกประมวลผลในเครื่อง",
|
||||
"accessibility": "การช่วยการเข้าถึง",
|
||||
"accessibility_why": "ให้การป้อนตามคำบอกวางหรือพิมพ์ข้อความลงในแอปอื่นได้",
|
||||
"status_granted": "อนุญาตแล้ว",
|
||||
"status_denied": "ถูกปฏิเสธ",
|
||||
"status_prompt": "ยังไม่ได้ขอ",
|
||||
"status_unknown": "ไม่ทราบ",
|
||||
"open_settings": "เปิดการตั้งค่า",
|
||||
"web_note": "สถานะสิทธิ์อ่านได้เฉพาะในแอปเดสก์ท็อป — ในเบราว์เซอร์ การเข้าถึงไมโครโฟนถูกจัดการโดยเบราว์เซอร์เอง"
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "ยังไม่ได้ติดตั้งโมเดลแปลงเสียงเป็นข้อความ — ฟีเจอร์นี้ต้องใช้โมเดลเพื่อถอดเสียง",
|
||||
"download": "ดาวน์โหลด {{label}} ({{size}} GB)",
|
||||
"started": "กำลังดาวน์โหลด {{label}} — ดูความคืบหน้าได้ที่ การตั้งค่า → โมเดล แล้วลองอีกครั้ง",
|
||||
"install_failed": "ไม่สามารถเริ่มการดาวน์โหลดได้: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Varsayılana sıfırla",
|
||||
"listening_label": "Dinleniyor...",
|
||||
"transcribing_label": "Metne dönüştürülüyor…",
|
||||
"a11y_setup": "Dikte sizin yerinize yazabilsin diye Erişilebilirlik'e izin verin",
|
||||
"pasted": "Yapıştırıldı",
|
||||
"no_speech": "Konuşma algılanmadı",
|
||||
"mic_denied": "Mikrofon erişimi reddedildi",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Modelleri yükle",
|
||||
"pick_engines": "Motorları seç",
|
||||
"system_check_desc": "RAM, disk, GPU, ffmpeg ve ağı araştırın. Engelleyiciler önceden işaretlenir, böylece indirmeden önce bilgi sahibi olursunuz.",
|
||||
"install_models_desc": "~5 GB ağırlık indirin — TTS + Whisper. Önce gerekli modeller, daha sonra isteğe bağlı modeller.",
|
||||
"install_models_desc": "~2,4 GB ağırlık indirin — yalnızca TTS modeli gereklidir. Donanımınız için seçilmiş transkripsiyon modellerini şimdi veya daha sonra Ayarlar'dan ekleyin.",
|
||||
"pick_engines_desc": "TTS / ASR / LLM arka uçlarını seçin. Varsayılanlar kutudan çıktığı gibi çalışır; istediğiniz zaman Ayarlar'dan özelleştirebilirsiniz.",
|
||||
"hero_desc": "Dublaj, ses klonlama ve ses tasarımı; tümü makinenizde yerel olarak çalışır.",
|
||||
"first_run": "İlk çalıştırmanın indirilmesi 5-10 dakika sürer. Bundan sonra her başlatma anında ve tamamen çevrimdışı olur.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Yükleme başarısız oldu: {{error}}",
|
||||
"view_on_hf": "HuggingFace'te görüntüle",
|
||||
"install_btn": "Yükle",
|
||||
"section_tts": "TTS (konuşma sentezi)",
|
||||
"section_asr": "ASR (çevrimdışı transkripsiyon)",
|
||||
"section_dictation": "Dikte (akış)",
|
||||
"section_diarisation": "Konuşmacı ayrıştırma",
|
||||
"section_other": "Diğer",
|
||||
"show_incompatible": "Uyumsuzları göster ({{count}})",
|
||||
"hide_incompatible": "Uyumsuzları gizle ({{count}})",
|
||||
"incompatible_title": "Bu sistemde çalışmayan modeller",
|
||||
"recommended_title": "Bu makine için seçilmiş — isteğe bağlı",
|
||||
"download_all_remaining_title": "Kalan tüm önerilen modelleri indir",
|
||||
"reco_install_one": "{{label}} yükle",
|
||||
"reinstall_btn": "Yeniden yükle",
|
||||
"downloading": "indiriliyor",
|
||||
"deleting": "siliniyor",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Hugging Face belirteci kaydedildi",
|
||||
"hf_token_error": "Belirteç kaydedilemedi — yeniden deneyin veya sonra Ayarlar → Kimlik bilgileri bölümünden ayarlayın.",
|
||||
"chip_recommended": "tavsiye edilir",
|
||||
"chip_recommended_title": "Bu makine için önerilir — isteğe bağlı",
|
||||
"size_left": "{{size}} kaldı",
|
||||
"hf_token_card_title": "Daha hızlı indirmeler için ücretsiz bir Hugging Face belirteci ekleyin",
|
||||
"hf_token_get": "Jetonunuz yok mu? Ücretsiz bir tane alın →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Lisanslama, ortaklıklar veya özel herhangi bir şey.",
|
||||
"website": "Web sitesi",
|
||||
"website_desc": "Proje ve yapımcı hakkında daha fazla bilgi."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "İzinler",
|
||||
"desc": "OmniVoice tamamen cihazınızda çalışır — bu sistem izinleri yalnızca burada listelenen özellikler için kullanılır ve ses asla makinenizden ayrılmaz.",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_why": "Dikte ve ses kaydı için kullanılır. Ses yerel olarak işlenir.",
|
||||
"accessibility": "Erişilebilirlik",
|
||||
"accessibility_why": "Diktenin metni diğer uygulamalara yapıştırmasına veya yazmasına izin verir.",
|
||||
"status_granted": "Verildi",
|
||||
"status_denied": "Reddedildi",
|
||||
"status_prompt": "Henüz istenmedi",
|
||||
"status_unknown": "Bilinmiyor",
|
||||
"open_settings": "Ayarları Aç",
|
||||
"web_note": "İzin durumu yalnızca masaüstü uygulamasında okunabilir — tarayıcıda mikrofon erişimini tarayıcının kendisi yönetir."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Yüklü bir konuşmadan metne modeli yok — bu özellik sesi yazıya dökmek için bir model gerektirir.",
|
||||
"download": "{{label}} indir ({{size}} GB)",
|
||||
"started": "{{label}} indiriliyor — ilerlemeyi Ayarlar → Modeller'den izleyin, sonra yeniden deneyin.",
|
||||
"install_failed": "İndirme başlatılamadı: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Скинути до замовчування",
|
||||
"listening_label": "слухаю...",
|
||||
"transcribing_label": "Транскрибування…",
|
||||
"a11y_setup": "Дозвольте Доступність, щоб диктування могло друкувати за вас",
|
||||
"pasted": "Вставив",
|
||||
"no_speech": "Мовлення не виявлено",
|
||||
"mic_denied": "Доступ до мікрофона заборонено",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Встановити моделі",
|
||||
"pick_engines": "Вибирайте двигуни",
|
||||
"system_check_desc": "Перевірте оперативну пам’ять, диск, графічний процесор, ffmpeg і мережу. Blockers are flagged upfront so you know before downloading.",
|
||||
"install_models_desc": "Завантажити ~5 Гб ваг — TTS + Whisper. Спершу потрібні моделі, потім додаткові.",
|
||||
"install_models_desc": "Завантажте ~2,4 ГБ ваг — обов'язковою є лише модель TTS. Додайте моделі транскрипції, підібрані для вашого обладнання, зараз або пізніше в Налаштуваннях.",
|
||||
"pick_engines_desc": "Виберіть серверні модулі TTS / ASR / LLM. Налаштування за замовчуванням працюють із коробки — налаштуйте будь-коли в налаштуваннях.",
|
||||
"hero_desc": "Дубляж, клонування голосу та дизайн голосу — усе це працює локально на вашому комп’ютері.",
|
||||
"first_run": "Завантаження першого запуску займає 5–10 хвилин. Після цього кожен запуск відбувається миттєво та повністю в автономному режимі.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Помилка встановлення: {{error}}",
|
||||
"view_on_hf": "Переглянути на HuggingFace",
|
||||
"install_btn": "встановити",
|
||||
"section_tts": "TTS (синтез мовлення)",
|
||||
"section_asr": "ASR (офлайн-транскрипція)",
|
||||
"section_dictation": "Диктування (потокове)",
|
||||
"section_diarisation": "Діаризація",
|
||||
"section_other": "Інше",
|
||||
"show_incompatible": "Показати несумісні ({{count}})",
|
||||
"hide_incompatible": "Приховати несумісні ({{count}})",
|
||||
"incompatible_title": "Моделі, що не працюють на цій системі",
|
||||
"recommended_title": "Підібрано для цієї машини — необов’язково",
|
||||
"download_all_remaining_title": "Завантажити всі рекомендовані моделі, що залишилися",
|
||||
"reco_install_one": "Встановити {{label}}",
|
||||
"reinstall_btn": "Перевстановити",
|
||||
"downloading": "завантаження",
|
||||
"deleting": "видалення",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Токен Hugging Face збережено",
|
||||
"hf_token_error": "Не вдалося зберегти токен — повторіть або задайте пізніше в Налаштування → Облікові дані.",
|
||||
"chip_recommended": "рекомендований",
|
||||
"chip_recommended_title": "Рекомендовано для цієї машини — необов’язково",
|
||||
"size_left": "залишилося {{size}}",
|
||||
"hf_token_card_title": "Додайте безкоштовний жетон Hugging Face для швидшого завантаження",
|
||||
"hf_token_get": "Немає жетона? Отримайте один безкоштовно →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Ліцензування, партнерство чи щось приватне.",
|
||||
"website": "Веб-сайт",
|
||||
"website_desc": "Детальніше про проект та виробника."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Дозволи",
|
||||
"desc": "OmniVoice працює повністю на вашому пристрої — ці системні дозволи використовуються лише для перелічених тут функцій, і аудіо ніколи не залишає ваш комп'ютер.",
|
||||
"microphone": "Мікрофон",
|
||||
"microphone_why": "Використовується для диктування та запису голосу. Аудіо обробляється локально.",
|
||||
"accessibility": "Універсальний доступ",
|
||||
"accessibility_why": "Дозволяє диктуванню вставляти або вводити текст в інші застосунки.",
|
||||
"status_granted": "Надано",
|
||||
"status_denied": "Відмовлено",
|
||||
"status_prompt": "Ще не запитано",
|
||||
"status_unknown": "Невідомо",
|
||||
"open_settings": "Відкрити налаштування",
|
||||
"web_note": "Стан дозволів можна прочитати лише в настільному застосунку — у браузері доступом до мікрофона керує сам браузер."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Модель розпізнавання мовлення не встановлено — ця функція потребує її для транскрипції аудіо.",
|
||||
"download": "Завантажити {{label}} ({{size}} ГБ)",
|
||||
"started": "Завантаження {{label}} — стежте за прогресом у Налаштування → Моделі, потім повторіть спробу.",
|
||||
"install_failed": "Не вдалося розпочати завантаження: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "Đặt lại về mặc định",
|
||||
"listening_label": "Đang nghe…",
|
||||
"transcribing_label": "Phiên âm…",
|
||||
"a11y_setup": "Cho phép Trợ năng để đọc chính tả có thể gõ thay bạn",
|
||||
"pasted": "Đã dán",
|
||||
"no_speech": "Không phát hiện thấy giọng nói nào",
|
||||
"mic_denied": "Quyền truy cập micrô bị từ chối",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "Cài đặt mô hình",
|
||||
"pick_engines": "Chọn động cơ",
|
||||
"system_check_desc": "Thăm dò RAM, đĩa, GPU, ffmpeg và mạng. Trình chặn được gắn cờ trước để bạn biết trước khi tải xuống.",
|
||||
"install_models_desc": "Tải xuống trọng lượng ~5 GB - TTS + Whisper. Mẫu bắt buộc trước, mẫu tùy chọn sau.",
|
||||
"install_models_desc": "Tải xuống ~2,4 GB trọng số — chỉ bắt buộc mô hình TTS. Thêm các mô hình phiên âm được chọn cho phần cứng của bạn ngay bây giờ hoặc sau này trong Cài đặt.",
|
||||
"pick_engines_desc": "Chọn phụ trợ TTS / ASR / LLM. Các cài đặt mặc định hoạt động ngay lập tức — tùy chỉnh bất cứ lúc nào trong Cài đặt.",
|
||||
"hero_desc": "Lồng tiếng, sao chép giọng nói và thiết kế giọng nói — tất cả đều chạy cục bộ trên máy của bạn.",
|
||||
"first_run": "Lần chạy đầu tiên mất 5–10 phút để tải xuống. Sau đó, mọi lần khởi chạy đều diễn ra ngay lập tức và hoàn toàn ngoại tuyến.",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "Cài đặt không thành công: {{error}}",
|
||||
"view_on_hf": "Xem trên HuggingFace",
|
||||
"install_btn": "cài đặt",
|
||||
"section_tts": "TTS (tổng hợp giọng nói)",
|
||||
"section_asr": "ASR (phiên âm ngoại tuyến)",
|
||||
"section_dictation": "Chính tả (streaming)",
|
||||
"section_diarisation": "Phân tách người nói",
|
||||
"section_other": "Khác",
|
||||
"show_incompatible": "Hiện các mô hình không tương thích ({{count}})",
|
||||
"hide_incompatible": "Ẩn các mô hình không tương thích ({{count}})",
|
||||
"incompatible_title": "Các mô hình không chạy được trên hệ thống này",
|
||||
"recommended_title": "Được chọn cho máy này — tùy chọn",
|
||||
"download_all_remaining_title": "Tải xuống tất cả mô hình đề xuất còn lại",
|
||||
"reco_install_one": "Cài đặt {{label}}",
|
||||
"reinstall_btn": "Cài đặt lại",
|
||||
"downloading": "đang tải xuống",
|
||||
"deleting": "xóa",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Đã lưu token Hugging Face",
|
||||
"hf_token_error": "Không thể lưu token — thử lại hoặc đặt sau trong Cài đặt → Thông tin xác thực.",
|
||||
"chip_recommended": "đề nghị",
|
||||
"chip_recommended_title": "Đề xuất cho máy này — tùy chọn",
|
||||
"size_left": "{{size}} còn lại",
|
||||
"hf_token_card_title": "Thêm mã thông báo Ôm Mặt miễn phí để tải xuống nhanh hơn",
|
||||
"hf_token_get": "Bạn không có mã thông báo? Nhận một cái miễn phí →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "Cấp phép, hợp tác hoặc bất cứ điều gì riêng tư.",
|
||||
"website": "Trang web",
|
||||
"website_desc": "Thông tin thêm về dự án và nhà sản xuất."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Quyền truy cập",
|
||||
"desc": "OmniVoice chạy hoàn toàn trên thiết bị của bạn — các quyền hệ thống này chỉ dùng cho các tính năng liệt kê ở đây, và âm thanh không bao giờ rời khỏi máy của bạn.",
|
||||
"microphone": "Micrô",
|
||||
"microphone_why": "Dùng cho đọc chính tả và ghi âm giọng nói. Âm thanh được xử lý cục bộ.",
|
||||
"accessibility": "Trợ năng",
|
||||
"accessibility_why": "Cho phép đọc chính tả dán hoặc gõ văn bản vào các ứng dụng khác.",
|
||||
"status_granted": "Đã cấp",
|
||||
"status_denied": "Bị từ chối",
|
||||
"status_prompt": "Chưa được hỏi",
|
||||
"status_unknown": "Không rõ",
|
||||
"open_settings": "Mở Cài đặt",
|
||||
"web_note": "Trạng thái quyền chỉ đọc được trong ứng dụng máy tính — trên trình duyệt, quyền truy cập micrô do chính trình duyệt quản lý."
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "Chưa cài đặt mô hình chuyển giọng nói thành văn bản — tính năng này cần một mô hình để phiên âm.",
|
||||
"download": "Tải xuống {{label}} ({{size}} GB)",
|
||||
"started": "Đang tải {{label}} — theo dõi tiến trình trong Cài đặt → Mô hình, sau đó thử lại.",
|
||||
"install_failed": "Không thể bắt đầu tải xuống: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,6 +547,7 @@
|
||||
"reset_default": "恢复默认",
|
||||
"listening_label": "正在听……",
|
||||
"transcribing_label": "正在抄写…",
|
||||
"a11y_setup": "允许辅助功能,以便听写为您输入文字",
|
||||
"pasted": "粘贴的",
|
||||
"no_speech": "未检测到语音",
|
||||
"mic_denied": "麦克风访问被拒绝",
|
||||
@@ -1148,7 +1149,7 @@
|
||||
"install_models": "安装模型",
|
||||
"pick_engines": "选择引擎",
|
||||
"system_check_desc": "检查内存、磁盘、GPU、ffmpeg 和网络。提前发现阻塞项。",
|
||||
"install_models_desc": "下载约 5 GB 模型权重 — TTS + Whisper。优先安装必需模型,可选模型稍后安装。",
|
||||
"install_models_desc": "下载约 2.4 GB 模型权重 — 仅 TTS 模型为必需。可现在安装为您的硬件精选的转录模型,也可稍后在设置中添加。",
|
||||
"pick_engines_desc": "选择 TTS/ASR/LLM 后端。默认配置开箱即用 — 随时在设置中自定义。",
|
||||
"hero_desc": "配音、声音克隆和声音设计 — 全部在本地运行。",
|
||||
"first_run": "首次运行需要 5-10 分钟下载。之后每次启动都是即时且完全离线的。",
|
||||
@@ -1681,6 +1682,17 @@
|
||||
"install_error": "安装失败:{{error}}",
|
||||
"view_on_hf": "在 HuggingFace 上查看",
|
||||
"install_btn": "安装",
|
||||
"section_tts": "TTS(语音合成)",
|
||||
"section_asr": "ASR(离线转写)",
|
||||
"section_dictation": "听写(流式)",
|
||||
"section_diarisation": "说话人分离",
|
||||
"section_other": "其他",
|
||||
"show_incompatible": "显示不兼容项 ({{count}})",
|
||||
"hide_incompatible": "隐藏不兼容项 ({{count}})",
|
||||
"incompatible_title": "无法在此系统上运行的模型",
|
||||
"recommended_title": "为本机精选 — 可选",
|
||||
"download_all_remaining_title": "下载所有剩余推荐模型",
|
||||
"reco_install_one": "安装 {{label}}",
|
||||
"reinstall_btn": "重新安装",
|
||||
"downloading": "下载",
|
||||
"deleting": "删除",
|
||||
@@ -1980,6 +1992,7 @@
|
||||
"hf_token_saved": "Hugging Face 令牌已保存",
|
||||
"hf_token_error": "令牌保存失败——请重试,或稍后在设置 → 凭据中设置。",
|
||||
"chip_recommended": "推荐",
|
||||
"chip_recommended_title": "推荐用于本机 — 可选",
|
||||
"size_left": "{{size}} 左",
|
||||
"hf_token_card_title": "添加免费的 Hugging Face 令牌以加快下载速度",
|
||||
"hf_token_get": "没有令牌?免费获得一份 →",
|
||||
@@ -2176,5 +2189,25 @@
|
||||
"email_desc": "许可、合作伙伴关系或任何私人的东西。",
|
||||
"website": "网站",
|
||||
"website_desc": "有关该项目和制造商的更多信息。"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "权限",
|
||||
"desc": "OmniVoice 完全在您的设备上运行——这些系统权限仅用于此处列出的功能,音频绝不会离开您的电脑。",
|
||||
"microphone": "麦克风",
|
||||
"microphone_why": "用于听写和语音录制。音频在本地处理。",
|
||||
"accessibility": "辅助功能",
|
||||
"accessibility_why": "允许听写将文本粘贴或输入到其他应用中。",
|
||||
"status_granted": "已授权",
|
||||
"status_denied": "已拒绝",
|
||||
"status_prompt": "尚未请求",
|
||||
"status_unknown": "未知",
|
||||
"open_settings": "打开设置",
|
||||
"web_note": "权限状态仅可在桌面应用中读取——在浏览器中,麦克风访问由浏览器自行管理。"
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "尚未安装语音转文字模型 — 此功能需要一个模型来转写音频。",
|
||||
"download": "下载 {{label}}({{size}} GB)",
|
||||
"started": "正在下载 {{label}} — 请在 设置 → 模型 中查看进度,完成后重试。",
|
||||
"install_failed": "无法开始下载:{{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"reset_default": "重設為預設值",
|
||||
"listening_label": "正在聽…",
|
||||
"transcribing_label": "正在抄寫…",
|
||||
"a11y_setup": "允許輔助使用,讓聽寫功能為您輸入文字",
|
||||
"pasted": "貼上的",
|
||||
"no_speech": "未偵測到語音",
|
||||
"mic_denied": "麥克風存取被拒絕",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"install_models": "安裝型號",
|
||||
"pick_engines": "選擇引擎",
|
||||
"system_check_desc": "探測 RAM、磁碟、GPU、ffmpeg 和網路。攔截器會預先標記,以便您在下載前知道。",
|
||||
"install_models_desc": "下載約 5 GB 的權重 — TTS + Whisper。首先是必需的型號,然後是可選的型號。",
|
||||
"install_models_desc": "下載約 2.4 GB 的權重 — 僅 TTS 模型為必需。可立即安裝為您的硬體精選的轉錄模型,或稍後在設定中新增。",
|
||||
"pick_engines_desc": "選擇 TTS / ASR / LLM 後端。預設設定開箱即用 - 可以隨時在“設定”中自訂。",
|
||||
"hero_desc": "配音、語音克隆和語音設計 - 所有這些都在您的電腦上本地運行。",
|
||||
"first_run": "首次運行需要 5-10 分鐘才能下載。此後,每次啟動都是即時且完全離線的。",
|
||||
@@ -1674,6 +1675,17 @@
|
||||
"install_error": "安裝失敗:{{error}}",
|
||||
"view_on_hf": "在 HuggingFace 上查看",
|
||||
"install_btn": "安裝",
|
||||
"section_tts": "TTS(語音合成)",
|
||||
"section_asr": "ASR(離線轉寫)",
|
||||
"section_dictation": "聽寫(串流)",
|
||||
"section_diarisation": "說話者分離",
|
||||
"section_other": "其他",
|
||||
"show_incompatible": "顯示不相容項 ({{count}})",
|
||||
"hide_incompatible": "隱藏不相容項 ({{count}})",
|
||||
"incompatible_title": "無法在此系統上執行的模型",
|
||||
"recommended_title": "為本機精選 — 可選",
|
||||
"download_all_remaining_title": "下載所有剩餘推薦模型",
|
||||
"reco_install_one": "安裝 {{label}}",
|
||||
"reinstall_btn": "重新安裝",
|
||||
"downloading": "下載",
|
||||
"deleting": "刪除",
|
||||
@@ -1973,6 +1985,7 @@
|
||||
"hf_token_saved": "Hugging Face 權杖已儲存",
|
||||
"hf_token_error": "權杖儲存失敗——請重試,或稍後在設定 → 憑證中設定。",
|
||||
"chip_recommended": "推薦",
|
||||
"chip_recommended_title": "建議用於本機 — 可選",
|
||||
"size_left": "{{size}} 左",
|
||||
"hf_token_card_title": "添加免費的 Hugging Face 令牌以加快下載速度",
|
||||
"hf_token_get": "沒有令牌?免費獲得一份 →",
|
||||
@@ -2169,5 +2182,25 @@
|
||||
"email_desc": "許可、合作夥伴關係或任何私人的東西。",
|
||||
"website": "網站",
|
||||
"website_desc": "有關該項目和製造商的更多資訊。"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "權限",
|
||||
"desc": "OmniVoice 完全在您的裝置上執行——這些系統權限僅用於此處列出的功能,音訊絕不會離開您的電腦。",
|
||||
"microphone": "麥克風",
|
||||
"microphone_why": "用於聽寫與語音錄製。音訊在本機處理。",
|
||||
"accessibility": "輔助使用",
|
||||
"accessibility_why": "允許聽寫將文字貼上或輸入到其他應用程式。",
|
||||
"status_granted": "已授權",
|
||||
"status_denied": "已拒絕",
|
||||
"status_prompt": "尚未要求",
|
||||
"status_unknown": "未知",
|
||||
"open_settings": "開啟設定",
|
||||
"web_note": "權限狀態僅能在桌面應用程式中讀取——在瀏覽器中,麥克風存取由瀏覽器自行管理。"
|
||||
},
|
||||
"asr_missing": {
|
||||
"message": "尚未安裝語音轉文字模型 — 此功能需要一個模型來轉錄音訊。",
|
||||
"download": "下載 {{label}}({{size}} GB)",
|
||||
"started": "正在下載 {{label}} — 請在 設定 → 模型 中查看進度,完成後再試一次。",
|
||||
"install_failed": "無法開始下載:{{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4710,6 +4710,14 @@ body:has(.capture-pill) {
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: color-mix(in srgb, #d3869b 8%, transparent);
|
||||
}
|
||||
/* Curated "recommended for this machine" pick — green sibling of the pink
|
||||
required tag (same green the RecoBanner's installed state uses). */
|
||||
.models-row__tag--rec {
|
||||
color: #8ec07c;
|
||||
border-color: color-mix(in srgb, #8ec07c 40%, transparent);
|
||||
background: color-mix(in srgb, #8ec07c 8%, transparent);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* ── Models tab: table (virtualised) ─────────────────────────────
|
||||
The toolbar / role tabs / search chrome and the recommendation
|
||||
|
||||
@@ -25,6 +25,7 @@ import { API } from '../api/client';
|
||||
import BatchAddDialog from '../components/BatchAddDialog';
|
||||
import toast from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { asrMissingPayload, toastAsrModelMissing } from '../utils/asrModelMissing';
|
||||
import { recordValueMoment } from '../utils/donationMoments';
|
||||
import { absoluteTime, timeAgo } from '../utils/relativeTime';
|
||||
|
||||
@@ -134,6 +135,13 @@ export default function BatchQueue({ onBack }) {
|
||||
);
|
||||
success++;
|
||||
} catch (e) {
|
||||
const missing = asrMissingPayload(e);
|
||||
if (missing) {
|
||||
// Typed 409: no ASR model installed → one download CTA, then stop
|
||||
// (every remaining file would fail the same preflight).
|
||||
toastAsrModelMissing(missing);
|
||||
break;
|
||||
}
|
||||
toastErrorWithReport(
|
||||
t('batch.enqueue_failed', { name: file.name, message: e.message }),
|
||||
e,
|
||||
|
||||
@@ -28,6 +28,7 @@ import RemoteBackendPanel from '../components/settings/RemoteBackendPanel';
|
||||
import MCPBindingsPanel from '../components/settings/MCPBindingsPanel';
|
||||
import OpenApiPanel from '../components/settings/OpenApiPanel';
|
||||
import PronunciationPanel from '../components/settings/PronunciationPanel';
|
||||
import PermissionsPanel from '../components/settings/PermissionsPanel';
|
||||
import DictationDemo from '../components/DictationDemo';
|
||||
import UpdatesPanel from '../components/UpdatesPanel';
|
||||
import GeneralTab from '../components/settings/GeneralTab';
|
||||
@@ -407,6 +408,8 @@ export default function Settings() {
|
||||
<StorageTab />
|
||||
</>
|
||||
);
|
||||
case 'permissions':
|
||||
return <PermissionsPanel />;
|
||||
case 'network':
|
||||
return <NetworkTab />;
|
||||
case 'audio-tools':
|
||||
|
||||
@@ -10,6 +10,7 @@ import MediaEngineCard from '../components/MediaEngineCard';
|
||||
import MirrorRescue from '../components/MirrorRescue';
|
||||
import HfTokenCard from '../components/HfTokenCard';
|
||||
import DictationDemo from '../components/DictationDemo';
|
||||
import PermissionChecks from '../components/PermissionChecks';
|
||||
import { APP_VERSION } from '../utils/appVersion';
|
||||
import { Button } from '../ui';
|
||||
|
||||
@@ -339,6 +340,9 @@ export default function SetupWizard({ onReady }) {
|
||||
<div className="flex min-h-0 flex-auto flex-col gap-3" key="step-0">
|
||||
<div className="fr-rise min-h-0 flex-1 overflow-y-auto" style={{ '--rise': 1 }}>
|
||||
<PreflightPanel report={pre} loading={preLoading} onRecheck={recheckPreflight} />
|
||||
{/* OS permissions (mic + macOS Accessibility) — advisory rows
|
||||
that never gate Continue; renders nothing outside Tauri. */}
|
||||
<PermissionChecks />
|
||||
{/* Invisible when the media engine is ready; a quiet progress
|
||||
line while the backend fetches its own bundled build; an
|
||||
actionable card only on failure. */}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Dictation mic pre-flight (guided OS-permissions UX): when the OS reports
|
||||
* the microphone grant as DENIED, the pill must skip getUserMedia entirely
|
||||
* and show the guided path (per-OS hint + Open Settings deep-link) instead
|
||||
* of the opaque NotAllowedError toast. Every other state ('granted',
|
||||
* 'prompt', 'unknown' — and the plain browser, which has no probe) proceeds
|
||||
* to getUserMedia exactly as before.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const { toastMock } = vi.hoisted(() => ({
|
||||
toastMock: Object.assign(vi.fn(), {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock('react-hot-toast', () => ({ default: toastMock, toast: toastMock }));
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args) => invokeMock(...args),
|
||||
}));
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: vi.fn(async () => () => {}),
|
||||
}));
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({ hide: async () => {} }),
|
||||
}));
|
||||
|
||||
// Keep the api/history/model-CTA side modules out of this test's blast radius.
|
||||
vi.mock('../api/client', () => ({
|
||||
wsUrl: (p) => `ws://test${p}`,
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() }));
|
||||
vi.mock('../utils/asrModelMissing', () => ({
|
||||
asrMissingPayload: () => null,
|
||||
toastAsrModelMissing: vi.fn(),
|
||||
}));
|
||||
|
||||
// Minimal zustand stand-in: dictation enabled, toggle mode, no AEC/sherpa.
|
||||
const storeState = {
|
||||
dictationEnabled: true,
|
||||
dictationMode: 'toggle',
|
||||
loadDictationPrefs: vi.fn(),
|
||||
aecEnabled: false,
|
||||
dictationModelId: null,
|
||||
};
|
||||
vi.mock('../store', () => {
|
||||
const useAppStore = (sel) => sel(storeState);
|
||||
useAppStore.getState = () => storeState;
|
||||
return { useAppStore };
|
||||
});
|
||||
|
||||
import CaptureWidget from '../components/CaptureWidget';
|
||||
|
||||
/** Route the invoke mock per command. */
|
||||
function stubInvoke({ mic = 'granted' } = {}) {
|
||||
invokeMock.mockImplementation(async (cmd) => {
|
||||
if (cmd === 'check_microphone') return mic;
|
||||
if (cmd === 'check_accessibility') return true;
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** A getUserMedia spy installed on jsdom's bare navigator. */
|
||||
function installGum(impl) {
|
||||
const gum = vi.fn(impl);
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
value: { getUserMedia: gum },
|
||||
configurable: true,
|
||||
});
|
||||
return gum;
|
||||
}
|
||||
|
||||
const notFound = () => {
|
||||
const e = new Error('no device');
|
||||
e.name = 'NotFoundError';
|
||||
return e;
|
||||
};
|
||||
|
||||
/** Fire the in-page dictation shortcut (Ctrl+Shift+Space). */
|
||||
function pressShortcut() {
|
||||
fireEvent.keyDown(window, { code: 'Space', ctrlKey: true, shiftKey: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
toastMock.mockClear();
|
||||
toastMock.error.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
delete navigator.mediaDevices;
|
||||
});
|
||||
|
||||
describe('CaptureWidget — mic permission pre-flight (Tauri)', () => {
|
||||
beforeEach(() => {
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
});
|
||||
|
||||
it('OS-denied → guided error pill with Open Settings, getUserMedia never called', async () => {
|
||||
stubInvoke({ mic: 'denied' });
|
||||
const gum = installGum(async () => {
|
||||
throw notFound();
|
||||
});
|
||||
render(<CaptureWidget />);
|
||||
pressShortcut();
|
||||
|
||||
// Guided path instead of the raw getUserMedia failure.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Mic access denied/)).toBeInTheDocument();
|
||||
});
|
||||
expect(gum).not.toHaveBeenCalled();
|
||||
expect(toastMock.error).toHaveBeenCalled();
|
||||
|
||||
// The pill's Open Settings action deep-links the OS mic-privacy pane.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Settings' }));
|
||||
await waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith('open_microphone_settings');
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['granted', 'prompt', 'unknown'])(
|
||||
'"%s" proceeds to getUserMedia as before (reactive micError stays the fallback)',
|
||||
async (mic) => {
|
||||
stubInvoke({ mic });
|
||||
const gum = installGum(async () => {
|
||||
throw notFound();
|
||||
});
|
||||
render(<CaptureWidget />);
|
||||
pressShortcut();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(gum).toHaveBeenCalled();
|
||||
});
|
||||
// A no-device failure is NOT an OS denial — no Open Settings action.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Mic access denied/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: 'Open Settings' })).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('CaptureWidget — plain browser (no Tauri)', () => {
|
||||
it('behaviour unchanged: no permission probe, straight to getUserMedia', async () => {
|
||||
const gum = installGum(async () => {
|
||||
throw notFound();
|
||||
});
|
||||
render(<CaptureWidget />);
|
||||
pressShortcut();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(gum).toHaveBeenCalled();
|
||||
});
|
||||
expect(invokeMock).not.toHaveBeenCalledWith('check_microphone');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Connect-time WS error vs. mic-graph setup race: a typed asr_model_missing
|
||||
* error frame can arrive while startRecording is still awaiting
|
||||
* startMicCapture. The error branch resolves the session (error pill, socket
|
||||
* closed, wsHadFinalRef latched) — startRecording's tail must then ABORT
|
||||
* instead of clobbering the error with setState('recording') + tray flag,
|
||||
* which (with the socket gone) stranded the next Stop on "Transcribing…".
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const { toastMock } = vi.hoisted(() => ({
|
||||
toastMock: Object.assign(vi.fn(), {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock('react-hot-toast', () => ({ default: toastMock, toast: toastMock }));
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args) => invokeMock(...args),
|
||||
}));
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: vi.fn(async () => () => {}),
|
||||
}));
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({ hide: async () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
wsUrl: (p) => `ws://test${p}`,
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() }));
|
||||
|
||||
const { toastAsrMock } = vi.hoisted(() => ({ toastAsrMock: vi.fn() }));
|
||||
vi.mock('../utils/asrModelMissing', () => ({
|
||||
// Matches the real payload extraction for the WS frame shape.
|
||||
asrMissingPayload: (err) =>
|
||||
err && typeof err === 'object' && err.error === 'asr_model_missing' ? err : null,
|
||||
toastAsrModelMissing: toastAsrMock,
|
||||
}));
|
||||
|
||||
// Deferred startMicCapture so the test controls WHEN the mic graph finishes
|
||||
// setting up relative to the WS error frame.
|
||||
const { micDeferred, micStop } = vi.hoisted(() => {
|
||||
let resolve;
|
||||
const promise = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { micDeferred: { promise, resolve }, micStop: vi.fn(async () => {}) };
|
||||
});
|
||||
vi.mock('../utils/aec/micCapture', () => ({
|
||||
startMicCapture: vi.fn(() => micDeferred.promise),
|
||||
}));
|
||||
vi.mock('../utils/aec/pcm', () => ({
|
||||
frameFromFloat: vi.fn(),
|
||||
floatToInt16: vi.fn(() => new Int16Array(0)),
|
||||
AEC_NEAR: 0,
|
||||
AEC_FAR: 1,
|
||||
}));
|
||||
|
||||
// Sherpa live model selected → raw-PCM path (startMicCapture is awaited).
|
||||
const storeState = {
|
||||
dictationEnabled: true,
|
||||
dictationMode: 'toggle',
|
||||
loadDictationPrefs: vi.fn(),
|
||||
aecEnabled: false,
|
||||
dictationModelId: 'sherpa-parakeet-v3',
|
||||
};
|
||||
vi.mock('../store', () => {
|
||||
const useAppStore = (sel) => sel(storeState);
|
||||
useAppStore.getState = () => storeState;
|
||||
return { useAppStore };
|
||||
});
|
||||
|
||||
import CaptureWidget from '../components/CaptureWidget';
|
||||
|
||||
class FakeWS {
|
||||
static instances = [];
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = FakeWS.CONNECTING;
|
||||
FakeWS.instances.push(this);
|
||||
}
|
||||
send() {}
|
||||
close() {
|
||||
this.readyState = FakeWS.CLOSED;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function pressShortcut() {
|
||||
fireEvent.keyDown(window, { code: 'Space', ctrlKey: true, shiftKey: true });
|
||||
}
|
||||
|
||||
let realWebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
invokeMock.mockReset();
|
||||
invokeMock.mockImplementation(async (cmd) => {
|
||||
if (cmd === 'check_microphone') return 'granted';
|
||||
if (cmd === 'check_accessibility') return true;
|
||||
return undefined;
|
||||
});
|
||||
FakeWS.instances = [];
|
||||
realWebSocket = globalThis.WebSocket;
|
||||
globalThis.WebSocket = FakeWS;
|
||||
// jsdom has no MediaRecorder; only isTypeSupported is reached on the
|
||||
// raw-PCM (sherpa) path exercised here.
|
||||
globalThis.MediaRecorder = class {
|
||||
static isTypeSupported() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
value: { getUserMedia: vi.fn(async () => ({ getTracks: () => [{ stop: vi.fn() }] })) },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = realWebSocket;
|
||||
delete globalThis.MediaRecorder;
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
delete navigator.mediaDevices;
|
||||
});
|
||||
|
||||
describe('CaptureWidget — connect-time asr_model_missing during mic setup', () => {
|
||||
it('keeps the error pill: no recording state, no tray flag, mic released', async () => {
|
||||
render(<CaptureWidget />);
|
||||
pressShortcut();
|
||||
|
||||
// The socket opens before the mic worklet finishes setting up.
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
const ws = FakeWS.instances[0];
|
||||
|
||||
// Typed preflight error lands while startMicCapture is still pending.
|
||||
ws.onmessage({
|
||||
data: JSON.stringify({
|
||||
type: 'error',
|
||||
kind: 'asr_model_missing',
|
||||
error: 'asr_model_missing',
|
||||
message: 'no ASR model installed',
|
||||
recommended: { repo_id: 'x/y', label: 'Y', size_gb: 1 },
|
||||
}),
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No speech-to-text model/)).toBeInTheDocument();
|
||||
});
|
||||
expect(toastAsrMock).toHaveBeenCalled();
|
||||
|
||||
// Mic graph setup completes AFTER the error — the tail must abort:
|
||||
// release the worklet, keep the error state, never flip the tray on.
|
||||
micDeferred.resolve(micStop);
|
||||
await waitFor(() => expect(micStop).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByText(/No speech-to-text model/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Listening/)).not.toBeInTheDocument();
|
||||
expect(invokeMock).not.toHaveBeenCalledWith('set_tray_recording', { recording: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* SetupWizard System Check permission rows: LED/chip per grant state,
|
||||
* macOS-only Accessibility row, denied → Open Settings, and the plain-browser
|
||||
* no-op (renders nothing without the Tauri shell).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args) => invokeMock(...args),
|
||||
}));
|
||||
|
||||
import PermissionChecks, { permissionChip } from '../components/PermissionChecks';
|
||||
|
||||
function stubInvoke({ mic = 'granted', a11y = true } = {}) {
|
||||
invokeMock.mockImplementation(async (cmd) => {
|
||||
if (cmd === 'check_microphone') return mic;
|
||||
if (cmd === 'check_accessibility') return a11y;
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
});
|
||||
|
||||
describe('permissionChip — status → chip mapping', () => {
|
||||
it('maps the four states to ok/warn/neutral', () => {
|
||||
expect(permissionChip('granted')).toMatchObject({ led: 'ok', tone: 'success' });
|
||||
expect(permissionChip('denied')).toMatchObject({ led: 'warn', tone: 'warn' });
|
||||
expect(permissionChip('prompt')).toMatchObject({ led: 'neutral', tone: 'neutral' });
|
||||
expect(permissionChip('unknown')).toMatchObject({ led: 'neutral', tone: 'neutral' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionChecks — wizard rows', () => {
|
||||
it('renders nothing outside the Tauri shell (browser/dev no-op)', () => {
|
||||
const { container } = render(<PermissionChecks platform="mac" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows mic + Accessibility rows on macOS with live chips', async () => {
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
stubInvoke({ mic: 'granted', a11y: false });
|
||||
render(<PermissionChecks platform="mac" />);
|
||||
expect(screen.getByTestId('permission-checks')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('perm-chip-microphone')).toHaveTextContent('Granted');
|
||||
expect(screen.getByTestId('perm-chip-accessibility')).toHaveTextContent('Denied');
|
||||
});
|
||||
// Denied a11y row exposes the deep-link.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Settings' }));
|
||||
await waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith('open_accessibility_settings');
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the Accessibility row off-macOS and keeps the mic row honest on Linux', async () => {
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
stubInvoke({ mic: 'unknown' });
|
||||
render(<PermissionChecks platform="linux" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('perm-chip-microphone')).toHaveTextContent('Unknown');
|
||||
});
|
||||
expect(screen.queryByTestId('perm-row-accessibility')).not.toBeInTheDocument();
|
||||
// Non-granted states surface the per-OS guide line.
|
||||
expect(screen.getByText(/audio group/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('denied mic shows the Open Settings deep-link and the per-OS fix path', async () => {
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
stubInvoke({ mic: 'denied' });
|
||||
render(<PermissionChecks platform="windows" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('perm-chip-microphone')).toHaveTextContent('Denied');
|
||||
});
|
||||
expect(screen.getByText(/Privacy & security → Microphone/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Settings' }));
|
||||
await waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith('open_microphone_settings');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Settings → Permissions panel: live status rows for the OS grants the app's
|
||||
* default features use (mic everywhere, Accessibility on macOS), deep-link
|
||||
* buttons when denied, focus-recheck, and the graceful browser no-op.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
|
||||
const { toastMock } = vi.hoisted(() => {
|
||||
const fn = () => {};
|
||||
return {
|
||||
toastMock: Object.assign(fn, { error: fn, success: fn, dismiss: fn, loading: fn }),
|
||||
};
|
||||
});
|
||||
vi.mock('react-hot-toast', () => ({ default: toastMock, toast: toastMock }));
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args) => invokeMock(...args),
|
||||
}));
|
||||
|
||||
import PermissionsPanel from '../components/settings/PermissionsPanel';
|
||||
|
||||
/** Route the invoke mock per command. */
|
||||
function stubInvoke({ mic = 'granted', a11y = true } = {}) {
|
||||
invokeMock.mockImplementation(async (cmd) => {
|
||||
if (cmd === 'check_microphone') return mic;
|
||||
if (cmd === 'check_accessibility') return a11y;
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
});
|
||||
|
||||
describe('PermissionsPanel — inside Tauri', () => {
|
||||
beforeEach(() => {
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
});
|
||||
|
||||
it('renders the Microphone row with a Granted chip', async () => {
|
||||
stubInvoke({ mic: 'granted' });
|
||||
render(<PermissionsPanel platform="windows" />);
|
||||
expect(screen.getByText('Microphone')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('perm-chip-microphone')).toHaveTextContent('Granted');
|
||||
});
|
||||
// No Accessibility row off-macOS — nothing to grant there.
|
||||
expect(screen.queryByText('Accessibility')).not.toBeInTheDocument();
|
||||
// Granted → no Open Settings button.
|
||||
expect(screen.queryByRole('button', { name: 'Open Settings' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('denied mic → Denied chip, per-OS hint, and an Open Settings deep-link', async () => {
|
||||
stubInvoke({ mic: 'denied' });
|
||||
render(<PermissionsPanel platform="mac" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('perm-chip-microphone')).toHaveTextContent('Denied');
|
||||
});
|
||||
// The actionable per-OS path replaces the generic "why" line.
|
||||
expect(
|
||||
screen.getByText(/System Settings → Privacy & Security → Microphone/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const buttons = screen.getAllByRole('button', { name: 'Open Settings' });
|
||||
fireEvent.click(buttons[0]);
|
||||
await waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith('open_microphone_settings');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the Accessibility row on macOS with its own deep-link when denied', async () => {
|
||||
stubInvoke({ mic: 'granted', a11y: false });
|
||||
render(<PermissionsPanel platform="mac" />);
|
||||
expect(screen.getByText('Accessibility')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('perm-chip-accessibility')).toHaveTextContent('Denied');
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Settings' }));
|
||||
await waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith('open_accessibility_settings');
|
||||
});
|
||||
});
|
||||
|
||||
it('re-probes on window focus so a grant flipped in System Settings shows up', async () => {
|
||||
stubInvoke({ mic: 'denied' });
|
||||
render(<PermissionsPanel platform="windows" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('perm-chip-microphone')).toHaveTextContent('Denied');
|
||||
});
|
||||
|
||||
stubInvoke({ mic: 'granted' });
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('perm-chip-microphone')).toHaveTextContent('Granted');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the honest neutral chips for prompt/unknown', async () => {
|
||||
stubInvoke({ mic: 'prompt' });
|
||||
render(<PermissionsPanel platform="mac" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('perm-chip-microphone')).toHaveTextContent('Not asked yet');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionsPanel — browser / web UI (no Tauri)', () => {
|
||||
it('no-ops gracefully: explains instead of guessing, never invokes', () => {
|
||||
render(<PermissionsPanel platform="mac" />);
|
||||
expect(screen.getByText(/only readable in the desktop app/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Microphone')).not.toBeInTheDocument();
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Typed "no ASR model installed" error → one-click download CTA.
|
||||
*
|
||||
* Only the TTS model is required on a fresh install; ASR consumers answer
|
||||
* with `{ error: 'asr_model_missing', recommended: {…} }` over three
|
||||
* transports (HTTP 409 detail, dub SSE error event, dictation WS frame).
|
||||
* Pins the payload normalization + the CTA wiring: the button starts the
|
||||
* install via POST /models/install and, for dictation picks, persists
|
||||
* dictation.model_id so a retry actually uses the downloaded model.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: Object.assign(vi.fn(), {
|
||||
error: (...a) => toastError(...a),
|
||||
success: (...a) => toastSuccess(...a),
|
||||
dismiss: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const installModel = vi.fn();
|
||||
vi.mock('../api/setup', () => ({ installModel: (...a) => installModel(...a) }));
|
||||
|
||||
const apiPost = vi.fn();
|
||||
vi.mock('../api/client', () => ({ apiPost: (...a) => apiPost(...a) }));
|
||||
|
||||
vi.mock('i18next', () => ({
|
||||
default: { t: (key, opts) => (opts ? `${key} ${JSON.stringify(opts)}` : key) },
|
||||
}));
|
||||
|
||||
import { asrMissingPayload, toastAsrModelMissing } from '../utils/asrModelMissing';
|
||||
|
||||
const PAYLOAD = {
|
||||
error: 'asr_model_missing',
|
||||
missing_repo_id: 'Systran/faster-whisper-large-v3',
|
||||
recommended: {
|
||||
repo_id: 'Systran/faster-whisper-large-v3',
|
||||
label: 'Whisper large-v3',
|
||||
size_gb: 2.9,
|
||||
},
|
||||
};
|
||||
|
||||
describe('asrMissingPayload', () => {
|
||||
it('extracts from a structured ApiError detail (HTTP 409)', () => {
|
||||
const err = Object.assign(new Error('409'), { status: 409, detail: PAYLOAD });
|
||||
expect(asrMissingPayload(err)).toEqual(PAYLOAD);
|
||||
});
|
||||
|
||||
it('extracts from a raw SSE/WS payload', () => {
|
||||
expect(asrMissingPayload(PAYLOAD)).toEqual(PAYLOAD);
|
||||
expect(
|
||||
asrMissingPayload({ type: 'error', kind: 'asr_model_missing', ...PAYLOAD }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('extracts from an Error tagged by the dub SSE handler', () => {
|
||||
const err = Object.assign(new Error('detail'), { asrModelMissing: PAYLOAD });
|
||||
expect(asrMissingPayload(err)).toEqual(PAYLOAD);
|
||||
});
|
||||
|
||||
it('returns null for anything else', () => {
|
||||
expect(asrMissingPayload(null)).toBeNull();
|
||||
expect(asrMissingPayload(new Error('boom'))).toBeNull();
|
||||
expect(asrMissingPayload({ detail: 'plain string 409' })).toBeNull();
|
||||
expect(asrMissingPayload({ error: 'other_error' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toastAsrModelMissing', () => {
|
||||
beforeEach(() => {
|
||||
toastError.mockReset();
|
||||
toastSuccess.mockReset();
|
||||
installModel.mockReset();
|
||||
apiPost.mockReset();
|
||||
});
|
||||
|
||||
function renderToast(payload) {
|
||||
toastAsrModelMissing(payload);
|
||||
expect(toastError).toHaveBeenCalledTimes(1);
|
||||
const [content] = toastError.mock.calls[0];
|
||||
// Toast content is a render-prop; mount it like react-hot-toast would.
|
||||
render(typeof content === 'function' ? content({ id: 'tst-1' }) : content);
|
||||
}
|
||||
|
||||
it('renders the download CTA and starts the install on click', async () => {
|
||||
installModel.mockResolvedValue({ status: 'started' });
|
||||
renderToast(PAYLOAD);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.textContent).toContain('asr_missing.download');
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() =>
|
||||
expect(installModel).toHaveBeenCalledWith('Systran/faster-whisper-large-v3'),
|
||||
);
|
||||
// Non-dictation pick: no dictation pref write.
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('also persists dictation.model_id for dictation picks', async () => {
|
||||
installModel.mockResolvedValue({ status: 'started' });
|
||||
apiPost.mockResolvedValue({});
|
||||
renderToast({
|
||||
...PAYLOAD,
|
||||
recommended: {
|
||||
repo_id: 'csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8',
|
||||
label: 'Parakeet TDT v3',
|
||||
size_gb: 0.18,
|
||||
dictation_id: 'sherpa-parakeet-tdt-v3',
|
||||
},
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
await waitFor(() =>
|
||||
expect(apiPost).toHaveBeenCalledWith('/dictation/prefs', {
|
||||
model_id: 'sherpa-parakeet-tdt-v3',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('degrades to a plain toast when no recommendation resolves', () => {
|
||||
toastAsrModelMissing({ error: 'asr_model_missing', recommended: null });
|
||||
expect(toastError).toHaveBeenCalledTimes(1);
|
||||
expect(typeof toastError.mock.calls[0][0]).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,9 @@ describe('#695 — every dub handler resets on a stale job (regression of #660)'
|
||||
for (const fn of ['handleDubUpload', 'handleDubIngestUrl']) {
|
||||
const start = src.indexOf(`const ${fn} =`);
|
||||
expect(start, `${fn} should exist`).toBeGreaterThan(-1);
|
||||
const body = src.slice(start, start + 2500);
|
||||
// Window sized to span the whole catch chain (grew with the
|
||||
// asr_model_missing branch — keep it comfortably ahead of the handlers).
|
||||
const body = src.slice(start, start + 4000);
|
||||
const guardIdx = body.indexOf('isExpiredDubJobError(err)');
|
||||
const reportIdx = body.indexOf('toastErrorWithReport');
|
||||
expect(guardIdx, `${fn} must handle a stale job`).toBeGreaterThan(-1);
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import {
|
||||
modelSectionKey,
|
||||
matchesModelQuery,
|
||||
groupModels,
|
||||
} from '../components/settings/models/sections';
|
||||
import { makeModelColumns } from '../components/settings/models/columns';
|
||||
|
||||
// ── Feature: grouped Model Store catalog with platform clarity ─────────────
|
||||
// The flat table became role sections (TTS / ASR offline / Dictation /
|
||||
// Diarisation). Curated rows (backend `curated`, from curated_on in
|
||||
// models.yaml) wear a "recommended" chip; platform-incompatible rows
|
||||
// (`supported: false`) collapse behind a per-section "Show incompatible (N)"
|
||||
// toggle instead of rendering greyed-out inline.
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn() },
|
||||
toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }),
|
||||
}));
|
||||
|
||||
const t = i18n.t.bind(i18n);
|
||||
|
||||
// ── Pure helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('modelSectionKey — role → catalog section', () => {
|
||||
it('maps TTS / plain ASR / Diarisation to their sections', () => {
|
||||
expect(modelSectionKey({ role: 'TTS' })).toBe('tts');
|
||||
expect(modelSectionKey({ role: 'ASR' })).toBe('asr');
|
||||
expect(modelSectionKey({ role: 'Diarisation' })).toBe('diarisation');
|
||||
expect(modelSectionKey({ role: 'diarization' })).toBe('diarisation'); // spelling alias
|
||||
});
|
||||
|
||||
it('splits streaming/dictation ASR (engine sherpa-onnx OR a tag) out of ASR', () => {
|
||||
expect(modelSectionKey({ role: 'ASR', engine: 'sherpa-onnx', tag: 'offline' })).toBe(
|
||||
'dictation',
|
||||
);
|
||||
expect(modelSectionKey({ role: 'ASR', tag: 'streaming' })).toBe('dictation');
|
||||
expect(modelSectionKey({ role: 'ASR' })).toBe('asr');
|
||||
});
|
||||
|
||||
it('routes unknown roles to "other" and never throws on malformed rows', () => {
|
||||
expect(modelSectionKey({ role: 'LLM' })).toBe('other');
|
||||
expect(modelSectionKey({})).toBe('other');
|
||||
expect(modelSectionKey(null)).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupModels — ordered sections, query + compatibility split', () => {
|
||||
const MODELS = [
|
||||
{ repo_id: 'd/diar', label: 'Diar', role: 'Diarisation' },
|
||||
{ repo_id: 't/tts', label: 'Voice', role: 'TTS' },
|
||||
{ repo_id: 'a/asr', label: 'Whisper', role: 'ASR', note: 'universal pick' },
|
||||
{ repo_id: 's/dict', label: 'Parakeet', role: 'ASR', engine: 'sherpa-onnx', tag: 'offline' },
|
||||
{ repo_id: 'x/mac-only', label: 'MacOnly', role: 'TTS', supported: false },
|
||||
];
|
||||
|
||||
it('groups into TTS → ASR → Dictation → Diarisation order, omitting empty sections', () => {
|
||||
const sections = groupModels(MODELS, '');
|
||||
expect(sections.map((s) => s.key)).toEqual(['tts', 'asr', 'dictation', 'diarisation']);
|
||||
});
|
||||
|
||||
it('splits supported vs incompatible rows per section', () => {
|
||||
const tts = groupModels(MODELS, '').find((s) => s.key === 'tts');
|
||||
expect(tts.compatible.map((m) => m.repo_id)).toEqual(['t/tts']);
|
||||
expect(tts.incompatible.map((m) => m.repo_id)).toEqual(['x/mac-only']);
|
||||
});
|
||||
|
||||
it('applies the search query over repo_id / label / note / role', () => {
|
||||
expect(groupModels(MODELS, 'parakeet').map((s) => s.key)).toEqual(['dictation']);
|
||||
expect(groupModels(MODELS, 'universal pick').map((s) => s.key)).toEqual(['asr']);
|
||||
expect(groupModels(MODELS, 'zzz')).toEqual([]);
|
||||
expect(matchesModelQuery(MODELS[2], ' WHISPER ')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Curated "recommended" chip on catalog rows ──────────────────────────────
|
||||
|
||||
const IDLE_RT = {
|
||||
showBar: false,
|
||||
isDeleting: false,
|
||||
isInstalling: false,
|
||||
rowBusy: false,
|
||||
unsupported: false,
|
||||
aggPct: null,
|
||||
totals: { downloaded: 0, total: 0 },
|
||||
hasFiles: false,
|
||||
};
|
||||
|
||||
function renderNameCell(mOver = {}) {
|
||||
const cols = makeModelColumns({
|
||||
t,
|
||||
getRowRuntime: () => IDLE_RT,
|
||||
speedRef: { current: {} },
|
||||
MODEL_ROLE_LABEL: {},
|
||||
onInstall: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onReinstall: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onDismissError: vi.fn(),
|
||||
});
|
||||
const col = cols.find((c) => c.id === 'name');
|
||||
const m = { repo_id: 'org/model', label: 'My Model', role: 'asr', installed: false, ...mOver };
|
||||
return render(col.cell({ row: { original: m } }));
|
||||
}
|
||||
|
||||
describe('Model Store row — curated "recommended" chip', () => {
|
||||
it('badges a curated row with the recommended chip (existing badge_recommended key)', () => {
|
||||
renderNameCell({ curated: true });
|
||||
const chip = screen.getByTestId('model-recommended-org/model');
|
||||
expect(chip).toHaveTextContent(t('voicePanel.badge_recommended'));
|
||||
expect(chip).toHaveAttribute('title', t('models.recommended_title'));
|
||||
});
|
||||
|
||||
it('a required row keeps its stronger "required" tag — no doubled chips', () => {
|
||||
renderNameCell({ curated: true, required: true });
|
||||
expect(screen.getByText(t('models.required_tag'))).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('model-recommended-org/model')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('a non-curated row gets no chip', () => {
|
||||
renderNameCell({ curated: false });
|
||||
expect(screen.queryByTestId('model-recommended-org/model')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tab-level: sections + incompatible collapse ─────────────────────────────
|
||||
|
||||
const refetch = vi.fn();
|
||||
const MODELS = [
|
||||
{
|
||||
repo_id: 'k2-fsa/OmniVoice',
|
||||
label: 'OmniVoice TTS',
|
||||
role: 'TTS',
|
||||
size_gb: 2.4,
|
||||
installed: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
repo_id: 'Systran/faster-whisper-large-v3',
|
||||
label: 'Whisper large-v3',
|
||||
role: 'ASR',
|
||||
size_gb: 2.9,
|
||||
installed: false,
|
||||
curated: true,
|
||||
},
|
||||
{
|
||||
repo_id: 'csukuangfj/parakeet',
|
||||
label: 'Parakeet dictation',
|
||||
role: 'ASR',
|
||||
size_gb: 0.18,
|
||||
installed: false,
|
||||
engine: 'sherpa-onnx',
|
||||
tag: 'offline',
|
||||
},
|
||||
{
|
||||
repo_id: 'pyannote/speaker-diarization-3.1',
|
||||
label: 'Speaker diarization',
|
||||
role: 'Diarisation',
|
||||
size_gb: 0.3,
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
repo_id: 'mlx-community/whisper-large-v3-mlx',
|
||||
label: 'Whisper MLX',
|
||||
role: 'ASR',
|
||||
size_gb: 3.0,
|
||||
installed: false,
|
||||
supported: false,
|
||||
platforms: ['darwin-arm64'],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('../api/hooks', () => ({
|
||||
useModels: () => ({
|
||||
data: {
|
||||
models: MODELS,
|
||||
total_installed_bytes: 0,
|
||||
disk_free_gb: 42.5,
|
||||
hf_cache_dir: '/home/u/.cache/huggingface',
|
||||
platform_tags: ['linux', 'linux-x86_64', 'cuda'],
|
||||
},
|
||||
isLoading: false,
|
||||
refetch,
|
||||
}),
|
||||
useRecommendations: () => ({ data: null, refetch }),
|
||||
useInstallModel: () => ({ mutateAsync: vi.fn() }),
|
||||
useDeleteModel: () => ({ mutateAsync: vi.fn() }),
|
||||
}));
|
||||
vi.mock('../api/setup', () => ({
|
||||
setupDownloadStreamUrl: () => 'http://localhost/stream',
|
||||
cancelInstallModel: vi.fn(),
|
||||
}));
|
||||
vi.mock('../api/system', () => ({
|
||||
listLoadedModels: vi.fn().mockResolvedValue({ models: [], count: 0 }),
|
||||
unloadLoadedModel: vi.fn(),
|
||||
}));
|
||||
vi.mock('../api/external', () => ({ openExternal: vi.fn() }));
|
||||
vi.mock('../components/settings/models/RecoBanner', () => ({ default: () => null }));
|
||||
// Surface the per-section row set without the virtualizer (yields no rows in
|
||||
// jsdom). Section chrome (headers, incompatible toggle) stays real.
|
||||
vi.mock('../components/settings/models/ModelsTable', () => ({
|
||||
default: ({ tableRows }) => (
|
||||
<div data-testid="mock-table">
|
||||
{tableRows.map((r) => (
|
||||
<div key={r.id} data-testid="visible-row">
|
||||
{r.original.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import ModelStoreTab from '../components/settings/ModelStoreTab';
|
||||
|
||||
function mountTab() {
|
||||
global.EventSource = class {
|
||||
constructor() {
|
||||
this.onmessage = null;
|
||||
}
|
||||
close() {}
|
||||
};
|
||||
return render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ModelStoreTab info={{ has_hf_token: true }} modelBadge={null} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const visibleLabels = () => screen.queryAllByTestId('visible-row').map((n) => n.textContent);
|
||||
|
||||
describe('Model Store — grouped catalog', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders role sections in order with localized titles', async () => {
|
||||
mountTab();
|
||||
await waitFor(() => expect(screen.getByTestId('models-section-tts')).toBeInTheDocument());
|
||||
const keys = ['tts', 'asr', 'dictation', 'diarisation'];
|
||||
const sections = keys.map((k) => screen.getByTestId(`models-section-${k}`));
|
||||
// DOM order matches the section order.
|
||||
for (let i = 1; i < sections.length; i++) {
|
||||
expect(
|
||||
sections[i - 1].compareDocumentPosition(sections[i]) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(screen.getByText(t('models.section_dictation'))).toBeInTheDocument();
|
||||
expect(screen.getByText(t('models.section_asr'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('routes sherpa-onnx rows to the Dictation section, plain ASR stays in ASR', async () => {
|
||||
mountTab();
|
||||
await waitFor(() => expect(screen.getByTestId('models-section-dictation')).toBeInTheDocument());
|
||||
const dictation = screen.getByTestId('models-section-dictation');
|
||||
expect(dictation).toHaveTextContent('Parakeet dictation');
|
||||
expect(dictation).not.toHaveTextContent('Whisper large-v3');
|
||||
const asr = screen.getByTestId('models-section-asr');
|
||||
expect(asr).toHaveTextContent('Whisper large-v3');
|
||||
});
|
||||
|
||||
it('collapses incompatible rows behind a default-collapsed per-section toggle', async () => {
|
||||
mountTab();
|
||||
await waitFor(() => expect(screen.getByTestId('models-section-asr')).toBeInTheDocument());
|
||||
// The mac-only model is NOT rendered inline…
|
||||
expect(visibleLabels()).not.toContain('Whisper MLX');
|
||||
// …but the ASR section offers "Show incompatible (1)".
|
||||
const toggle = screen.getByTestId('models-incompatible-toggle-asr');
|
||||
expect(toggle).toHaveTextContent(t('models.show_incompatible', { count: 1 }));
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(visibleLabels()).toContain('Whisper MLX'));
|
||||
expect(toggle).toHaveTextContent(t('models.hide_incompatible', { count: 1 }));
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(visibleLabels()).not.toContain('Whisper MLX'));
|
||||
});
|
||||
|
||||
it('sections without incompatible rows render no toggle', async () => {
|
||||
mountTab();
|
||||
await waitFor(() => expect(screen.getByTestId('models-section-tts')).toBeInTheDocument());
|
||||
expect(screen.queryByTestId('models-incompatible-toggle-tts')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('models-incompatible-toggle-dictation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search filters across sections and the global empty state clears it', async () => {
|
||||
mountTab();
|
||||
await waitFor(() => expect(visibleLabels().length).toBeGreaterThan(0));
|
||||
const search = screen.getByRole('searchbox', { name: t('models.search_label') });
|
||||
fireEvent.change(search, { target: { value: 'parakeet' } });
|
||||
await waitFor(() => expect(visibleLabels()).toEqual(['Parakeet dictation']));
|
||||
expect(screen.queryByTestId('models-section-tts')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(search, { target: { value: 'zzz-nothing' } });
|
||||
await waitFor(() => expect(visibleLabels()).toHaveLength(0));
|
||||
expect(screen.getByText(t('models.no_matches'))).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('models-clear-filters'));
|
||||
await waitFor(() => expect(visibleLabels().length).toBeGreaterThan(0));
|
||||
expect(search).toHaveValue('');
|
||||
});
|
||||
});
|
||||
@@ -103,3 +103,42 @@ describe('RecoBanner — disk context near the download actions', () => {
|
||||
expect(screen.queryByTestId('reco-low-disk')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── RecoBanner — "For your system": rationale caption + one-click installs ──
|
||||
|
||||
describe('RecoBanner — rationale caption and per-row install', () => {
|
||||
it('shows the backend preset rationale as a caption under the device title', () => {
|
||||
renderBanner({ reco: { ...RECO, rationale: 'Apple Silicon preset: Metal-native picks.' } });
|
||||
expect(screen.getByTestId('reco-rationale')).toHaveTextContent(
|
||||
'Apple Silicon preset: Metal-native picks.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders no caption for a legacy payload without a rationale', () => {
|
||||
renderBanner();
|
||||
expect(screen.queryByTestId('reco-rationale')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers a one-click install per missing pick that fires onInstall(repo_id)', () => {
|
||||
const onInstall = vi.fn();
|
||||
renderBanner({ onInstall });
|
||||
fireEvent.click(screen.getByTestId('reco-install-b/nice'));
|
||||
expect(onInstall).toHaveBeenCalledWith('b/nice');
|
||||
});
|
||||
|
||||
it('shows a busy spinner instead of the install button while the row downloads', () => {
|
||||
const onInstall = vi.fn();
|
||||
renderBanner({
|
||||
onInstall,
|
||||
getRowRuntime: (m) => ({ showBar: m.repo_id === 'b/nice' }),
|
||||
});
|
||||
expect(screen.queryByTestId('reco-install-b/nice')).not.toBeInTheDocument();
|
||||
// The other missing pick still gets its button.
|
||||
expect(screen.getByTestId('reco-install-a/required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no per-row action for legacy callers without onInstall', () => {
|
||||
renderBanner();
|
||||
expect(screen.queryByTestId('reco-install-b/nice')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* useRecording (voice-clone reference recording) shares the same mic
|
||||
* pre-flight seam as the dictation pill: OS-denied → guided toast, no
|
||||
* getUserMedia; anything else → unchanged.
|
||||
*/
|
||||
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
const { toastMock } = vi.hoisted(() => ({
|
||||
toastMock: Object.assign(vi.fn(), {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock('react-hot-toast', () => ({ default: toastMock, toast: toastMock }));
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args) => invokeMock(...args),
|
||||
}));
|
||||
vi.mock('../api/system', () => ({ cleanAudio: vi.fn() }));
|
||||
|
||||
import useRecording from '../hooks/useRecording';
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
toastMock.error.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
delete navigator.mediaDevices;
|
||||
});
|
||||
|
||||
function installGum(impl) {
|
||||
const gum = vi.fn(impl);
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
value: { getUserMedia: gum },
|
||||
configurable: true,
|
||||
});
|
||||
return gum;
|
||||
}
|
||||
|
||||
it('OS-denied → guided toast, getUserMedia skipped', async () => {
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
invokeMock.mockImplementation(async (cmd) => (cmd === 'check_microphone' ? 'denied' : undefined));
|
||||
const gum = installGum(async () => {
|
||||
throw new Error('should not be reached');
|
||||
});
|
||||
const { result } = renderHook(() => useRecording(vi.fn()));
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
expect(gum).not.toHaveBeenCalled();
|
||||
expect(toastMock.error).toHaveBeenCalled();
|
||||
expect(result.current.isRecording).toBe(false);
|
||||
});
|
||||
|
||||
it('prompt/unknown/granted → getUserMedia proceeds as before', async () => {
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
invokeMock.mockImplementation(async (cmd) => (cmd === 'check_microphone' ? 'prompt' : undefined));
|
||||
const err = new Error('denied later');
|
||||
err.name = 'NotAllowedError';
|
||||
const gum = installGum(async () => {
|
||||
throw err; // reactive micError path still handles the real failure
|
||||
});
|
||||
const { result } = renderHook(() => useRecording(vi.fn()));
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
expect(gum).toHaveBeenCalled();
|
||||
expect(toastMock.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('plain browser: no probe, straight to getUserMedia', async () => {
|
||||
const gum = installGum(async () => {
|
||||
const e = new Error('nope');
|
||||
e.name = 'NotFoundError';
|
||||
throw e;
|
||||
});
|
||||
const { result } = renderHook(() => useRecording(vi.fn()));
|
||||
await act(async () => {
|
||||
await result.current.startRecording();
|
||||
});
|
||||
expect(gum).toHaveBeenCalled();
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isPlatformPick } from '../components/WizardLibrary';
|
||||
import { isPlatformPick, isRecommendedPick } from '../components/WizardLibrary';
|
||||
|
||||
// The first-run "Models & engines" wizard surfaces platform-tuned optional
|
||||
// models (e.g. MLX Whisper on Apple Silicon) by default and folds only the
|
||||
@@ -33,3 +33,32 @@ describe('isPlatformPick — surface platform-tuned models by default', () => {
|
||||
expect(isPlatformPick({ platforms: ['cuda', 'darwin-arm64'] }, macTags)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The wizard's "recommended" chip is driven by the backend's `curated` flag
|
||||
// (curated_on in models.yaml — the same signal the Settings model store
|
||||
// badges), with the platform-tag heuristic only as a legacy fallback.
|
||||
describe('isRecommendedPick — curated flag drives the recommended chip', () => {
|
||||
const macTags = ['darwin', 'darwin-arm64'];
|
||||
|
||||
it('marks a curated model recommended even without a platforms field', () => {
|
||||
expect(isRecommendedPick({ repo_id: 'a/b', curated: true }, macTags)).toBe(true);
|
||||
});
|
||||
|
||||
it('a platform-matching but NOT-curated model is no longer a pick', () => {
|
||||
expect(isRecommendedPick({ platforms: ['darwin-arm64'], curated: false }, macTags)).toBe(false);
|
||||
});
|
||||
|
||||
it('required models never wear the recommended chip (they are required)', () => {
|
||||
expect(isRecommendedPick({ required: true, curated: true }, macTags)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the platform-tag heuristic when `curated` is absent (older backend)', () => {
|
||||
expect(isRecommendedPick({ platforms: ['darwin-arm64'] }, macTags)).toBe(true);
|
||||
expect(isRecommendedPick({ platforms: ['cuda'] }, macTags)).toBe(false);
|
||||
});
|
||||
|
||||
it('is safe with malformed input', () => {
|
||||
expect(isRecommendedPick(null, macTags)).toBe(false);
|
||||
expect(isRecommendedPick(undefined, macTags)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* asrModelMissing — typed "no speech-to-text model installed" error + CTA.
|
||||
*
|
||||
* Only the TTS model is required (backend models.yaml): a fresh install has
|
||||
* no ASR model on disk. Backends answer ASR requests on such an install with
|
||||
* a typed payload instead of 500ing or silently downloading multi-GB weights:
|
||||
*
|
||||
* HTTP 409 { detail: { error: 'asr_model_missing', recommended: {…} } }
|
||||
* SSE error { detail, error: 'asr_model_missing', recommended: {…} }
|
||||
* WS frame { type: 'error', kind: 'asr_model_missing', recommended: {…} }
|
||||
*
|
||||
* `asrMissingPayload` normalizes all three shapes (plus an Error the SSE
|
||||
* handler tagged with `.asrModelMissing`); `toastAsrModelMissing` renders the
|
||||
* one-click "Download {label} ({size} GB)" CTA that starts the install via
|
||||
* the existing model-install API (progress shows in Settings → Models) and
|
||||
* tells the user to retry. Same toast-with-action pattern as errorToast.jsx.
|
||||
*/
|
||||
import toast from 'react-hot-toast';
|
||||
import i18next from 'i18next';
|
||||
import { installModel } from '../api/setup';
|
||||
import { apiPost } from '../api/client';
|
||||
|
||||
export const ASR_MODEL_MISSING = 'asr_model_missing';
|
||||
|
||||
/** Extract the typed payload from any of the transport shapes, or null. */
|
||||
export function asrMissingPayload(err) {
|
||||
if (!err || typeof err !== 'object') return null;
|
||||
// Error tagged by the dub SSE handler.
|
||||
if (err.asrModelMissing && typeof err.asrModelMissing === 'object') return err.asrModelMissing;
|
||||
// Raw SSE data / WS frame.
|
||||
if (err.error === ASR_MODEL_MISSING) return err;
|
||||
// ApiError from apiFetch: structured 409 detail.
|
||||
const d = err.detail;
|
||||
if (d && typeof d === 'object' && d.error === ASR_MODEL_MISSING) return d;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Actionable toast: message + one-click download of the recommended model. */
|
||||
export function toastAsrModelMissing(payload) {
|
||||
const t = i18next.t.bind(i18next);
|
||||
const rec = payload?.recommended;
|
||||
const message = t('asr_missing.message');
|
||||
if (!rec || !rec.repo_id) {
|
||||
toast.error(message, { duration: 8000 });
|
||||
return;
|
||||
}
|
||||
const label = rec.label || rec.repo_id;
|
||||
toast.error(
|
||||
(tst) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ flex: 1 }}>{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
onClick={async () => {
|
||||
toast.dismiss(tst.id);
|
||||
try {
|
||||
await installModel(rec.repo_id);
|
||||
if (rec.dictation_id) {
|
||||
// Make the retry actually pick the model up: persist it as
|
||||
// the dictation engine (backend validates + normalizes).
|
||||
await apiPost('/dictation/prefs', { model_id: rec.dictation_id }).catch(() => {});
|
||||
}
|
||||
toast.success(t('asr_missing.started', { label }), { duration: 10000 });
|
||||
} catch (e) {
|
||||
toast.error(t('asr_missing.install_failed', { message: String(e?.message || e) }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('asr_missing.download', { label, size: rec.size_gb })}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
{ duration: 15000 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* micDeniedToast — the guided "microphone access denied" toast.
|
||||
*
|
||||
* Shown by the dictation/recording pre-flight when the OS reports the mic
|
||||
* grant as denied (utils/permissions.js) — in that state getUserMedia can
|
||||
* only throw an opaque NotAllowedError, so we skip it and walk the user to
|
||||
* the fix instead: the per-OS hint (capture.mic_hint_*) plus, inside Tauri,
|
||||
* an "Open Settings" button that deep-links the OS microphone-privacy pane.
|
||||
* On Linux (no such pane) the deep-link resolves false and the toast falls
|
||||
* back to the "use your system sound settings" hint.
|
||||
*
|
||||
* Same toast-with-action pattern as utils/errorToast.jsx.
|
||||
*/
|
||||
import toast from 'react-hot-toast';
|
||||
import { detectPlatform, micHintKey } from './micError';
|
||||
import { inTauri, openMicrophoneSettings } from './permissions';
|
||||
|
||||
export function showMicDeniedGuide(t, platform = detectPlatform()) {
|
||||
const message = t('capture.mic_denied_toast', { hint: t(micHintKey(platform)) });
|
||||
if (!inTauri()) {
|
||||
// Browser/dev: no OS pane to deep-link — plain reactive-style toast.
|
||||
toast.error(message, { duration: 8000 });
|
||||
return;
|
||||
}
|
||||
toast.error(
|
||||
(tst) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ flex: 1 }}>{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
onClick={async () => {
|
||||
toast.dismiss(tst.id);
|
||||
if (!(await openMicrophoneSettings())) {
|
||||
toast(t('capture.mic_hint_linux'), { icon: 'ℹ️', duration: 8000 });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('permissions.open_settings')}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
{ duration: 10000 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* permissions — browser-safe wrappers around the Tauri shell's OS-permission
|
||||
* probes (microphone TCC / ConsentStore, macOS Accessibility) and the
|
||||
* deep-links into the matching OS settings panes.
|
||||
*
|
||||
* Every function degrades gracefully outside the Tauri shell (web UI /
|
||||
* Docker / vite dev): probes return the honest "we can't know" value
|
||||
* ('unknown' for the mic, `true` for Accessibility — there is nothing to
|
||||
* grant in a browser), and the open-settings deep-links resolve `false`
|
||||
* without throwing. A failure INSIDE Tauri (old shell without the command,
|
||||
* IPC hiccup) also degrades to the same values — the probes are advisory
|
||||
* and must never block dictation/recording on their own.
|
||||
*
|
||||
* Pure JS module (no React) so it is unit-testable; the React glue
|
||||
* (state + recheck-on-focus) lives in hooks/usePermissions.js.
|
||||
*/
|
||||
import { detectPlatform } from './micError';
|
||||
|
||||
export { detectPlatform };
|
||||
|
||||
/** True inside the Tauri shell; false in the browser web UI / Docker. */
|
||||
export function inTauri() {
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
// One shared import of the Tauri core module. Probes run concurrently
|
||||
// (Promise.all in usePermissions), and racing two dynamic imports of the
|
||||
// same specifier is both wasteful and — under vitest's module mocker —
|
||||
// nondeterministic; a single cached promise removes the race entirely.
|
||||
let corePromise = null;
|
||||
function tauriCore() {
|
||||
if (!corePromise) {
|
||||
corePromise = import('@tauri-apps/api/core').catch((err) => {
|
||||
// A transient import failure must not be cached forever — clear the
|
||||
// memo so the next probe/deep-link retries instead of re-rejecting.
|
||||
corePromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return corePromise;
|
||||
}
|
||||
|
||||
/** The four honest microphone grant states the shell can report. */
|
||||
const MIC_STATES = new Set(['granted', 'denied', 'prompt', 'unknown']);
|
||||
|
||||
/**
|
||||
* Microphone permission: 'granted' | 'denied' | 'prompt' | 'unknown'.
|
||||
* macOS reads the TCC grant; Windows the per-user ConsentStore toggle;
|
||||
* Linux (no per-app mic permission) and non-Tauri are always 'unknown'.
|
||||
* Callers must treat 'unknown' as neither granted nor denied.
|
||||
*/
|
||||
export async function checkMicrophone() {
|
||||
if (!inTauri()) return 'unknown';
|
||||
try {
|
||||
const { invoke } = await tauriCore();
|
||||
const state = await invoke('check_microphone');
|
||||
return MIC_STATES.has(state) ? state : 'unknown';
|
||||
} catch (err) {
|
||||
// Older shell without the command — don't guess, don't block.
|
||||
console.warn('check_microphone failed:', err);
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS Accessibility grant (needed so dictation can paste/type into other
|
||||
* apps). Resolves `true` on Windows/Linux and outside Tauri — nothing to
|
||||
* grant there.
|
||||
*/
|
||||
export async function checkAccessibility() {
|
||||
if (!inTauri()) return true;
|
||||
try {
|
||||
const { invoke } = await tauriCore();
|
||||
return (await invoke('check_accessibility')) !== false;
|
||||
} catch (err) {
|
||||
console.warn('check_accessibility failed:', err);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke an open-*-settings command. Resolves `true` when the pane was
|
||||
* opened, `false` when it wasn't (outside Tauri, or the shell rejected —
|
||||
* e.g. Linux has no mic-privacy pane and errors with a "settings:" kind).
|
||||
* Callers use the `false` to show a "use your system settings" hint.
|
||||
*/
|
||||
async function openSettingsPane(command) {
|
||||
if (!inTauri()) return false;
|
||||
try {
|
||||
const { invoke } = await tauriCore();
|
||||
await invoke(command);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(`${command} failed:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep-link the OS microphone-privacy pane (macOS/Windows; false on Linux). */
|
||||
export function openMicrophoneSettings() {
|
||||
return openSettingsPane('open_microphone_settings');
|
||||
}
|
||||
|
||||
/** Deep-link macOS Privacy → Accessibility (no-op elsewhere). */
|
||||
export function openAccessibilitySettings() {
|
||||
return openSettingsPane('open_accessibility_settings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-link macOS Privacy → Input Monitoring. Exposed for completeness —
|
||||
* the global dictation shortcut uses tauri-plugin-global-shortcut (Carbon
|
||||
* hotkey registration), which does NOT need this grant, so no default UI
|
||||
* surfaces it.
|
||||
*/
|
||||
export function openInputMonitoringSettings() {
|
||||
return openSettingsPane('open_input_monitoring_settings');
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args) => invokeMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
inTauri,
|
||||
checkMicrophone,
|
||||
checkAccessibility,
|
||||
openMicrophoneSettings,
|
||||
openAccessibilitySettings,
|
||||
openInputMonitoringSettings,
|
||||
} from './permissions';
|
||||
|
||||
let warnSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('permissions — browser fallback (no Tauri shell)', () => {
|
||||
it('inTauri() is false', () => {
|
||||
expect(inTauri()).toBe(false);
|
||||
});
|
||||
|
||||
it('checkMicrophone resolves "unknown" without touching invoke', async () => {
|
||||
await expect(checkMicrophone()).resolves.toBe('unknown');
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checkAccessibility resolves true without touching invoke', async () => {
|
||||
await expect(checkAccessibility()).resolves.toBe(true);
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('every open-settings deep-link resolves false without throwing', async () => {
|
||||
await expect(openMicrophoneSettings()).resolves.toBe(false);
|
||||
await expect(openAccessibilitySettings()).resolves.toBe(false);
|
||||
await expect(openInputMonitoringSettings()).resolves.toBe(false);
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permissions — inside the Tauri shell', () => {
|
||||
beforeEach(() => {
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
});
|
||||
|
||||
it.each(['granted', 'denied', 'prompt', 'unknown'])(
|
||||
'checkMicrophone passes "%s" through',
|
||||
async (state) => {
|
||||
invokeMock.mockResolvedValue(state);
|
||||
await expect(checkMicrophone()).resolves.toBe(state);
|
||||
expect(invokeMock).toHaveBeenCalledWith('check_microphone');
|
||||
},
|
||||
);
|
||||
|
||||
it('coerces an unexpected shell value to "unknown"', async () => {
|
||||
invokeMock.mockResolvedValue('whatever-new-state');
|
||||
await expect(checkMicrophone()).resolves.toBe('unknown');
|
||||
});
|
||||
|
||||
it('a failing probe degrades to unknown / true (never blocks)', async () => {
|
||||
invokeMock.mockRejectedValue(new Error('command check_microphone not found'));
|
||||
await expect(checkMicrophone()).resolves.toBe('unknown');
|
||||
await expect(checkAccessibility()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('checkAccessibility maps the boolean through', async () => {
|
||||
invokeMock.mockResolvedValue(false);
|
||||
await expect(checkAccessibility()).resolves.toBe(false);
|
||||
invokeMock.mockResolvedValue(true);
|
||||
await expect(checkAccessibility()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('openMicrophoneSettings resolves true when the pane opened', async () => {
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
await expect(openMicrophoneSettings()).resolves.toBe(true);
|
||||
expect(invokeMock).toHaveBeenCalledWith('open_microphone_settings');
|
||||
});
|
||||
|
||||
it('openMicrophoneSettings resolves false on the Linux "settings:" rejection', async () => {
|
||||
invokeMock.mockRejectedValue('settings: no microphone permission pane on this OS');
|
||||
await expect(openMicrophoneSettings()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('openInputMonitoringSettings resolves false on the non-macOS rejection', async () => {
|
||||
invokeMock.mockRejectedValue('settings: input monitoring settings are macOS-only');
|
||||
await expect(openInputMonitoringSettings()).resolves.toBe(false);
|
||||
expect(invokeMock).toHaveBeenCalledWith('open_input_monitoring_settings');
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,12 @@ dependencies = [
|
||||
# …). Gives mac-ARM users a broad engine picker in Settings. Also gated
|
||||
# by platform markers because it depends on mlx (Apple Silicon only).
|
||||
"mlx-audio>=0.3.0 ; sys_platform == 'darwin' and platform_machine == 'arm64'",
|
||||
# Apple Silicon-only Parakeet TDT v3 ASR (mlx-community/parakeet-tdt-0.6b-v3
|
||||
# via MLX). Gives mac-ARM the Parakeet tier CUDA/CPU users get from
|
||||
# sherpa/NeMo: 25 European languages, TDT token/word timestamps, ~2 GB
|
||||
# unified memory, dictation-grade speed on the GPU. Same platform gate as
|
||||
# mlx-whisper/mlx-audio (depends on mlx — no wheels elsewhere).
|
||||
"parakeet-mlx>=0.5.2 ; sys_platform == 'darwin' and platform_machine == 'arm64'",
|
||||
"demucs>=4.0.1",
|
||||
"yt-dlp>=2024.12.13",
|
||||
"alembic>=1.13",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Shared cleanup for tests/backend — undo sys.modules surgery.
|
||||
|
||||
Several files in this tree (test_perf_settings.py, test_engine_spawn_token.py,
|
||||
api/test_engines_route_shape.py, services/test_token_resolver.py, …) purge
|
||||
``core`` / ``api`` / ``services`` from ``sys.modules`` and re-import them under
|
||||
a per-test, monkeypatched ``OMNIVOICE_DATA_DIR``. monkeypatch restores the ENV
|
||||
at teardown, but the re-imported modules stay cached — bound to the now-dead
|
||||
tmp_path (``core.config`` freezes DB_PATH/VOICES_DIR at import time). Any
|
||||
later test that lazily resolves those modules (e.g. a route handler doing
|
||||
``from services import x`` at request time) then reads/writes a data dir that
|
||||
no other part of that test uses: in combined ``pytest tests/ backend/tests/``
|
||||
runs this broke backend/tests' personas import (voice file written into the
|
||||
poisoned VOICES_DIR) and audiobook resume (job seeded in one DB, endpoint
|
||||
reading another). CI's isolated invocations never see it; local combined runs
|
||||
do.
|
||||
|
||||
The autouse teardown below re-purges after every test here, so the next
|
||||
consumer re-imports against the RESTORED env. It deliberately mirrors the
|
||||
setup-side purge condition used by those files — keep the two in sync, and
|
||||
keep the bare package names ("api", "services", "core"): a surviving stale
|
||||
package object still holds attribute bindings to stale submodules, which
|
||||
splits ``from services import x`` (package attr, stale) from
|
||||
``from services.x import y`` (fresh re-import).
|
||||
"""
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def purge_backend_modules() -> None:
|
||||
for mod in list(sys.modules):
|
||||
if (
|
||||
mod in ("main", "core", "api", "services")
|
||||
or mod.startswith("core.")
|
||||
or mod.startswith("api.")
|
||||
or mod.startswith("services.")
|
||||
):
|
||||
sys.modules.pop(mod, None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _repurge_backend_modules_after_module_surgery():
|
||||
yield
|
||||
purge_backend_modules()
|
||||
@@ -31,6 +31,15 @@ if not os.environ.get("OMNIVOICE_ENV_FILE"):
|
||||
os.environ["OMNIVOICE_ENV_FILE"] = os.path.join(
|
||||
os.environ["OMNIVOICE_DATA_DIR"], "user-env"
|
||||
)
|
||||
# The TTS checkpoint sentinel, suite-wide. Individual modules used to opt in
|
||||
# (`OMNIVOICE_MODEL=test`), but any module that boots the real app lifespan
|
||||
# (`with TestClient(main.app)`) without it lets `preload_model()` resolve the
|
||||
# real k2-fsa/OmniVoice checkpoint — on a networked machine with an empty HF
|
||||
# cache that meant a silent multi-GB background download mid-suite. The
|
||||
# sentinel is honored verbatim by `resolve_omnivoice_checkpoint()` (never
|
||||
# self-healed to the real default), so no test can trigger a real model
|
||||
# download/load without explicitly overriding OMNIVOICE_MODEL.
|
||||
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
||||
|
||||
|
||||
# ── Test fixtures ──────────────────────────────────────────────────────────
|
||||
@@ -140,6 +149,59 @@ def _torch_default_dtype_guard(request):
|
||||
_drain_leaked_dtype(request.node.nodeid)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asr_model_installed(monkeypatch, request):
|
||||
"""Neutralize the no-ASR-installed preflight (asr_model_missing_error →
|
||||
None) for tests that exercise batch/dub/dictation/clone-ref *mechanics*
|
||||
and assume ASR weights are present. The hermetic test env has no HF model
|
||||
cache, so without this every ASR consumer answers the typed 409/SSE/WS
|
||||
``asr_model_missing`` payload before the code under test even runs. The
|
||||
preflight itself has its own suite (tests/test_asr_model_missing.py).
|
||||
Every consumer resolves the helper off ``services.asr_backend`` at call
|
||||
time, so patching the module covers them all. Opt in per module with
|
||||
``pytestmark = pytest.mark.usefixtures("asr_model_installed")``.
|
||||
|
||||
Patches BOTH the freshly imported module and any module-typed alias the
|
||||
test module itself holds (``import services.asr_backend as ab`` at top
|
||||
level): in a full-suite run an earlier test can purge ``services.*`` from
|
||||
sys.modules, leaving the test module's alias pointing at a STALE pre-purge
|
||||
module object — code invoked through that alias resolves the preflight in
|
||||
the stale module's globals, which a single sys.modules-based setattr would
|
||||
miss (the CI-only empty-HF-cache failure mode). Never patch by name
|
||||
string alone here. (Same fixture exists in backend/tests/conftest.py.)"""
|
||||
import types
|
||||
|
||||
from services import asr_backend
|
||||
|
||||
targets = {id(asr_backend): asr_backend}
|
||||
test_module = getattr(request, "module", None)
|
||||
if test_module is not None:
|
||||
for val in vars(test_module).values():
|
||||
if (isinstance(val, types.ModuleType)
|
||||
and getattr(val, "__name__", "") == "services.asr_backend"):
|
||||
targets[id(val)] = val
|
||||
for mod in targets.values():
|
||||
monkeypatch.setattr(mod, "asr_model_missing_error", lambda **_kw: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_asr_installed_memo():
|
||||
"""The ASR preflight memoizes installed-POSITIVE repos process-wide
|
||||
(services.asr_backend._INSTALLED_REPO_MEMO) so dictation stops paying a
|
||||
scan_cache_dir walk per utterance. Tests stub ``is_cached`` both ways, so
|
||||
a positive memoized under one test's stub (or from a dev machine's real
|
||||
HF cache) must never leak into the next test's 'missing' expectations.
|
||||
Touches the memo only when the module is already imported — never forces
|
||||
the import. (Same guard exists in backend/tests/conftest.py.)"""
|
||||
mod = sys.modules.get("services.asr_backend")
|
||||
if mod is not None:
|
||||
getattr(mod, "_INSTALLED_REPO_MEMO", set()).clear()
|
||||
yield
|
||||
mod = sys.modules.get("services.asr_backend")
|
||||
if mod is not None:
|
||||
getattr(mod, "_INSTALLED_REPO_MEMO", set()).clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def torch_dtype_isolation(request):
|
||||
"""Opt-in save/restore for tests known to trip the CI-Linux fp16 leak.
|
||||
|
||||
@@ -44,24 +44,50 @@ if not FIXTURE_SRC.exists():
|
||||
_FIXTURE_COPY = Path(tempfile.mkdtemp(prefix="omnivoice-smoke-"))
|
||||
shutil.copytree(FIXTURE_SRC, _FIXTURE_COPY, dirs_exist_ok=True)
|
||||
|
||||
# Point backend.core.config.get_app_data_dir() at the COPY. Force-override
|
||||
# (not setdefault) — earlier tests in a full-suite run may have set it to
|
||||
# their own temp dir, and core.config caches DB_PATH at first import.
|
||||
os.environ["OMNIVOICE_DATA_DIR"] = str(_FIXTURE_COPY)
|
||||
|
||||
def _purge_backend_modules():
|
||||
"""Drop cached backend modules so the next import re-reads the CURRENT
|
||||
env — `core.config` caches DB_PATH/VOICES_DIR at import time. Same
|
||||
pattern as tests/backend/services/conftest.py.
|
||||
|
||||
The bare package names ("api", "services") must be purged along with
|
||||
their submodules: a surviving stale package object keeps attribute
|
||||
bindings to STALE submodules, so `from services import asr_backend`
|
||||
resolves the stale twin while `from services.asr_backend import ...`
|
||||
re-imports a fresh one — fixture patches then land on the module the
|
||||
code under test never sees."""
|
||||
for mod in list(sys.modules):
|
||||
if (
|
||||
mod in ("main", "core", "api", "services")
|
||||
or mod.startswith("core.")
|
||||
or mod.startswith("api.")
|
||||
or mod.startswith("services.")
|
||||
):
|
||||
sys.modules.pop(mod, None)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
# Purge any cached backend modules from earlier tests in the suite —
|
||||
# `core.config` reads OMNIVOICE_DATA_DIR at import time and caches DB_PATH,
|
||||
# so a prior import with a different value would survive the env-var
|
||||
# override above. Same pattern as tests/backend/services/conftest.py.
|
||||
for mod in list(sys.modules):
|
||||
if mod == "main" or mod == "core" or mod.startswith("core.") or mod.startswith("api.") or mod.startswith("services."):
|
||||
sys.modules.pop(mod, None)
|
||||
# Point backend.core.config.get_app_data_dir() at the COPY, scoped to
|
||||
# THIS module and undone afterwards. This used to be a module-level
|
||||
# `os.environ["OMNIVOICE_DATA_DIR"] = ...` — a process-wide leak: in a
|
||||
# combined `pytest tests/ backend/tests/` run every later fresh import
|
||||
# of core.config resolved DB/voices paths into the smoke fixture copy,
|
||||
# breaking backend/tests (personas import wrote voices into one data
|
||||
# dir while the test asserted another; audiobook resume read a
|
||||
# different DB than it seeded). CI's isolated invocations never see
|
||||
# combined-run leaks, so keep this bubble airtight for local runs.
|
||||
mp = pytest.MonkeyPatch()
|
||||
mp.setenv("OMNIVOICE_DATA_DIR", str(_FIXTURE_COPY))
|
||||
_purge_backend_modules()
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
return TestClient(app, client=("127.0.0.1", 50000))
|
||||
yield TestClient(app, client=("127.0.0.1", 50000))
|
||||
# Teardown mirrors setup: purge the modules imported under the smoke
|
||||
# env FIRST, then restore the env — later tests re-import against the
|
||||
# restored OMNIVOICE_DATA_DIR instead of inheriting smoke-bound paths.
|
||||
_purge_backend_modules()
|
||||
mp.undo()
|
||||
|
||||
|
||||
# ── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -148,3 +148,249 @@ def test_alignment_failure_never_loses_the_transcript(monkeypatch):
|
||||
|
||||
def test_empty_segments_short_circuit(monkeypatch):
|
||||
assert ab.forced_align([], object(), "en") == []
|
||||
|
||||
|
||||
# ── parakeet-mlx (Apple Silicon Parakeet TDT v3) ────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _fresh_capture_singleton(monkeypatch):
|
||||
"""Isolate the capture-backend singleton and pin a deterministic
|
||||
environment: no sherpa pref, MLX whisper + faster-whisper available."""
|
||||
monkeypatch.setattr(ab, "_capture_backend", None)
|
||||
monkeypatch.setattr(ab, "_capture_backend_key", None)
|
||||
monkeypatch.setattr(ab, "dictation_model_id", lambda: None)
|
||||
monkeypatch.setattr(ab.MLXWhisperBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
monkeypatch.setattr(ab.FasterWhisperBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
yield
|
||||
|
||||
|
||||
def test_parakeet_mlx_is_registered_with_hint_and_mps_compat():
|
||||
assert "parakeet-mlx" in ab._REGISTRY
|
||||
assert ab._REGISTRY["parakeet-mlx"] is ab.ParakeetMLXBackend
|
||||
assert ab.ParakeetMLXBackend.gpu_compat == ("mps",)
|
||||
assert ab._INSTALL_HINTS.get("parakeet-mlx")
|
||||
|
||||
|
||||
def test_capture_prefers_installed_parakeet_mlx(_fresh_capture_singleton, monkeypatch):
|
||||
"""On Apple Silicon with the Parakeet weights ON DISK and a covered
|
||||
(European) locale language, dictation/capture picks parakeet-mlx over
|
||||
mlx-whisper turbo."""
|
||||
monkeypatch.setattr(ab.ParakeetMLXBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
monkeypatch.setattr(ab, "_parakeet_mlx_installed", lambda: True)
|
||||
monkeypatch.setattr(ab, "_locale_language", lambda: "en")
|
||||
backend = ab.get_capture_asr_backend()
|
||||
assert isinstance(backend, ab.ParakeetMLXBackend)
|
||||
# The dictation preflight mirror agrees: the repo it would check is the
|
||||
# installed parakeet model, so no asr_model_missing 409 can fire.
|
||||
assert ab._capture_whisper_repo() == ab._PARAKEET_MLX_DEFAULT
|
||||
|
||||
|
||||
def test_capture_keeps_whisper_for_uncovered_language(_fresh_capture_singleton, monkeypatch):
|
||||
"""Language parity: parakeet-mlx knows exactly 25 European languages —
|
||||
a CJK/etc locale must keep the ~100-language whisper tier even with the
|
||||
parakeet weights installed. Installing a model must never silently break
|
||||
dictation that worked yesterday."""
|
||||
monkeypatch.setattr(ab.ParakeetMLXBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
monkeypatch.setattr(ab, "_parakeet_mlx_installed", lambda: True)
|
||||
monkeypatch.setattr(ab, "_locale_language", lambda: "ja")
|
||||
backend = ab.get_capture_asr_backend()
|
||||
assert isinstance(backend, ab.MLXWhisperBackend)
|
||||
# Preflight mirror stays in lock-step with the picker.
|
||||
assert ab._capture_whisper_repo() == ab._MLX_MODEL_TURBO
|
||||
|
||||
|
||||
def test_capture_keeps_whisper_without_locale_signal(_fresh_capture_singleton, monkeypatch):
|
||||
"""No usable locale (C/POSIX, empty launchd GUI env) → no evidence the
|
||||
user's language is covered → keep whisper (never regress on a guess)."""
|
||||
monkeypatch.setattr(ab.ParakeetMLXBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
monkeypatch.setattr(ab, "_parakeet_mlx_installed", lambda: True)
|
||||
monkeypatch.setattr(ab, "_locale_language", lambda: None)
|
||||
backend = ab.get_capture_asr_backend()
|
||||
assert isinstance(backend, ab.MLXWhisperBackend)
|
||||
|
||||
|
||||
def test_env_pinned_parakeet_model_bypasses_language_gate(_fresh_capture_singleton, monkeypatch):
|
||||
"""ASR_MODEL_PARAKEET_MLX is an explicit engine choice — trust it over
|
||||
the locale heuristic."""
|
||||
monkeypatch.setattr(ab.ParakeetMLXBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
monkeypatch.setattr(ab, "_parakeet_mlx_installed", lambda: True)
|
||||
monkeypatch.setattr(ab, "_locale_language", lambda: "ja")
|
||||
monkeypatch.setenv("ASR_MODEL_PARAKEET_MLX", ab._PARAKEET_MLX_DEFAULT)
|
||||
backend = ab.get_capture_asr_backend()
|
||||
assert isinstance(backend, ab.ParakeetMLXBackend)
|
||||
|
||||
|
||||
def test_installing_parakeet_mid_session_rebuilds_capture_singleton(
|
||||
_fresh_capture_singleton, monkeypatch):
|
||||
"""The warm-singleton cache key includes the parakeet gate, so installing
|
||||
the model from Settings → Models takes effect on the next utterance —
|
||||
not after an app restart (the stale-singleton regression)."""
|
||||
monkeypatch.setattr(ab.ParakeetMLXBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
monkeypatch.setattr(ab, "_locale_language", lambda: "en")
|
||||
installed = {"v": False}
|
||||
monkeypatch.setattr(ab, "_parakeet_mlx_installed", lambda: installed["v"])
|
||||
b1 = ab.get_capture_asr_backend()
|
||||
assert isinstance(b1, ab.MLXWhisperBackend)
|
||||
# Warm reuse while nothing changed.
|
||||
assert ab.get_capture_asr_backend() is b1
|
||||
installed["v"] = True # user installs parakeet from Settings → Models
|
||||
b2 = ab.get_capture_asr_backend()
|
||||
assert isinstance(b2, ab.ParakeetMLXBackend)
|
||||
|
||||
|
||||
def test_locale_language_parses_env_and_skips_posix(monkeypatch):
|
||||
import locale as _locale
|
||||
for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
monkeypatch.setattr(_locale, "getlocale", lambda: (None, None))
|
||||
assert ab._locale_language() is None
|
||||
monkeypatch.setenv("LANG", "C.UTF-8")
|
||||
assert ab._locale_language() is None # POSIX default is not a language
|
||||
monkeypatch.setenv("LANG", "de_DE.UTF-8")
|
||||
assert ab._locale_language() == "de"
|
||||
monkeypatch.setenv("LC_ALL", "uk_UA.UTF-8") # LC_ALL outranks LANG
|
||||
assert ab._locale_language() == "uk"
|
||||
|
||||
|
||||
def test_parakeet_transcribe_never_claims_english(monkeypatch):
|
||||
"""The backend serves 25 languages and cannot see the model's detected
|
||||
pick — it must report the caller's requested language (or None), never a
|
||||
hardcoded 'en' that downstream consumers would treat as detected truth."""
|
||||
class _Sent:
|
||||
text, start, end, tokens = "hallo welt", 0.0, 1.0, []
|
||||
|
||||
class _Res:
|
||||
text, sentences = "hallo welt", [_Sent()]
|
||||
|
||||
class _Model:
|
||||
def transcribe(self, path, **kw):
|
||||
return _Res()
|
||||
|
||||
backend = ab.ParakeetMLXBackend()
|
||||
monkeypatch.setattr(backend, "_ensure_model", lambda: None)
|
||||
backend._model = _Model()
|
||||
assert backend.transcribe("a.wav")["language"] is None
|
||||
assert backend.transcribe("a.wav", language="de")["language"] == "de"
|
||||
|
||||
|
||||
def test_capture_never_auto_downloads_parakeet_mlx(_fresh_capture_singleton, monkeypatch):
|
||||
"""Package installed but weights NOT cached → the picker must skip
|
||||
parakeet-mlx (no surprise ~1.2 GB download) and keep MLX turbo. Exercises
|
||||
the REAL _parakeet_mlx_installed via the model store's is_cached."""
|
||||
from api.routers.setup import models as setup_models
|
||||
monkeypatch.setattr(ab.ParakeetMLXBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
monkeypatch.setattr(ab, "_locale_language", lambda: "en") # pass the language gate
|
||||
monkeypatch.setattr(setup_models, "is_cached", lambda repo_id: False)
|
||||
monkeypatch.setattr(setup_models, "cache_is_complete", lambda meta: True)
|
||||
backend = ab.get_capture_asr_backend()
|
||||
assert isinstance(backend, ab.MLXWhisperBackend)
|
||||
assert not isinstance(backend, ab.ParakeetMLXBackend)
|
||||
|
||||
|
||||
def test_capture_skips_parakeet_mlx_when_package_unavailable(_fresh_capture_singleton, monkeypatch):
|
||||
"""Not Apple Silicon / package missing → unchanged pre-existing behavior."""
|
||||
monkeypatch.setattr(ab.ParakeetMLXBackend, "is_available",
|
||||
classmethod(lambda cls: (False, "requires Apple Silicon")))
|
||||
monkeypatch.setattr(
|
||||
ab, "_parakeet_mlx_installed",
|
||||
lambda: pytest.fail("installed-check must not run when unavailable"),
|
||||
)
|
||||
backend = ab.get_capture_asr_backend()
|
||||
assert isinstance(backend, ab.MLXWhisperBackend)
|
||||
|
||||
|
||||
def test_offline_auto_detect_never_picks_parakeet_mlx(monkeypatch):
|
||||
"""Language-coverage parity: the whisper family stays the universal
|
||||
offline (dub/batch) default — 25 EU languages is not 99."""
|
||||
monkeypatch.setattr(ab, "_mps_available", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ab, "_probe_available",
|
||||
_probe({"parakeet-mlx", "mlx-whisper", "whisperx", "faster-whisper"}),
|
||||
)
|
||||
assert ab._auto_detect() == "mlx-whisper"
|
||||
|
||||
|
||||
def test_pinned_parakeet_mlx_is_preflighted_not_silently_downloaded(monkeypatch):
|
||||
"""A user who PINS parakeet-mlx as the offline engine with no weights on
|
||||
disk gets the typed asr_model_missing payload (download CTA), never a
|
||||
silent first-load download."""
|
||||
from api.routers.setup import models as setup_models
|
||||
monkeypatch.setattr(ab, "active_backend_id", lambda: "parakeet-mlx")
|
||||
monkeypatch.setattr(setup_models, "is_cached", lambda repo_id: False)
|
||||
monkeypatch.setattr(setup_models, "cache_is_complete", lambda meta: True)
|
||||
# The catalog entry is darwin-arm64-only; make the host check deterministic
|
||||
# so the recommended-pick assertion holds on Linux CI too.
|
||||
monkeypatch.setattr(setup_models, "_model_supported", lambda m: True)
|
||||
payload = ab.asr_model_missing_error()
|
||||
assert payload is not None
|
||||
assert payload["error"] == "asr_model_missing"
|
||||
assert payload["missing_repo_id"] == ab._PARAKEET_MLX_DEFAULT
|
||||
# The catalog carries the model, so the CTA recommends exactly it.
|
||||
assert payload["recommended"]["repo_id"] == ab._PARAKEET_MLX_DEFAULT
|
||||
|
||||
|
||||
def test_parakeet_mlx_token_to_word_mapping():
|
||||
"""Subword tokens (leading space = word start) merge into whisper-shaped
|
||||
word dicts with the span's start/end."""
|
||||
class Tok:
|
||||
def __init__(self, text, start, end):
|
||||
self.text, self.start, self.end = text, start, end
|
||||
|
||||
words = ab.ParakeetMLXBackend._tokens_to_words([
|
||||
Tok(" Hel", 0.10, 0.20), Tok("lo", 0.20, 0.30),
|
||||
Tok(" wor", 0.40, 0.55), Tok("ld", 0.55, 0.70), Tok(".", 0.70, 0.75),
|
||||
])
|
||||
assert words == [
|
||||
{"word": "Hello", "start": 0.10, "end": 0.30},
|
||||
{"word": "world.", "start": 0.40, "end": 0.75},
|
||||
]
|
||||
|
||||
|
||||
# Real-weights smoke — opt-in like the supertonic smoke tests: downloads
|
||||
# ~460 MB (the tiny 110M TDT-CTC model, NOT the 1.2 GB v3), so CI never runs
|
||||
# it. Locally: OMNIVOICE_SMOKE=1 pytest tests/test_asr_device_aware_autodetect.py
|
||||
_PARAKEET_MLX_TINY = "mlx-community/parakeet-tdt_ctc-110m"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("OMNIVOICE_SMOKE") != "1",
|
||||
reason="network test (~460 MB weights); set OMNIVOICE_SMOKE=1 to run",
|
||||
)
|
||||
def test_parakeet_mlx_real_transcribe_smoke(tmp_path):
|
||||
"""End-to-end on real weights (Apple Silicon only): load, transcribe a
|
||||
generated WAV, and verify the standard return shape survives the trip."""
|
||||
ok, why = ab.ParakeetMLXBackend.is_available()
|
||||
if not ok:
|
||||
pytest.skip(f"parakeet-mlx unavailable on this host: {why}")
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
sr = 16000
|
||||
t = np.linspace(0, 1.0, sr, endpoint=False)
|
||||
wav = (0.1 * np.sin(2 * np.pi * 220 * t)).astype("float32")
|
||||
path = tmp_path / "tone.wav"
|
||||
sf.write(path, wav, sr)
|
||||
|
||||
backend = ab.ParakeetMLXBackend(model_name=_PARAKEET_MLX_TINY)
|
||||
backend.ensure_loaded()
|
||||
try:
|
||||
out = backend.transcribe(str(path), word_timestamps=True)
|
||||
finally:
|
||||
backend.unload()
|
||||
assert set(out) >= {"text", "chunks", "segments", "language"}
|
||||
assert isinstance(out["text"], str)
|
||||
for seg in out["segments"]:
|
||||
assert isinstance(seg["text"], str) and seg["text"].strip()
|
||||
assert seg["start"] <= seg["end"]
|
||||
for w in seg["words"]:
|
||||
assert w["word"] and w["start"] <= w["end"]
|
||||
for chunk in out["chunks"]:
|
||||
assert chunk["text"] and len(chunk["timestamp"]) == 2
|
||||
|
||||
@@ -20,6 +20,9 @@ _EXPECTED = {
|
||||
"mlx-whisper": ("mps", "cpu"),
|
||||
"pytorch-whisper": ("cuda", "mps", "cpu"),
|
||||
"nemo-parakeet": ("cuda", "cpu"),
|
||||
# MLX runs on Apple Silicon's unified-memory GPU only; is_available
|
||||
# hard-gates on mlx_supported(), so claiming cpu would be false.
|
||||
"parakeet-mlx": ("mps",),
|
||||
"moonshine": ("cpu",),
|
||||
"funasr": ("cuda", "cpu"),
|
||||
# Crash-isolated sidecar wraps the same CTranslate2 engine as
|
||||
@@ -27,8 +30,9 @@ _EXPECTED = {
|
||||
"faster-whisper-isolated": ("cuda", "cpu"),
|
||||
}
|
||||
|
||||
# Engines that legitimately have NO cpu path (hard GPU gate in is_available).
|
||||
_GPU_ONLY: set[str] = set()
|
||||
# Engines that legitimately have NO cpu path (hard platform/GPU gate in
|
||||
# is_available — e.g. parakeet-mlx gates on Apple Silicon via mlx_supported()).
|
||||
_GPU_ONLY: set[str] = {"parakeet-mlx"}
|
||||
|
||||
_VALID = {"cuda", "rocm", "mps", "xpu", "cpu"}
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""No-ASR-installed preflight (TTS-only installs).
|
||||
|
||||
Only the TTS model is required (models.yaml): a fresh install has NO ASR
|
||||
model on disk. Every whisper-family backend auto-downloads its weights from
|
||||
HF on first load, so before this preflight an ASR-less install that hit
|
||||
dub / batch / dictation either silently pulled a multi-GB model or 500'd.
|
||||
These tests pin the contract: each consumer answers with a typed 409 (or SSE
|
||||
error) carrying ``{"error": "asr_model_missing", "recommended": {...}}``
|
||||
BEFORE any backend is constructed — and stays silent when a model IS
|
||||
installed (mocked via the same ``is_cached`` helper the model store uses).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
from main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _offline_asr_missing(cached: bool):
|
||||
"""Deterministic offline (dub/batch) selection: faster-whisper large-v3,
|
||||
with the HF cache reporting installed/not-installed per ``cached``."""
|
||||
from api.routers.setup import models as setup_models
|
||||
from services import asr_backend
|
||||
with patch.object(asr_backend, "active_backend_id", return_value="faster-whisper"), \
|
||||
patch.object(setup_models, "is_cached", return_value=cached), \
|
||||
patch.object(setup_models, "cache_is_complete", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _dictation_whisper_missing():
|
||||
"""Deterministic dictation selection: no sherpa pref, MLX unavailable,
|
||||
faster-whisper package available, model NOT cached."""
|
||||
from api.routers.setup import models as setup_models
|
||||
from services import asr_backend
|
||||
with patch.object(asr_backend, "dictation_model_id", return_value=None), \
|
||||
patch.object(asr_backend.MLXWhisperBackend, "is_available",
|
||||
return_value=(False, "not apple silicon")), \
|
||||
patch.object(asr_backend.FasterWhisperBackend, "is_available",
|
||||
return_value=(True, "ready")), \
|
||||
patch.object(asr_backend.SherpaDictationBackend, "is_available",
|
||||
return_value=(False, "sherpa-onnx not installed")), \
|
||||
patch.object(setup_models, "is_cached", return_value=False), \
|
||||
patch.object(setup_models, "cache_is_complete", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
# ── Helper contract ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestHelper:
|
||||
def test_none_when_model_installed(self):
|
||||
from services.asr_backend import asr_model_missing_error
|
||||
with _offline_asr_missing(cached=True):
|
||||
assert asr_model_missing_error() is None
|
||||
|
||||
def test_typed_payload_when_missing(self):
|
||||
from services.asr_backend import asr_model_missing_error
|
||||
with _offline_asr_missing(cached=False):
|
||||
payload = asr_model_missing_error()
|
||||
assert payload is not None
|
||||
assert payload["error"] == "asr_model_missing"
|
||||
assert payload["missing_repo_id"] == "Systran/faster-whisper-large-v3"
|
||||
rec = payload["recommended"]
|
||||
# The missing repo is itself in the catalog → recommend exactly it,
|
||||
# so the one-click download makes a retry succeed.
|
||||
assert rec["repo_id"] == "Systran/faster-whisper-large-v3"
|
||||
assert rec["label"] and rec["size_gb"] > 0
|
||||
|
||||
def test_opt_in_engines_are_not_gated(self):
|
||||
# FunASR/NeMo/… are explicit opt-ins we can't preflight — never block.
|
||||
from services import asr_backend
|
||||
with patch.object(asr_backend, "active_backend_id", return_value="funasr"):
|
||||
assert asr_backend.asr_model_missing_error() is None
|
||||
|
||||
def test_dictation_sherpa_selected_but_not_installed(self):
|
||||
from services import asr_backend
|
||||
from services import sherpa_dictation as sd
|
||||
with patch.object(asr_backend, "dictation_model_id",
|
||||
return_value="sherpa-parakeet-tdt-v3"), \
|
||||
patch.object(asr_backend.SherpaDictationBackend, "is_available",
|
||||
return_value=(True, "ready")), \
|
||||
patch.object(sd, "is_installed", return_value=False):
|
||||
payload = asr_backend.asr_model_missing_error(purpose="dictation")
|
||||
assert payload is not None
|
||||
assert payload["error"] == "asr_model_missing"
|
||||
rec = payload["recommended"]
|
||||
# The curated sherpa dictation entry, with the dictation_id the client
|
||||
# needs to also set dictation.model_id so the retry picks it up.
|
||||
assert rec["repo_id"] == "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
|
||||
assert rec["dictation_id"] == "sherpa-parakeet-tdt-v3"
|
||||
|
||||
def test_dictation_sherpa_installed_is_fine(self):
|
||||
from services import asr_backend
|
||||
from services import sherpa_dictation as sd
|
||||
with patch.object(asr_backend, "dictation_model_id",
|
||||
return_value="sherpa-parakeet-tdt-v3"), \
|
||||
patch.object(asr_backend.SherpaDictationBackend, "is_available",
|
||||
return_value=(True, "ready")), \
|
||||
patch.object(sd, "is_installed", return_value=True):
|
||||
assert asr_backend.asr_model_missing_error(purpose="dictation") is None
|
||||
|
||||
def test_never_raises(self):
|
||||
from services import asr_backend
|
||||
with patch.object(asr_backend, "active_backend_id",
|
||||
side_effect=RuntimeError("boom")):
|
||||
assert asr_backend.asr_model_missing_error() is None
|
||||
|
||||
def test_custom_pin_outside_catalog_fails_open(self):
|
||||
"""A repo the model store can't install (custom ASR_MODEL_* pin) must
|
||||
FAIL OPEN — the download CTA could never fix that state, so blocking
|
||||
would trap the user in an un-installable loop."""
|
||||
from services import asr_backend
|
||||
with patch.object(asr_backend, "active_backend_id",
|
||||
return_value="faster-whisper"), \
|
||||
patch.dict(os.environ, {"ASR_MODEL_FASTER": "someorg/custom-whisper"}):
|
||||
assert asr_backend.asr_model_missing_error() is None
|
||||
|
||||
def test_pytorch_whisper_default_repo_fails_open(self):
|
||||
"""openai/whisper-large-v3-turbo (the pytorch-whisper default) is not
|
||||
a catalog entry — the preflight stays out of the way (auto-download,
|
||||
the pre-preflight behaviour)."""
|
||||
from services import asr_backend
|
||||
with patch.object(asr_backend, "active_backend_id",
|
||||
return_value="pytorch-whisper"):
|
||||
assert asr_backend.asr_model_missing_error() is None
|
||||
|
||||
def test_unknown_faster_whisper_alias_fails_open(self):
|
||||
"""An alias our table doesn't know (but faster_whisper may resolve)
|
||||
must not be coerced to the default repo's CTA."""
|
||||
from services import asr_backend
|
||||
with patch.object(asr_backend, "active_backend_id",
|
||||
return_value="faster-whisper"), \
|
||||
patch.dict(os.environ, {"ASR_MODEL_FASTER": "large-v3-turbo-exotic"}):
|
||||
assert asr_backend.asr_model_missing_error() is None
|
||||
|
||||
def test_isolated_faster_whisper_is_preflighted(self):
|
||||
"""The crash-isolated sidecar loads the same CT2 weights as in-process
|
||||
faster-whisper — it gets the same preflight, not a silent download."""
|
||||
from api.routers.setup import models as setup_models
|
||||
from services import asr_backend
|
||||
with patch.object(asr_backend, "active_backend_id",
|
||||
return_value="faster-whisper-isolated"), \
|
||||
patch.object(setup_models, "is_cached", return_value=False), \
|
||||
patch.object(setup_models, "cache_is_complete", return_value=True):
|
||||
payload = asr_backend.asr_model_missing_error()
|
||||
assert payload is not None
|
||||
assert payload["missing_repo_id"] == "Systran/faster-whisper-large-v3"
|
||||
|
||||
def test_sherpa_offline_backend_maps_to_configured_model(self):
|
||||
"""OMNIVOICE_ASR_BACKEND=sherpa-onnx-asr preflights the configured
|
||||
(default) sherpa dictation model's repo."""
|
||||
from api.routers.setup import models as setup_models
|
||||
from services import asr_backend
|
||||
with patch.object(asr_backend, "active_backend_id",
|
||||
return_value="sherpa-onnx-asr"), \
|
||||
patch.object(setup_models, "is_cached", return_value=False), \
|
||||
patch.object(setup_models, "cache_is_complete", return_value=True):
|
||||
payload = asr_backend.asr_model_missing_error()
|
||||
assert payload is not None
|
||||
assert payload["missing_repo_id"] == (
|
||||
"csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
|
||||
)
|
||||
|
||||
def test_installed_positive_is_memoized(self):
|
||||
"""Once a repo is confirmed installed, later preflights skip the
|
||||
scan_cache_dir walk (installs only add — no invalidation needed)."""
|
||||
from api.routers.setup import models as setup_models
|
||||
from services import asr_backend
|
||||
calls = {"n": 0}
|
||||
|
||||
def counting_is_cached(repo_id):
|
||||
calls["n"] += 1
|
||||
return True
|
||||
|
||||
with patch.object(asr_backend, "active_backend_id",
|
||||
return_value="faster-whisper"), \
|
||||
patch.object(setup_models, "is_cached", side_effect=counting_is_cached), \
|
||||
patch.object(setup_models, "cache_is_complete", return_value=True):
|
||||
assert asr_backend.asr_model_missing_error() is None
|
||||
assert asr_backend.asr_model_missing_error() is None
|
||||
assert calls["n"] == 1
|
||||
|
||||
def test_transcribe_reference_skips_without_backend_construction(self, tmp_path):
|
||||
"""Clone-ref transcription degrades to None — it must never trigger a
|
||||
silent multi-GB download (it is best-effort by contract)."""
|
||||
from services import asr_backend
|
||||
wav = tmp_path / "ref.wav"
|
||||
wav.write_bytes(b"RIFF0000WAVE")
|
||||
with _offline_asr_missing(cached=False), \
|
||||
patch.object(asr_backend, "get_active_asr_backend",
|
||||
side_effect=AssertionError("backend must not be built")):
|
||||
assert asr_backend.transcribe_reference(str(wav)) is None
|
||||
|
||||
|
||||
# ── Consumer wiring (typed 409, not 500, not a download) ────────────────────
|
||||
|
||||
def _assert_409(r):
|
||||
assert r.status_code == 409, r.text
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "asr_model_missing"
|
||||
assert detail["recommended"]["repo_id"]
|
||||
assert detail["message"]
|
||||
|
||||
|
||||
class TestEndpoints:
|
||||
def test_batch_enqueue_409(self, client):
|
||||
with _offline_asr_missing(cached=False):
|
||||
r = client.post(
|
||||
"/batch/enqueue",
|
||||
files={"video": ("t.mp4", b"x", "video/mp4")},
|
||||
data={"langs": "es"},
|
||||
)
|
||||
_assert_409(r)
|
||||
|
||||
def test_batch_enqueue_unaffected_when_installed(self, client):
|
||||
# Backward compat: an install with whisper on disk never sees the 409.
|
||||
# (Bad langs short-circuits before any job is actually enqueued.)
|
||||
with _offline_asr_missing(cached=True):
|
||||
r = client.post(
|
||||
"/batch/enqueue",
|
||||
files={"video": ("t.mp4", b"x", "video/mp4")},
|
||||
data={"langs": " "},
|
||||
)
|
||||
assert r.status_code == 400 # the langs error, not the ASR 409
|
||||
|
||||
def test_capture_transcribe_409(self, client):
|
||||
with _dictation_whisper_missing():
|
||||
r = client.post(
|
||||
"/transcribe",
|
||||
files={"audio": ("t.wav", b"RIFF0000WAVE", "audio/wav")},
|
||||
)
|
||||
_assert_409(r)
|
||||
|
||||
def test_dub_legacy_transcribe_409(self, client, tmp_path):
|
||||
from api.routers import dub_core
|
||||
wav = tmp_path / "audio.wav"
|
||||
wav.write_bytes(b"RIFF0000WAVE")
|
||||
job = {"id": "j1", "audio_path": str(wav)}
|
||||
with patch.object(dub_core, "_get_job", return_value=job), \
|
||||
_offline_asr_missing(cached=False):
|
||||
r = client.post("/dub/transcribe/j1")
|
||||
_assert_409(r)
|
||||
|
||||
def test_dub_transcribe_preflights_even_with_preloaded_pipe(self, client, tmp_path):
|
||||
"""A preloaded `_asr_pipe` only substitutes for the *pytorch-whisper*
|
||||
backend (its sole consumer) — with faster-whisper active and no
|
||||
weights on disk, the typed 409 must still fire instead of letting the
|
||||
backend auto-download."""
|
||||
from api.routers import dub_core
|
||||
wav = tmp_path / "audio.wav"
|
||||
wav.write_bytes(b"RIFF0000WAVE")
|
||||
job = {"id": "j3", "audio_path": str(wav)}
|
||||
model = type("M", (), {"_asr_pipe": object()})()
|
||||
|
||||
async def fake_get_model():
|
||||
return model
|
||||
|
||||
with patch.object(dub_core, "_get_job", return_value=job), \
|
||||
patch.object(dub_core, "should_preload_tts_asr", return_value=True), \
|
||||
patch.object(dub_core, "get_model", fake_get_model), \
|
||||
_offline_asr_missing(cached=False):
|
||||
r = client.post("/dub/transcribe/j3")
|
||||
_assert_409(r)
|
||||
|
||||
def test_dub_stream_emits_typed_sse_error(self, client, tmp_path):
|
||||
"""EventSource can't read non-2xx bodies, so the SSE preflight must
|
||||
carry the typed payload in-stream."""
|
||||
from api.routers import dub_core
|
||||
wav = tmp_path / "audio.wav"
|
||||
wav.write_bytes(b"RIFF0000WAVE")
|
||||
job = {"id": "j2", "audio_path": str(wav)}
|
||||
with patch.object(dub_core, "_get_job", return_value=job), \
|
||||
_offline_asr_missing(cached=False):
|
||||
r = client.get("/dub/transcribe-stream/j2")
|
||||
assert r.status_code == 200
|
||||
assert "event: error" in r.text
|
||||
assert '"error": "asr_model_missing"' in r.text
|
||||
assert '"recommended"' in r.text
|
||||
|
||||
def test_openai_compat_transcriptions_409(self, client):
|
||||
with _offline_asr_missing(cached=False):
|
||||
r = client.post(
|
||||
"/v1/audio/transcriptions",
|
||||
files={"file": ("t.wav", b"RIFF0000WAVE", "audio/wav")},
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
assert "No speech-to-text model is installed" in r.json()["detail"]
|
||||
|
||||
def test_capture_ws_sends_typed_error_frame(self, client):
|
||||
from api.routers import capture_ws
|
||||
with patch.object(capture_ws, "_LOOPBACK_HOSTS",
|
||||
set(capture_ws._LOOPBACK_HOSTS) | {"testclient"}), \
|
||||
_dictation_whisper_missing():
|
||||
with client.websocket_connect("/ws/transcribe") as ws:
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "error"
|
||||
assert msg["kind"] == "asr_model_missing"
|
||||
assert msg["error"] == "asr_model_missing"
|
||||
assert msg["recommended"]["repo_id"]
|
||||
@@ -21,6 +21,12 @@ import pytest
|
||||
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
||||
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
||||
|
||||
# These tests exercise the endpoint's wiring with a stubbed backend and assume
|
||||
# ASR weights are installed — neutralize the no-ASR preflight (which otherwise
|
||||
# answers a typed 409 asr_model_missing in the hermetic no-HF-cache test env;
|
||||
# the preflight has its own suite: tests/test_asr_model_missing.py).
|
||||
pytestmark = pytest.mark.usefixtures("asr_model_installed")
|
||||
|
||||
|
||||
class _StubBackend:
|
||||
id = "stub"
|
||||
|
||||
@@ -22,6 +22,13 @@ os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
||||
os.environ["OMNIVOICE_STREAM_INTERVAL"] = "0.1"
|
||||
os.environ["OMNIVOICE_STREAM_SILENCE"] = "0.2"
|
||||
|
||||
# These tests exercise the WS protocol with stubbed transcription and assume
|
||||
# ASR weights are installed — neutralize the no-ASR preflight (which otherwise
|
||||
# closes the socket with a typed asr_model_missing error frame in the hermetic
|
||||
# no-HF-cache test env; the preflight has its own suite:
|
||||
# tests/test_asr_model_missing.py).
|
||||
pytestmark = pytest.mark.usefixtures("asr_model_installed")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
|
||||
@@ -18,6 +18,11 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# These tests exercise the transcribe-stream mechanics and assume ASR weights
|
||||
# are installed — neutralize the no-ASR preflight (its own suite:
|
||||
# tests/test_asr_model_missing.py).
|
||||
pytestmark = pytest.mark.usefixtures("asr_model_installed")
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ import wave
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
# These tests exercise ASR-consumer mechanics and assume ASR weights are
|
||||
# installed - neutralize the no-ASR preflight (its own suite:
|
||||
# tests/test_asr_model_missing.py).
|
||||
pytestmark = pytest.mark.usefixtures("asr_model_installed")
|
||||
|
||||
|
||||
from api.routers import dub_core as dc
|
||||
from services.asr_backend import FunASRBackend
|
||||
|
||||
@@ -39,6 +39,18 @@ def _symlink_or_skip(target: str, link: str) -> None:
|
||||
pytest.skip("symlinks not supported in this environment")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_ambient_offline_mode(monkeypatch):
|
||||
"""All network in this file is mocked, but `repair_repo_cache` itself
|
||||
deliberately refuses to delete anything while HF offline mode is set
|
||||
("don't delete what we can't restore"). An ambient HF_HUB_OFFLINE=1 —
|
||||
offline CI, air-gapped dev shell — must not silently flip these tests
|
||||
onto that skip path. `test_repair_offline_deletes_nothing` opts back in
|
||||
explicitly via monkeypatch.setenv."""
|
||||
monkeypatch.delenv("HF_HUB_OFFLINE", raising=False)
|
||||
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising=False)
|
||||
|
||||
|
||||
def _mk_repo_cache(tmp_path, repo_id: str = "test/checkpoint"):
|
||||
"""The canonical HF cache layout for ``repo_id``, with one healthy blob,
|
||||
one healthy snapshot link, one healthy regular file — and no breakage yet.
|
||||
|
||||
@@ -25,7 +25,19 @@ def db(tmp_path, monkeypatch):
|
||||
_db.init_db()
|
||||
import services.mcp_bindings as mb
|
||||
importlib.reload(mb)
|
||||
return mb
|
||||
try:
|
||||
yield mb
|
||||
finally:
|
||||
# Mirror the `client` fixture below: importlib.reload mutates the
|
||||
# module objects IN PLACE, so without this teardown every later test
|
||||
# in a combined run — even one holding a collection-time reference —
|
||||
# keeps reading DB/voices paths bound to this test's dead tmp_path
|
||||
# (it broke backend/tests personas/audiobook in full-suite runs).
|
||||
# Restore the env first, then re-reload under the restored value.
|
||||
monkeypatch.undo()
|
||||
importlib.reload(_cfg)
|
||||
importlib.reload(_db)
|
||||
importlib.reload(mb)
|
||||
|
||||
|
||||
def test_upsert_creates_then_updates(db):
|
||||
@@ -169,9 +181,19 @@ def client(tmp_path, monkeypatch):
|
||||
# Reloading main above poisons the global module for any later test
|
||||
# that does `from main import …`. Reload once more with the default
|
||||
# (project) data dir restored so the shared module is clean again.
|
||||
# The api.*/services.* trees must be PURGED first, not merely left
|
||||
# cached: `import main` above (re)imported them under this test's
|
||||
# tmp_path env, and modules like api.routers.profiles keep
|
||||
# value-copies of core.config paths (`from core.config import
|
||||
# VOICES_DIR`) that an in-place reload of core.config alone cannot
|
||||
# heal — later personas/profiles requests then wrote voice files
|
||||
# into this test's dead tmp_path in combined full-suite runs.
|
||||
monkeypatch.undo()
|
||||
importlib.reload(importlib.import_module("core.config"))
|
||||
importlib.reload(importlib.import_module("core.db"))
|
||||
for m in list(sys.modules):
|
||||
if m in ("api", "services") or m.startswith(("api.", "services.")):
|
||||
sys.modules.pop(m, None)
|
||||
importlib.reload(_main)
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user