Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
772e3e82b4 | ||
|
|
228019c9a8 | ||
|
|
e38b5f5741 | ||
|
|
f60f5f1f59 | ||
|
|
7640f42dce | ||
|
|
3eeed0cfe9 | ||
|
|
b946fded12 | ||
|
|
7718a7a10b | ||
|
|
0687e13b57 | ||
|
|
89d585a36e | ||
|
|
3f5114923b | ||
|
|
43f1d46fe6 | ||
|
|
3223a20f88 | ||
|
|
2d37627ab2 | ||
|
|
54a88f694b | ||
|
|
3441201be0 | ||
|
|
de5d848189 | ||
|
|
aa7c2f5801 | ||
|
|
b7f14ce4ad | ||
|
|
7a928da1a0 | ||
|
|
0961a5e512 | ||
|
|
0619df8dff | ||
|
|
a91b27b518 | ||
|
|
605236566c | ||
|
|
918c400f29 | ||
|
|
76d16ac1bd | ||
|
|
f22606f3ad | ||
|
|
f8492dd676 | ||
|
|
f6afa43d07 | ||
|
|
835a889326 | ||
|
|
2f3888549b | ||
|
|
e9e4d95d06 | ||
|
|
2048d2793a | ||
|
|
52ce462396 | ||
|
|
e300739d78 | ||
|
|
7efae54cf8 | ||
|
|
0257bfcfec | ||
|
|
933c1a2cf1 | ||
|
|
c59a787a10 | ||
|
|
ed6d7a9652 | ||
|
|
3a1013527f | ||
|
|
243220fc3a | ||
|
|
d57b4babc5 | ||
|
|
c198a8349a | ||
|
|
c4f3ca457d | ||
|
|
e1e3a477a7 | ||
|
|
e20add344c | ||
|
|
0a07202634 | ||
|
|
3cf1007f28 | ||
|
|
1044483edf | ||
|
|
a04c972b71 | ||
|
|
8c1afe6d9d | ||
|
|
809314a459 | ||
|
|
2dcfd0bb55 | ||
|
|
93616a9c2a | ||
|
|
4072ec3db4 | ||
|
|
0548386cb3 | ||
|
|
45ec840ead | ||
|
|
fcc6e4a843 | ||
|
|
99a98eaefe | ||
|
|
f72439d6cf | ||
|
|
a355ad4ab6 | ||
|
|
daefad8769 | ||
|
|
afe013a6bc | ||
|
|
f0764532e2 | ||
|
|
d11d608c2d | ||
|
|
109199e024 | ||
|
|
9d133870e5 | ||
|
|
0d9a392e8d | ||
|
|
e6284a5d5f | ||
|
|
69567e2e56 | ||
|
|
8e98e7a1be | ||
|
|
b0785c4e6d | ||
|
|
5baf82bfb9 | ||
|
|
f5d33aad8c | ||
|
|
539309ea84 | ||
|
|
e450a37d4b | ||
|
|
77b66abd94 | ||
|
|
1a39061849 | ||
|
|
dd1aa3654d | ||
|
|
8430f9843c | ||
|
|
3299d5986b | ||
|
|
a53ddc35a8 | ||
|
|
62eddfad41 | ||
|
|
5462eeacaa | ||
|
|
9aa01d4e72 | ||
|
|
17d181e427 | ||
|
|
1762c57355 | ||
|
|
e3eda1af2c | ||
|
|
40b3c4f460 | ||
|
|
eed841a8ca | ||
|
|
49ab178db7 | ||
|
|
3482399197 | ||
|
|
df4d016a7d | ||
|
|
128b07c923 | ||
|
|
fd6d21401b | ||
|
|
c9adcb2647 | ||
|
|
e6d3103ba8 | ||
|
|
1e6de9155b | ||
|
|
a41dc8bcac | ||
|
|
a8c5ce5c31 | ||
|
|
e0e19f3dc9 | ||
|
|
b73f31b237 | ||
|
|
6bcd3429ac | ||
|
|
81b6bbc4d3 | ||
|
|
def15b8423 | ||
|
|
4df7d4e97e | ||
|
|
42b63488e9 | ||
|
|
ee3e87c0a7 | ||
|
|
b8f1d7f19d | ||
|
|
155b9345b9 | ||
|
|
37c5df6f3a | ||
|
|
3be001f3fd | ||
|
|
28c7bacefb | ||
|
|
fbb258d2e2 | ||
|
|
6837ba25ac | ||
|
|
366b55d9d1 | ||
|
|
be007e9d77 | ||
|
|
030bc47515 | ||
|
|
31d4db65e8 | ||
|
|
fd30a6c4ad | ||
|
|
dcaed7cbf4 | ||
|
|
9615cd5294 | ||
|
|
e4c1ef0de6 | ||
|
|
5229a9504c | ||
|
|
214a859344 | ||
|
|
b72436a4e5 | ||
|
|
c2955dbe92 | ||
|
|
a6f008ec38 |
@@ -0,0 +1,127 @@
|
||||
# Installer smoke — runs scripts/install.sh / scripts/install.ps1 end-to-end
|
||||
# on all three desktop platforms so the one-liner installers can't rot.
|
||||
#
|
||||
# Gated by `paths` because a cold run downloads multi-GB wheels (torch) and
|
||||
# takes ~15-30 min per OS; it only needs to fire when an installer or this
|
||||
# workflow changes. The heavy Tauri bundles stay in release.yml (tag push).
|
||||
|
||||
name: Install smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "scripts/install.sh"
|
||||
- "scripts/install.ps1"
|
||||
- ".github/workflows/install-smoke.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/install.sh"
|
||||
- "scripts/install.ps1"
|
||||
- ".github/workflows/install-smoke.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
install:
|
||||
name: Install (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Running `sh scripts/install.sh` from the repo root exercises the
|
||||
# repo-root resolution (script dir is scripts/, project root one level
|
||||
# up) — the exact bug that made a local run clone a duplicate repo.
|
||||
# Binary mode is the default: prebuilt release asset, checksum verified.
|
||||
- name: Run installer — binary (macOS/Linux)
|
||||
if: runner.os != 'Windows'
|
||||
run: sh scripts/install.sh
|
||||
|
||||
- name: Verify install — binary (macOS/Linux)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
test -d "/Applications/VoiceStudio.app" || { echo "::error::VoiceStudio.app missing from /Applications"; exit 1; }
|
||||
echo "✓ VoiceStudio.app installed in /Applications"
|
||||
else
|
||||
test -x "$HOME/.local/bin/VoiceStudio" || { echo "::error::AppImage missing from ~/.local/bin"; exit 1; }
|
||||
"$HOME/.local/bin/VoiceStudio" --appimage-help >/dev/null 2>&1 || true
|
||||
echo "✓ AppImage installed and executable"
|
||||
fi
|
||||
|
||||
# Source mode stays covered end-to-end behind --source.
|
||||
- name: Run installer — source (macOS/Linux)
|
||||
if: runner.os != 'Windows'
|
||||
run: sh scripts/install.sh --source
|
||||
|
||||
- name: Verify install — source (macOS/Linux)
|
||||
if: runner.os != 'Windows'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
test -d .venv || { echo "::error::.venv missing"; exit 1; }
|
||||
test -f frontend/dist/index.html || { echo "::error::frontend build missing"; exit 1; }
|
||||
echo "✓ venv + frontend bundle present"
|
||||
|
||||
# Binary mode is the default; CI runs msiexec silently.
|
||||
- name: Run installer — binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
CI: true
|
||||
shell: pwsh
|
||||
run: '& { $ErrorActionPreference = "Stop"; & "${{ github.workspace }}\scripts\install.ps1" }'
|
||||
|
||||
- name: Verify install — binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$paths = @(
|
||||
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
$key = Get-ItemProperty $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -match "VoiceStudio|OmniVoice" } |
|
||||
Select-Object -First 1
|
||||
if (-not $key) {
|
||||
Get-ItemProperty $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object DisplayName | ForEach-Object { Write-Host " installed: $($_.DisplayName)" }
|
||||
Write-Host "::error::MSI product not registered"; exit 1
|
||||
}
|
||||
Write-Host "✓ MSI product registered: $($key.DisplayName)"
|
||||
|
||||
# Source mode stays covered end-to-end behind -Source.
|
||||
- name: Run installer — source (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
VOICESTUDIO_INSTALL_MODE: source
|
||||
shell: pwsh
|
||||
run: '& { $ErrorActionPreference = "Stop"; & "${{ github.workspace }}\scripts\install.ps1" }'
|
||||
|
||||
- name: Verify install — source (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path .venv)) { Write-Host "::error::.venv missing"; exit 1 }
|
||||
if (-not (Test-Path frontend\dist\index.html)) { Write-Host "::error::frontend build missing"; exit 1 }
|
||||
Write-Host "✓ venv + frontend bundle present"
|
||||
|
||||
- name: Upload install log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: install-log-${{ matrix.os }}
|
||||
path: |
|
||||
/Users/runner/Library/Application Support/OmniVoice/*.log
|
||||
/home/runner/.local/share/VoiceStudio/*.log
|
||||
${{ runner.temp }}/VoiceStudio/**/*.log
|
||||
if-no-files-found: ignore
|
||||
+40
-3
@@ -10,18 +10,29 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
**Highlights**
|
||||
|
||||
- The backend now answers within a second of launch and narrates its startup step by step
|
||||
- Reporting a bug from an outdated build now offers the latest release first
|
||||
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves
|
||||
- Dictation now stays bound to the app where it started and recovers locally from silent recognizer output (#1175)
|
||||
- The backend now answers within a second of launch and narrates its startup step by step (#1550)
|
||||
- Reporting a bug from an outdated build now offers the latest release first (#1547)
|
||||
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves (#1548)
|
||||
- Invisible watermarking no longer stalls — or silently skips — the first take of a session (#1615)
|
||||
- Dub subtitles can be retimed, inserted, and merged in either direction from the segment table (#1612) — thanks @invio-a11y!
|
||||
|
||||
### Changed
|
||||
- Dictation now carries one native output session from shortcut-down through final delivery, restores text, HTML, image, or file-list clipboards only when untouched, keeps Wayland copy-safe unless current-focus insertion is explicitly enabled, and retries silent Sherpa speech only through an already-installed local ASR model (#1175)
|
||||
- The backend binds its port immediately and reports startup progress live — `/health` answers 503-with-step and a new `/startup/progress` endpoint lists every step while PyTorch, API routes, and database migrations load in the background, so "starting at step X" is never mistakable for "dead"; the desktop splash narrates each step (#1550)
|
||||
|
||||
### Added
|
||||
- One-command install on every desktop OS: `curl -fsSL https://voicestudio.sh/install | sh` (macOS/Linux/WSL) or `irm https://voicestudio.sh/install | iex` (Windows) — the URL serves the right script per platform, and Windows gains a source installer (`scripts/install.ps1`) with a 3-OS CI smoke (#1626)
|
||||
- Per-line subtitle management in the dub table: a line's end time is editable alongside its start (typing a time and dragging its timeline edge now take the same path), lines merge with the previous row as well as the next (`Ctrl/Cmd+Shift+M`), and a new line can be inserted into the gap after any row (#1612) — thanks @invio-a11y!
|
||||
- CI now enforces performance regression budgets on the hot paths — operation-count tests pin streaming TTS to one synthesis per sentence and cached dub re-mixes to zero re-synthesis; fast-path guards cover zero re-decoding and ⌈N/W⌉ native batch calls when enabled (#1594)
|
||||
- Default-engine dubbing now synthesizes several segments per forward pass instead of one call per line — the width follows the host's device headroom (1 on CPU and low-VRAM cards, up to 8), `OMNIVOICE_DUB_BATCH_WIDTH` overrides it, and engines without native batching keep the single-segment path (#1594)
|
||||
- `/ws/tts` now reports real time-to-first-audio, and its RTF measures synthesis alone so a slow client can't inflate it (#1594)
|
||||
- The locally cached AudioSeal watermark generator warms on a background thread ~35s after boot (`OMNIVOICE_PRELOAD_WATERMARK=0` opts out; explicitly setting `=1` may download it), so the first synthesis no longer serializes the audioseal import + model load inline — measured at ~42s on a cold filesystem, 3s short of a 90s client timeout (#1576) — thanks @paoloantinori!
|
||||
- Voices you've cloned stay "warm" across restarts — encoded references now persist to disk (~10 KB each), so the first generation of a session skips the re-encode and any transcription pass; `OMNIVOICE_PROMPT_DISK_CACHE=0` opts out (#1565)
|
||||
- Optional FlashInfer acceleration for the default engine on CUDA (`OMNIVOICE_FLASHINFER=1`, ~2.2x measured) — needs the optional `flashinfer-python` package; missing package or kernel failure logs why and falls back to the standard path (#1565)
|
||||
- The bug reporter notices when you're on an outdated build and offers the latest release before filing — with a "File anyway" escape hatch — and stamps a `Build status` line into every report so up-to-date reports are tellable from stale ones (#1547)
|
||||
- Settings → Performance & Device gains a compute-device override (Auto / CUDA / ROCm / XPU / MPS / CPU, or `OMNIVOICE_DEVICE`) — pin the device when auto-detect picks wrong; only devices your machine actually has are offered (#1557)
|
||||
- Opt-in 24-layer PocketTTS checkpoints via `OMNIVOICE_POCKETTTS_24L` — better prosody for it/de/es/pt at roughly 2x render time (still faster than real-time); the fast 6-layer model stays the default (#1613) — thanks @paoloantinori!
|
||||
|
||||
### Docs
|
||||
- The Docker Hub overview now shows the current engine-switching demo, Model Catalogue, and gallery voice workflow (#1593)
|
||||
@@ -31,6 +42,30 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- The OmniVoice guide now covers combining style attributes with a reference clip (consistent instruct stabilizes cloning; the reference wins conflicts), inline pronunciation control (pinyin / CMU phonemes), and corrects the claim that the default engine can't do voice design — it can, from attributes (#1565)
|
||||
|
||||
### Fixed
|
||||
- Moving words across a speaker boundary in a dub — merging two lines and splitting them again — no longer dubs the second half in the first speaker's voice; each half now keeps the speaker, voice, direction, gain, and language of whoever actually says it (#1612) — thanks @invio-a11y!
|
||||
- Dictation on a WebView that refuses a 16 kHz audio context (WKWebView) now low-passes before downsampling, so frequencies above 8 kHz stop folding into the speech the recognizer is fed (#1610)
|
||||
- A microphone context that cannot be resumed now reports a mic error instead of leaving the dictation pill on "Listening" while capturing nothing (#1610)
|
||||
- Dictation no longer retains a whole session's audio for silent-model recovery — an open mic grew that buffer by ~115 MB an hour; the recent two minutes are kept instead (#1610)
|
||||
- The clipboard-delivery status is now translated in all 21 languages, so Wayland users — where clipboard delivery is the default — no longer see an English string (#1610)
|
||||
- A native sherpa-onnx load failure of any exception type now degrades to "engine unavailable" instead of taking the dictation WebSocket down (#1610)
|
||||
- Dictation now ships Whisper Tiny as its one cross-platform default, avoiding Parakeet's measured empty decoding on Windows while keeping Parakeet selectable behind runtime fallback (#1175)
|
||||
- Re-mixing a dub no longer decodes, rewrites, and re-reads every cached segment — same-rate cached audio is reused directly (and rejected if truncated), switching timing modes can't reuse slot-truncated audio as natural-rate, and RVC respects natural-rate modes (#1594)
|
||||
- PocketTTS French works again — pocket-tts only ships a 24-layer French model and rejected the name the sidecar asked for, so every French request failed at model load; French now always loads `french_24l` (#1613) — thanks @paoloantinori!
|
||||
- Installing IndexTTS 2.5 no longer fails claiming an interrupted download — the weights repo ships `config.yaml` and VoiceStudio demanded a `config_v2_5.yaml` that exists in no upstream release; both names are accepted, so a hand-renamed checkout keeps working (#1611) — thanks @zuiaiyutu!
|
||||
- IndexTTS 2.5 no longer has long-text generation killed at 60 seconds — the sidecar now proves it is alive every 5 seconds while `infer()` runs, and its deadline rises to 900s (`OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S`) (#1611) — thanks @zuiaiyutu!
|
||||
- The OpenAI-compatible `/v1/audio/speech` route now reuses the shared cached engine for explicit `model` ids instead of constructing a fresh engine — and its sidecar/model load, a ~28s floor per call for subprocess engines — on every request, with the same single-engine-resident discipline `/generate` applies (#1614) — thanks @paoloantinori!
|
||||
- The setup wizard's RAM check no longer blocks 8 GB machines whose OS reports ~7.8 GB usable — the thresholds now tolerate reserved memory, and `OMNIVOICE_RAM_PREFLIGHT=0` turns a genuine block into a warning for those who accept the OOM risk (#1618)
|
||||
- Invisible watermarking now runs eagerly instead of through `torch.compile` — AudioSeal's lazy compile sent the first embed of every session into Inductor's C++ codegen, which failed outright on macOS hosts whose toolchain couldn't serve it and shipped the audio unmarked after a 30-40s wait; first embed drops from 9.70s to 0.26s (#1615) — thanks @paoloantinori!
|
||||
- The macOS Accessibility blocker now rechecks while visible and closes as soon as the grant is enabled instead of keeping a stale permission prompt on screen (#1609)
|
||||
- The dubbing editor's video and transcript columns can now be resized by pointer or keyboard, and the chosen split persists across launches (#1571) — thanks @invio-a11y!
|
||||
- CPU-only synthesis now gets a bounded ten-minute execution budget, and a render that exhausts it is reported as a compute timeout instead of misleading "generation capacity is busy" queue pressure (#1588) — thanks @ChienNguyen1111!
|
||||
- Rapid Launchpad ↔ Dub navigation now replaces the workspace DOM owner cleanly, so late media/waveform cleanup cannot trigger React's `insertBefore` crash (#1590) — thanks @nicolas-jacques!
|
||||
- Watermark embedding failures now log the full traceback instead of just the exception message, so a silently-unmarked-audio incident (audio passes through unmarked by design) is diagnosable from the log alone (#1576) — thanks @paoloantinori!
|
||||
- Dubbing now recovers rapid two-speaker exchanges when diarization collapses them, defaults new projects to lip sync without overwriting saved timing choices, and keeps the editor usable on narrow screens (#1584) — thanks @victordonat0!
|
||||
- `OMNIVOICE_ASR_BACKEND=omnivoice` now selects the PyTorch-native Whisper path, so the documented ROCm escape hatch no longer fails as an unknown engine (#1582) — thanks @patmansk!
|
||||
- Network Sharing from Windows MSI/portable installs now serves the bundled web interface to LAN devices instead of redirecting them to their own `localhost` (#1589) — thanks @TWIISTED-STUDIOS!
|
||||
- Exported dubbed videos now mark the dubbed language as the default audio stream while keeping Original available as an explicit choice (#1575) — thanks @invio-a11y!
|
||||
- Cloning references can no longer exhaust system memory: transcript-free clips up to 75 seconds are searched in five bounded passages, longer clips ask to be trimmed, and supplied transcripts remain capped at 20 seconds to preserve alignment (#1578) — thanks @ACKAPOB!
|
||||
- Stored artifact subpaths now resolve after moving a data directory between Windows, macOS, Linux, and Docker, while traversal and symlink escapes remain blocked (#1559) — thanks @Eman-Yousaf!
|
||||
- A remote browser hitting an API-key-configured server's admin 403 now gets the API-key login form instead of endless console 403s, while desktop and PIN-only/no-key servers keep the plain loopback error so guests are never offered a login no key can satisfy (#1568) — thanks @paoloantinori!
|
||||
- The crash-isolated ASR sidecar and its download preflight now agree on which model to load — setting the shared faster-whisper model variable applies to both variants instead of the sidecar quietly using a different one (#1556)
|
||||
@@ -38,6 +73,8 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Supervisor restarts after repeat crashes now back off (immediate, then 5s, then 15s) instead of respawning back-to-back, so a tight crash loop can't burn the whole restart budget in seconds (#1548)
|
||||
- The Linux desktop cleanup regression test now isolates build artifacts, so an existing developer build can no longer change its result (#1566)
|
||||
|
||||
- Renaming, deleting, or revoking consent on a voice (and starring/clearing history, recording exports) now live-updates every open tab again — the sync routes' WebSocket events were silently dropped, which could look like "all my voices are gone" (#1561) — thanks @paoloantinori!
|
||||
|
||||
### CI
|
||||
- Project agents now share pinned Vite and FastAPI skills from skills.sh (#1594)
|
||||
- Weekly full-history secret scans no longer mistake the Ed25519 private-key type name for committed key material (#1591)
|
||||
|
||||
@@ -103,7 +103,7 @@ Use `bun run dev` for the browser UI. See [Contributing](.github/CONTRIBUTING.md
|
||||
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions |
|
||||
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video |
|
||||
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
|
||||
| **Dictation Widget** | System-wide shortcut, live transcription, optional local-LLM cleanup |
|
||||
| **[Dictation Widget](docs/features/dictation.md)** | System-wide shortcut, live transcription, optional local-LLM cleanup |
|
||||
| **Vocal Isolation** | Demucs speech/background separation |
|
||||
| **Speaker Diarization** | Pyannote and WhisperX speaker assignment |
|
||||
| **Batch Queue** | Queue large sets of audio and video jobs with per-job progress |
|
||||
|
||||
@@ -330,7 +330,7 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
# GPU pool and brick the backend (#730 class). Budget comes from the shared
|
||||
# length-scaled helper (#1190) instead of the flat 300s default.
|
||||
from services.model_manager import generate_timeout_s
|
||||
_budget = generate_timeout_s(text)
|
||||
_budget = generate_timeout_s(text, engine=model)
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
lambda: _infer(_PREVIEW_SEED), what="Archetype preview generate",
|
||||
timeout=_budget)
|
||||
@@ -357,15 +357,11 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
# Runs on the dedicated watermark pool (#1190): AudioSeal embedding is CPU
|
||||
# work that holds no VRAM, so it must not occupy a GPU worker ahead of the
|
||||
# next generate on 1-worker hosts.
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
import functools
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
functools.partial(mark_synthetic, audio_tensor, model.sampling_rate,
|
||||
context="archetypes.render"),
|
||||
what="Archetype watermark",
|
||||
from services.watermark import mark_synthetic_async
|
||||
audio_tensor = await mark_synthetic_async(
|
||||
audio_tensor, model.sampling_rate,
|
||||
context="archetypes.render",
|
||||
timeout=generate_timeout_s(""),
|
||||
executor=get_watermark_pool(),
|
||||
)
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
+188
-13
@@ -103,6 +103,75 @@ def _set_progress(job, stage, percent=0, **extra):
|
||||
job["progress"] = {"stage": stage, "percent": percent, **extra}
|
||||
|
||||
|
||||
#: Override for the native dub batch width. Set to 1 to disable batching.
|
||||
BATCH_WIDTH_ENV = "OMNIVOICE_DUB_BATCH_WIDTH"
|
||||
|
||||
#: Hard ceiling on the override — a batch this wide is already amortizing
|
||||
#: almost all of the per-call setup, and beyond it the failure mode is an OOM
|
||||
#: that costs more than the saving.
|
||||
_MAX_BATCH_WIDTH = 16
|
||||
|
||||
|
||||
def _native_batch_width(backend) -> int:
|
||||
"""How many segments to render in one native batch on THIS host.
|
||||
|
||||
A native batch widens the forward pass, so the width cannot be a constant.
|
||||
The default engine declares ``min_vram_gb = 6.0`` for a SINGLE job; an
|
||||
unconditional 8-wide batch would OOM the 4-8 GB CUDA cards and the MPS
|
||||
Macs where the per-segment path succeeds today — turning a throughput
|
||||
optimization into a regression on exactly the hardware that already
|
||||
struggles (#1616 is a 4 GB card reporting capacity failures). Default
|
||||
behaviour must not get riskier on a host, so the width is derived from
|
||||
measured headroom and falls back to 1 (no batching) when unknown.
|
||||
|
||||
CPU hosts get 1: batching there buys no kernel amortization and only
|
||||
multiplies peak RAM.
|
||||
"""
|
||||
override = os.environ.get(BATCH_WIDTH_ENV, "").strip()
|
||||
if override:
|
||||
try:
|
||||
return max(1, min(_MAX_BATCH_WIDTH, int(override)))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"%s=%r is not an integer — deriving the batch width from the host instead.",
|
||||
BATCH_WIDTH_ENV, override,
|
||||
)
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
caps = detect_host_caps()
|
||||
except Exception: # noqa: BLE001 — an unprobeable host takes the safe path
|
||||
return 1
|
||||
if caps.family == "cpu" or not caps.vram_gb:
|
||||
return 1
|
||||
headroom = caps.vram_gb - float(getattr(backend, "min_vram_gb", 0.0) or 0.0)
|
||||
if headroom < 2.0:
|
||||
return 1
|
||||
if headroom < 6.0:
|
||||
return 2
|
||||
if headroom < 12.0:
|
||||
return 4
|
||||
return 8
|
||||
|
||||
|
||||
def _batch_timeout_s(texts: list[str], backend) -> float:
|
||||
"""Execution budget for one native batch.
|
||||
|
||||
Not the sum of the per-item budgets: ``generate_timeout_s`` returns a
|
||||
floor (300s GPU / 600s CPU) plus per-length overage, so summing it across
|
||||
eight items yields a ~2400s budget — and a wedged batch would hold a
|
||||
GPU-pool worker for forty minutes before the reset this file depends on
|
||||
(#730). One floor covers wedge detection for the whole call; only the
|
||||
length-driven overage is genuinely additive.
|
||||
"""
|
||||
from services.model_manager import generate_timeout_s
|
||||
|
||||
floor = generate_timeout_s("", engine=backend)
|
||||
overage = sum(
|
||||
max(0.0, generate_timeout_s(text, engine=backend) - floor) for text in texts
|
||||
)
|
||||
return floor + overage
|
||||
|
||||
|
||||
async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
"""Full batch dub pipeline: extract → transcribe → translate → generate → mix → export."""
|
||||
import subprocess
|
||||
@@ -279,6 +348,111 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
full_audio = torch.zeros(1, total_samples)
|
||||
total_segs = len(translated_segments)
|
||||
|
||||
# Native engines can amortize encoder/decoder setup across a small
|
||||
# batch. Keep the adapter seam optional: engines without a real batch
|
||||
# implementation inherit TTSBackend.generate_batch(), which preserves
|
||||
# the established one-segment behavior below.
|
||||
from services.tts_backend import TTSBackend
|
||||
batched_audio: dict[int, torch.Tensor] = {}
|
||||
has_native_batch = type(backend).generate_batch is not TTSBackend.generate_batch
|
||||
if has_native_batch:
|
||||
from services.text_normalization import normalize_for_tts
|
||||
|
||||
batch_ref_audio = None
|
||||
batch_ref_text = None
|
||||
if job.get("voice_id"):
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR as _VD
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?",
|
||||
(job["voice_id"],),
|
||||
).fetchone()
|
||||
if row:
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
batch_ref_audio = os.path.join(_VD, row["locked_audio_path"])
|
||||
elif row["ref_audio_path"]:
|
||||
batch_ref_audio = os.path.join(_VD, row["ref_audio_path"])
|
||||
batch_ref_text = row["ref_text"]
|
||||
|
||||
batch_width = _native_batch_width(backend)
|
||||
|
||||
async def _prefetch_batch(first_index: int) -> None:
|
||||
"""Render the batch beginning at ``first_index`` into
|
||||
``batched_audio``.
|
||||
|
||||
Rendered on demand rather than prerendering the whole track:
|
||||
the tensors are popped as they are placed, so peak host memory
|
||||
is one batch instead of every segment of the language — and
|
||||
the progress bar tracks placement instead of running to the
|
||||
end and restarting at segment 1.
|
||||
"""
|
||||
if job["status"] == "cancelled":
|
||||
return
|
||||
batch_rows = []
|
||||
index = first_index
|
||||
while index < total_segs and len(batch_rows) < batch_width:
|
||||
seg = translated_segments[index]
|
||||
if (seg.get("end", 0) - seg.get("start", 0) > 0.05
|
||||
and seg.get("text", "").strip()):
|
||||
batch_rows.append((index, seg))
|
||||
index += 1
|
||||
if len(batch_rows) < 2:
|
||||
return # nothing to amortize — the per-segment path is equal
|
||||
batch_indices = [index for index, _ in batch_rows]
|
||||
batch_texts = [
|
||||
normalize_for_tts(row.get("text", "").strip(), target_lang)
|
||||
for _, row in batch_rows
|
||||
]
|
||||
batch_durations = [
|
||||
row.get("end", 0) - row.get("start", 0)
|
||||
for _, row in batch_rows
|
||||
]
|
||||
|
||||
def _render_native_batch():
|
||||
generated = backend.generate_batch(
|
||||
batch_texts,
|
||||
language=target_lang,
|
||||
ref_audio=batch_ref_audio,
|
||||
ref_text=batch_ref_text,
|
||||
duration=batch_durations,
|
||||
num_step=16,
|
||||
guidance_scale=2.0,
|
||||
speed=1.0,
|
||||
denoise=True,
|
||||
postprocess_output=True,
|
||||
)
|
||||
if len(generated) != len(batch_indices):
|
||||
raise RuntimeError(
|
||||
f"native batch returned {len(generated)} outputs for "
|
||||
f"{len(batch_indices)} segments"
|
||||
)
|
||||
rendered = []
|
||||
for audio_out in generated:
|
||||
if not getattr(backend, "applies_own_mastering", False):
|
||||
audio_out = apply_mastering(audio_out, sample_rate=sr)
|
||||
rendered.append(normalize_audio(audio_out, target_dBFS=-2.0))
|
||||
return rendered
|
||||
|
||||
try:
|
||||
rendered = await run_on_gpu_pool_guarded(
|
||||
_render_native_batch,
|
||||
what="Batch generate",
|
||||
timeout=_batch_timeout_s(batch_texts, backend),
|
||||
)
|
||||
batched_audio.update(zip(batch_indices, rendered))
|
||||
except TimeoutError:
|
||||
# Do not immediately queue the same expensive work again:
|
||||
# the timed-out pool task may still be holding the device.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Native TTS batch failed for segments %s-%s; falling back per segment: %s",
|
||||
batch_indices[0] + 1,
|
||||
batch_indices[-1] + 1,
|
||||
e,
|
||||
)
|
||||
|
||||
for i, seg in enumerate(translated_segments):
|
||||
if job["status"] == "cancelled":
|
||||
return
|
||||
@@ -356,10 +530,15 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
# Budget is the shared length-scaled one (#1190): a long segment
|
||||
# on CPU-class hardware no longer dies on the flat 300s.
|
||||
from services.model_manager import generate_timeout_s
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
_gen, what="Batch generate",
|
||||
timeout=generate_timeout_s(seg_text),
|
||||
)
|
||||
if has_native_batch and i not in batched_audio:
|
||||
await _prefetch_batch(i)
|
||||
if i in batched_audio:
|
||||
audio_tensor = batched_audio.pop(i)
|
||||
else:
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
_gen, what="Batch generate",
|
||||
timeout=generate_timeout_s(seg_text, engine=backend),
|
||||
)
|
||||
|
||||
# Fit to slot
|
||||
target_samples_seg = int(seg_duration * sr)
|
||||
@@ -413,19 +592,15 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
# unmarked while the interactive dub pipeline marked every segment.
|
||||
# One whole-track embed (chunked internally, #1045) is equivalent to
|
||||
# dub_generate's per-segment marks: the 16-bit message repeats
|
||||
# throughout. Runs in the GPU pool like generate's finalize; never
|
||||
# raises (degrades to unmarked on failure, same as every producer).
|
||||
# throughout. Never raises (degrades to unmarked on failure, same as
|
||||
# every producer).
|
||||
# Dispatched to the dedicated watermark pool, not the GPU pool (#1190):
|
||||
# AudioSeal embedding is CPU work that holds no VRAM, and a whole-track
|
||||
# embed is long enough that occupying a GPU worker with it stalled the
|
||||
# next language's segments on 1-worker hosts.
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
import functools
|
||||
full_audio = await loop.run_in_executor(
|
||||
get_watermark_pool(),
|
||||
functools.partial(mark_synthetic, full_audio, sr,
|
||||
context="batch.dub_track"),
|
||||
from services.watermark import mark_synthetic_async
|
||||
full_audio = await mark_synthetic_async(
|
||||
full_audio, sr, context="batch.dub_track",
|
||||
)
|
||||
|
||||
# Same assembly pattern as dub_generate.py:390 — `full_audio` is a
|
||||
|
||||
@@ -27,6 +27,10 @@ Protocol:
|
||||
"detail": "..."} — error ("detail"
|
||||
kept for legacy)
|
||||
|
||||
Sherpa ``final`` frames additionally carry
|
||||
``"final_kind": "utterance"|"summary"``. Utterances are mid-session
|
||||
commits; the summary is the authoritative whole-session result at EOF.
|
||||
|
||||
Every ``final`` text is normalised by services.text_polish (leading
|
||||
capital for Latin scripts, terminal punctuation, single-spaced) so the
|
||||
pasted result reads like typed text. Partials are raw.
|
||||
@@ -35,6 +39,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
@@ -70,17 +75,46 @@ _AEC_NEAR = 0x00 # microphone frame (clean it, then buffer for ASR)
|
||||
_AEC_FAR = 0x01 # playback reference frame (feed the echo model only)
|
||||
|
||||
|
||||
def _requested_pcm_sample_rate(query_params) -> int | None:
|
||||
"""Return a bounded PCM rate for ``?pcm=1``/``?aec=1`` sessions."""
|
||||
raw_pcm = query_params.get("pcm") in ("1", "true", "on")
|
||||
aec = query_params.get("aec") in ("1", "true", "on")
|
||||
if not raw_pcm and not aec:
|
||||
return None
|
||||
# Client-supplied ``?sr=`` values outside the range real capture devices use
|
||||
# are replaced with 16 kHz. The rate sizes server-side state — RecoveryTail
|
||||
# multiplies it by RECOVERY_TAIL_SECONDS to compute its byte ceiling — so an
|
||||
# absurd rate must never be believed: it would re-open the unbounded-memory
|
||||
# path the recovery-tail cap closed.
|
||||
SR_MIN, SR_MAX = 8000, 96000
|
||||
|
||||
|
||||
def _bounded_sample_rate(query_params) -> int:
|
||||
try:
|
||||
sample_rate = int(query_params.get("sr", "16000"))
|
||||
except (TypeError, ValueError):
|
||||
return 16000
|
||||
return sample_rate if 8000 <= sample_rate <= 96000 else 16000
|
||||
return sample_rate if SR_MIN <= sample_rate <= SR_MAX else 16000
|
||||
|
||||
|
||||
def _requested_pcm_sample_rate(query_params) -> int | None:
|
||||
"""Return the bounded rate when the client transport is raw PCM.
|
||||
|
||||
Sherpa clients omit ``pcm=1`` because the selected model already defines
|
||||
that transport. If the model is demoted or its runtime is unavailable, the
|
||||
legacy recognizer fallback must still decode those same bytes as PCM.
|
||||
"""
|
||||
raw_pcm = query_params.get("pcm") in ("1", "true", "on")
|
||||
aec = query_params.get("aec") in ("1", "true", "on")
|
||||
sherpa_pcm = False
|
||||
requested_model = query_params.get("model")
|
||||
if requested_model:
|
||||
try:
|
||||
from services.sherpa_dictation import is_sherpa_model
|
||||
sherpa_pcm = is_sherpa_model(requested_model)
|
||||
except Exception: # noqa: BLE001
|
||||
# A broken sherpa install must not decide the framing question —
|
||||
# sherpa_pcm stays False and the session negotiates the
|
||||
# MediaRecorder path; availability is re-probed (and reported)
|
||||
# when the model is actually selected.
|
||||
sherpa_pcm = False
|
||||
if not raw_pcm and not aec and not sherpa_pcm:
|
||||
return None
|
||||
return _bounded_sample_rate(query_params)
|
||||
|
||||
|
||||
def _demux_aec_frame(data: bytes) -> tuple[str, bytes]:
|
||||
@@ -137,16 +171,27 @@ def _select_sherpa_spec(websocket: WebSocket):
|
||||
from services import sherpa_dictation as sd
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _usable_spec(model_id):
|
||||
spec = sd.get_spec(model_id)
|
||||
if spec is not None and sd.is_demoted(spec.id):
|
||||
logger.warning(
|
||||
"dictation model %s is demoted — using the capture ASR fallback",
|
||||
spec.id,
|
||||
)
|
||||
return None
|
||||
return spec
|
||||
|
||||
requested = websocket.query_params.get("model")
|
||||
if requested:
|
||||
return sd.get_spec(requested) # explicit selection (may be None if bad)
|
||||
return _usable_spec(requested) # explicit selection (may be unavailable)
|
||||
# Fall back to the persisted dictation pref.
|
||||
try:
|
||||
from services.asr_backend import dictation_model_id
|
||||
mid = dictation_model_id()
|
||||
except Exception:
|
||||
mid = None
|
||||
return sd.get_spec(mid) if mid else None
|
||||
return _usable_spec(mid) if mid else None
|
||||
|
||||
|
||||
@router.websocket("/ws/transcribe")
|
||||
@@ -422,6 +467,64 @@ SHERPA_OFFLINE_SILENCE_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_SILENC
|
||||
SHERPA_OFFLINE_RMS_FLOOR = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_RMS", "0.01"))
|
||||
|
||||
|
||||
#: Seconds of audio retained for silent-model recovery. Recovery only needs
|
||||
#: enough speech to prove the model is broken and to re-transcribe what was
|
||||
#: said; retaining the whole session grew ~115 MB/hour at 16 kHz on an open
|
||||
#: mic, unbounded, and only ever got read when the fallback fired.
|
||||
RECOVERY_TAIL_DEFAULT_SECONDS = 120.0
|
||||
RECOVERY_TAIL_MAX_SECONDS = 300.0
|
||||
|
||||
|
||||
def _bounded_recovery_tail_seconds(value: str | None) -> float:
|
||||
"""Parse the recovery tail override without allowing unbounded buffers."""
|
||||
try:
|
||||
seconds = float(value) if value is not None else RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
except (TypeError, ValueError):
|
||||
return RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
if not math.isfinite(seconds) or seconds <= 0:
|
||||
return RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
return min(seconds, RECOVERY_TAIL_MAX_SECONDS)
|
||||
|
||||
|
||||
RECOVERY_TAIL_SECONDS = _bounded_recovery_tail_seconds(
|
||||
os.environ.get("OMNIVOICE_DICTATION_RECOVERY_TAIL_S")
|
||||
)
|
||||
|
||||
|
||||
class RecoveryTail:
|
||||
"""The most recent ``RECOVERY_TAIL_SECONDS`` of session audio.
|
||||
|
||||
Keeps the *tail* rather than the head: a long dictation's useful speech is
|
||||
what the user just said, and the silent-model check cares about how much
|
||||
audio the session carried overall — which ``total_bytes`` still reports
|
||||
truthfully after trimming.
|
||||
"""
|
||||
|
||||
__slots__ = ("_buf", "_max", "total_bytes")
|
||||
|
||||
def __init__(self, sample_rate: int, seconds: float = RECOVERY_TAIL_SECONDS):
|
||||
# int16 mono → 2 bytes/sample. Floor of one frame so a nonsense rate
|
||||
# or seconds value can't produce a zero-length buffer.
|
||||
self._max = max(2, int(seconds * max(1, sample_rate)) * 2)
|
||||
self._buf = bytearray()
|
||||
self.total_bytes = 0
|
||||
|
||||
def extend(self, pcm: bytes) -> None:
|
||||
self._buf.extend(pcm)
|
||||
self.total_bytes += len(pcm)
|
||||
excess = len(self._buf) - self._max
|
||||
if excess > 0:
|
||||
# int16 mono: trim whole samples only. A split frame can carry an
|
||||
# odd byte count, and an odd trim would leave the tail starting
|
||||
# mid-sample — every later sample byte-shifted, and the recovery
|
||||
# transcription fed noise.
|
||||
excess += excess % 2
|
||||
del self._buf[:excess]
|
||||
|
||||
def tail(self) -> bytes:
|
||||
return bytes(self._buf)
|
||||
|
||||
|
||||
def is_model_silent(text: str, heard_speech: bool, pcm_bytes: int) -> bool:
|
||||
"""True when the dictation model produced NO text despite real speech.
|
||||
|
||||
@@ -448,19 +551,74 @@ def _pcm16_to_f32(pcm: bytes):
|
||||
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
async def _sherpa_session(websocket: WebSocket):
|
||||
"""Shared WS receive setup for the sherpa handlers.
|
||||
def _pcm16_rms(pcm: bytes) -> float:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
return 0.0
|
||||
return float((samples * samples).mean() ** 0.5)
|
||||
|
||||
Returns ``(get_frame, state)`` where ``get_frame`` is an async callable
|
||||
that yields the next near-end (mic) PCM bytes, ``b""`` for a keepalive/ref
|
||||
frame, or ``None`` on EOF/disconnect. ``state`` carries sample rate, AEC,
|
||||
and the disconnect flag for the caller's finaliser.
|
||||
"""
|
||||
pcm_sr = 16000
|
||||
|
||||
async def _recover_silent_sherpa(
|
||||
spec, pcm: bytes, pcm_sr: int,
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""Retry a token-silent Sherpa session through an installed local ASR."""
|
||||
logger.warning(
|
||||
"dictation model %s decoded NOTHING from %.1fs of speech-level audio "
|
||||
"— falling back to the capture ASR engine for this session",
|
||||
spec.id, len(pcm) / float(max(1, pcm_sr) * 2),
|
||||
)
|
||||
try:
|
||||
pcm_sr = int(websocket.query_params.get("sr", "16000"))
|
||||
except (TypeError, ValueError):
|
||||
pcm_sr = 16000
|
||||
from services.asr_backend import asr_model_missing_error
|
||||
fallback_missing = await asyncio.to_thread(
|
||||
asr_model_missing_error,
|
||||
purpose="dictation",
|
||||
skip_sherpa=True,
|
||||
require_installed=True,
|
||||
)
|
||||
if fallback_missing is not None:
|
||||
logger.warning(
|
||||
"dictation silent-model fallback is not installed (%s); "
|
||||
"skipping recovery to avoid an automatic download",
|
||||
fallback_missing.get("missing_repo_id", "unknown"),
|
||||
)
|
||||
return "", []
|
||||
|
||||
result = await _transcribe_buffer_full(
|
||||
[pcm], pcm_sr=pcm_sr, skip_sherpa=True,
|
||||
)
|
||||
text = polish_text(_result_text(result))
|
||||
if not text:
|
||||
return "", []
|
||||
# The RMS gate can fire on fan/keyboard noise. Only another recognizer
|
||||
# producing words proves the audio held speech and makes persistent
|
||||
# demotion safe.
|
||||
try:
|
||||
from services.sherpa_dictation import demote_model
|
||||
if await asyncio.to_thread(demote_model, spec.id):
|
||||
logger.error(
|
||||
"dictation model %s demoted on this machine — it will no longer be "
|
||||
"auto-selected. Pick it again in Settings to give it another chance.",
|
||||
spec.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("silent-model demotion failed")
|
||||
segments = (result or {}).get("segments") or [
|
||||
{"start": 0.0, "end": None, "text": text}
|
||||
]
|
||||
return text, segments
|
||||
except Exception:
|
||||
logger.exception("dictation silent-model fallback failed")
|
||||
return "", []
|
||||
|
||||
|
||||
async def _sherpa_session(websocket: WebSocket):
|
||||
"""Shared WS setup for the sherpa handlers.
|
||||
|
||||
Returns ``(pcm_sr, aec)``: the bounded PCM sample rate for the session
|
||||
and the echo canceller when ``?aec=1`` requested one (``None`` otherwise
|
||||
or when AEC setup fails).
|
||||
"""
|
||||
pcm_sr = _bounded_sample_rate(websocket.query_params)
|
||||
aec = None
|
||||
if websocket.query_params.get("aec") in ("1", "true", "on"):
|
||||
try:
|
||||
@@ -569,6 +727,8 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
|
||||
last_partial = ""
|
||||
committed: list[str] = [] # finalized utterances this session
|
||||
session_pcm = RecoveryTail(pcm_sr) # bounded audio for silent-model recovery
|
||||
heard_speech = False
|
||||
client_disconnected = False
|
||||
|
||||
async def _send(payload) -> bool:
|
||||
@@ -610,6 +770,9 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
break
|
||||
if kind == "skip":
|
||||
continue
|
||||
session_pcm.extend(pcm)
|
||||
if not heard_speech and _pcm16_rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
heard_speech = True
|
||||
text, endpoint = await asyncio.to_thread(_decode_after_feed, pcm)
|
||||
if endpoint:
|
||||
# Commit this utterance (polished — it gets pasted); reset
|
||||
@@ -618,6 +781,7 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
if text:
|
||||
committed.append(text)
|
||||
await _send({"type": "final", "text": text,
|
||||
"final_kind": "utterance",
|
||||
"segments": [{"start": 0.0, "end": None, "text": text}],
|
||||
"language": "auto", "engine": backend.id})
|
||||
rec.reset(stream)
|
||||
@@ -644,7 +808,28 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
# Pieces are already polished; the join is too (polish is idempotent).
|
||||
full = " ".join(t for t in committed if t).strip()
|
||||
segments = [{"start": 0.0, "end": None, "text": t} for t in committed if t]
|
||||
|
||||
model_silent = is_model_silent(full, heard_speech, session_pcm.total_bytes)
|
||||
if model_silent:
|
||||
recovered, recovered_segments = await _recover_silent_sherpa(
|
||||
spec, session_pcm.tail(), pcm_sr,
|
||||
)
|
||||
if recovered:
|
||||
full = recovered
|
||||
segments = recovered_segments
|
||||
|
||||
if not client_disconnected:
|
||||
payload = {"type": "final", "text": full, "final_kind": "summary",
|
||||
"segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if model_silent:
|
||||
payload["engine"] = "capture-asr-fallback" if full else backend.id
|
||||
payload["model_silent"] = spec.id
|
||||
payload["warning"] = (
|
||||
f"The selected dictation model ({spec.id}) produced no text from your "
|
||||
"speech. Switched to the fallback engine for this session — pick a "
|
||||
"different model in Settings → Dictation."
|
||||
)
|
||||
if full:
|
||||
# Hard-bounded refinement (~4s): never delays this summary `final`
|
||||
# beyond OMNIVOICE_REFINE_TIMEOUT_S even with a dead LLM endpoint.
|
||||
@@ -653,14 +838,9 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
refined = await maybe_refine_async(full)
|
||||
except Exception:
|
||||
refined = None
|
||||
payload = {"type": "final", "text": full, "segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if refined and refined != full:
|
||||
payload["refined_text"] = refined
|
||||
await _send(payload)
|
||||
else:
|
||||
await _send({"type": "final", "text": "", "segments": [],
|
||||
"language": "auto", "engine": backend.id})
|
||||
await _send(payload)
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
@@ -697,7 +877,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
# whisper/zipformer transcribe the same bytes). Keep the whole session's
|
||||
# audio and whether any of it was speech-level, so the finaliser can tell
|
||||
# "user said nothing" (fine) from "model produced nothing" (broken).
|
||||
session_pcm = bytearray()
|
||||
session_pcm = RecoveryTail(pcm_sr)
|
||||
heard_speech = False
|
||||
running = True
|
||||
client_disconnected = False
|
||||
@@ -716,12 +896,6 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
client_disconnected = True
|
||||
return False
|
||||
|
||||
def _rms(pcm: bytes) -> float:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
return 0.0
|
||||
return float((samples * samples).mean() ** 0.5)
|
||||
|
||||
def _decode_window(pcm: bytes) -> str:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
@@ -740,7 +914,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
continue
|
||||
buf.extend(pcm)
|
||||
session_pcm.extend(pcm)
|
||||
if not heard_speech and _rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if not heard_speech and _pcm16_rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
heard_speech = True
|
||||
last_audio = time.monotonic()
|
||||
except WebSocketDisconnect:
|
||||
@@ -766,6 +940,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
if text:
|
||||
committed.append(text)
|
||||
await _send({"type": "final", "text": text,
|
||||
"final_kind": "utterance",
|
||||
"segments": [{"start": 0.0, "end": None, "text": text}],
|
||||
"language": "auto", "engine": backend.id})
|
||||
|
||||
@@ -777,8 +952,8 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
continue
|
||||
snapshot = bytes(buf)
|
||||
if len(snapshot) > sil_bytes and \
|
||||
_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if _rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
_pcm16_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if _pcm16_rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
await _commit(snapshot)
|
||||
else:
|
||||
# Pure silence — drop it (keep the gate window for
|
||||
@@ -824,39 +999,18 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
# quiet user — hand the session to the capture ASR backend so the user
|
||||
# still gets their words, and say which model let them down. Bounded to
|
||||
# this session; the pref is left alone so the user stays in control.
|
||||
model_silent = is_model_silent(full, heard_speech, len(session_pcm))
|
||||
model_silent = is_model_silent(full, heard_speech, session_pcm.total_bytes)
|
||||
if model_silent:
|
||||
logger.warning(
|
||||
"dictation model %s decoded NOTHING from %.1fs of speech-level audio "
|
||||
"— falling back to the capture ASR engine for this session",
|
||||
spec.id, len(session_pcm) / float(max(1, pcm_sr) * 2),
|
||||
recovered, recovered_segments = await _recover_silent_sherpa(
|
||||
spec, session_pcm.tail(), pcm_sr,
|
||||
)
|
||||
# Demote it so the NEXT session doesn't repeat this round trip. The
|
||||
# curated default can be broken on a platform we never tested (the
|
||||
# NeMo-TDT decoder is, on Windows), and observing it beats guessing.
|
||||
try:
|
||||
from services.sherpa_dictation import demote_model
|
||||
if demote_model(spec.id):
|
||||
logger.error(
|
||||
"dictation model %s demoted on this machine — it will no longer be "
|
||||
"auto-selected. Pick it again in Settings to give it another chance.",
|
||||
spec.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("silent-model demotion failed")
|
||||
try:
|
||||
result = await _transcribe_buffer_full([bytes(session_pcm)], pcm_sr=pcm_sr)
|
||||
fb_text = polish_text((result or {}).get("text", "") or "")
|
||||
if fb_text:
|
||||
full = fb_text
|
||||
segments = (result or {}).get("segments") or [
|
||||
{"start": 0.0, "end": None, "text": fb_text}
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("dictation silent-model fallback failed")
|
||||
if recovered:
|
||||
full = recovered
|
||||
segments = recovered_segments
|
||||
|
||||
if not client_disconnected:
|
||||
payload = {"type": "final", "text": full, "segments": segments,
|
||||
payload = {"type": "final", "text": full, "final_kind": "summary",
|
||||
"segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if model_silent:
|
||||
# The client surfaces this so a silently-broken model can't look
|
||||
@@ -884,6 +1038,35 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
pass
|
||||
|
||||
|
||||
def _result_text(result: dict | None) -> str:
|
||||
"""Normalize text from every ASR backend result shape.
|
||||
|
||||
Some backends return a top-level ``text`` value, while WhisperX, Faster
|
||||
Whisper, Moonshine, and OpenAI-compatible ASR expose only ``segments`` and
|
||||
``chunks``. Dictation partials and finals must interpret both contracts the
|
||||
same way.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
return ""
|
||||
|
||||
text = result.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return text.strip()
|
||||
|
||||
for key in ("segments", "chunks"):
|
||||
items = result.get(key)
|
||||
if not isinstance(items, (list, tuple)):
|
||||
continue
|
||||
text = " ".join(
|
||||
str(item.get("text", "")).strip()
|
||||
for item in items
|
||||
if isinstance(item, dict) and item.get("text")
|
||||
).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None) -> str:
|
||||
"""Quick partial transcription of the current audio buffer."""
|
||||
|
||||
@@ -898,7 +1081,7 @@ async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None)
|
||||
def _run():
|
||||
backend = get_capture_asr_backend()
|
||||
result = backend.transcribe(tmp, word_timestamps=False)
|
||||
return result.get("text", "")
|
||||
return _result_text(result)
|
||||
|
||||
# Bound dictation transcribes (#730): a wedged whisperx/CTranslate2 call
|
||||
# must not hold its GPU-pool worker forever and starve TTS / other ASR
|
||||
@@ -912,7 +1095,9 @@ async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None)
|
||||
pass
|
||||
|
||||
|
||||
async def _transcribe_buffer_full(chunks: list[bytes], *, pcm_sr: int | None = None) -> dict:
|
||||
async def _transcribe_buffer_full(
|
||||
chunks: list[bytes], *, pcm_sr: int | None = None, skip_sherpa: bool = False,
|
||||
) -> dict:
|
||||
"""Full transcription with timing info for the final result."""
|
||||
tmp = _pcm16_to_wav(b"".join(chunks), pcm_sr) if pcm_sr else _chunks_to_wav(chunks)
|
||||
if tmp is None:
|
||||
@@ -924,15 +1109,13 @@ async def _transcribe_buffer_full(chunks: list[bytes], *, pcm_sr: int | None = N
|
||||
from services.asr_backend import get_capture_asr_backend, run_transcribe_guarded
|
||||
|
||||
def _run():
|
||||
backend = get_capture_asr_backend()
|
||||
backend = get_capture_asr_backend(skip_sherpa=skip_sherpa)
|
||||
t0 = time.perf_counter()
|
||||
result = backend.transcribe(tmp, word_timestamps=False)
|
||||
elapsed = round(time.perf_counter() - t0, 2)
|
||||
|
||||
segments = result.get("segments", [])
|
||||
full_text = result.get("text", "")
|
||||
if not full_text and segments:
|
||||
full_text = " ".join(s.get("text", "") for s in segments).strip()
|
||||
full_text = _result_text(result)
|
||||
|
||||
# Wave 1.1: strip Whisper hallucination loops from the final
|
||||
# text (the string that gets auto-pasted). Segments keep the
|
||||
|
||||
@@ -577,6 +577,118 @@ def _clamp_num_speakers(value) -> Optional[int]:
|
||||
return value if 1 <= value <= 20 else None
|
||||
|
||||
|
||||
def _recover_from_phrase_embeddings(
|
||||
diar_pipe,
|
||||
diarized_segments: list[dict],
|
||||
*,
|
||||
phrases: list[dict],
|
||||
requested_speakers: int | None,
|
||||
audio_target: str,
|
||||
segments: list[dict],
|
||||
words: list,
|
||||
):
|
||||
"""Recover rapid turns when pyannote collapses a two-speaker exchange.
|
||||
|
||||
Uses ASR phrase boundaries and the embedding/audio components already
|
||||
loaded by speaker-diarization-3.1. Weak or imbalanced clusters are rejected
|
||||
so ordinary single-speaker recordings remain untouched. Returns
|
||||
``(segments, separation)`` or ``None``.
|
||||
"""
|
||||
present = {
|
||||
str(seg.get("speaker_id")) for seg in diarized_segments
|
||||
if seg.get("speaker_id")
|
||||
}
|
||||
if len(present) > 1:
|
||||
return None
|
||||
usable_phrases = [
|
||||
phrase for phrase in phrases
|
||||
if phrase.get("text")
|
||||
and float(phrase.get("end", 0.0)) - float(phrase.get("start", 0.0)) >= 0.75
|
||||
]
|
||||
if len(usable_phrases) < 4:
|
||||
return None
|
||||
requested = int(requested_speakers) if requested_speakers else 2
|
||||
if requested != 2:
|
||||
return None
|
||||
embedding = getattr(diar_pipe, "_embedding", None)
|
||||
audio = getattr(diar_pipe, "_audio", None)
|
||||
if embedding is None or audio is None:
|
||||
return None
|
||||
try:
|
||||
import numpy as np
|
||||
from pyannote.core import Segment as _PyannoteSegment
|
||||
from sklearn.cluster import AgglomerativeClustering
|
||||
|
||||
vectors = []
|
||||
durations = []
|
||||
for phrase in usable_phrases:
|
||||
start, end = float(phrase["start"]), float(phrase["end"])
|
||||
duration = end - start
|
||||
waveform, _ = audio.crop(
|
||||
audio_target, _PyannoteSegment(start, end),
|
||||
duration=duration, mode="pad",
|
||||
)
|
||||
vector = np.asarray(embedding(waveform[None])).reshape(-1)
|
||||
if not np.isfinite(vector).all():
|
||||
return None
|
||||
vectors.append(vector)
|
||||
durations.append(duration)
|
||||
matrix = np.vstack(vectors)
|
||||
labels = np.asarray(AgglomerativeClustering(
|
||||
n_clusters=2, metric="cosine", linkage="average",
|
||||
).fit_predict(matrix))
|
||||
if len(set(labels.tolist())) != 2:
|
||||
return None
|
||||
|
||||
counts = [int(np.sum(labels == cluster)) for cluster in (0, 1)]
|
||||
cluster_durations = [
|
||||
float(sum(duration for duration, label in zip(durations, labels) if label == cluster))
|
||||
for cluster in (0, 1)
|
||||
]
|
||||
if min(counts) < 2 or min(cluster_durations) < 1.5:
|
||||
return None
|
||||
|
||||
normalized = matrix / np.maximum(np.linalg.norm(matrix, axis=1, keepdims=True), 1e-8)
|
||||
similarities = normalized @ normalized.T
|
||||
within, cross = [], []
|
||||
for left in range(len(labels)):
|
||||
for right in range(left + 1, len(labels)):
|
||||
target = within if labels[left] == labels[right] else cross
|
||||
target.append(float(similarities[left, right]))
|
||||
if not within or not cross:
|
||||
return None
|
||||
separation = float(np.mean(within) - np.mean(cross))
|
||||
min_separation = 0.12 if requested_speakers == 2 else 0.18
|
||||
if separation < min_separation:
|
||||
logger.info(
|
||||
"phrase-embedding speaker recovery rejected (separation=%.3f < %.3f)",
|
||||
separation, min_separation,
|
||||
)
|
||||
return None
|
||||
|
||||
speaker_map = {}
|
||||
turns = []
|
||||
for phrase, label in zip(usable_phrases, labels.tolist()):
|
||||
if label not in speaker_map:
|
||||
speaker_map[label] = f"Speaker {len(speaker_map) + 1}"
|
||||
turns.append({
|
||||
"start": float(phrase["start"]),
|
||||
"end": float(phrase["end"]),
|
||||
"speaker": speaker_map[label],
|
||||
})
|
||||
# Assignment mutates segment dictionaries. Work on copies so a recovery
|
||||
# rejected by the final two-speaker check cannot leak partial labels
|
||||
# into the ordinary pyannote result.
|
||||
assigned = assign_speakers_from_turns([dict(item) for item in segments], turns)
|
||||
recovered = resplit_segments_by_turns(assigned, words, turns)
|
||||
if len({item.get("speaker_id") for item in recovered if item.get("speaker_id")}) < 2:
|
||||
return None
|
||||
return recovered, separation
|
||||
except Exception:
|
||||
logger.exception("phrase-embedding speaker recovery failed")
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/dub/transcribe-stream/{job_id}")
|
||||
async def dub_transcribe_stream(
|
||||
job_id: str,
|
||||
@@ -912,6 +1024,12 @@ async def dub_transcribe_stream(
|
||||
# Words (global-timeline) retained so diarization can re-split a segment
|
||||
# that spans two speakers' turns at the word boundary (#486).
|
||||
all_words: list = []
|
||||
# Preserve the ASR backend's natural phrase boundaries before
|
||||
# segment_transcript merges short neighboring phrases. Pyannote 3.1
|
||||
# occasionally collapses rapid exchanges into one dominant speaker; in
|
||||
# that narrow case these phrase spans give its own WeSpeaker embedding
|
||||
# model clean candidate utterances for a conservative recovery pass.
|
||||
asr_phrase_segments: list[dict] = []
|
||||
detected_lang = None
|
||||
next_seg_id = 0
|
||||
chunk_errors: list[str] = []
|
||||
@@ -1048,6 +1166,17 @@ async def dub_transcribe_stream(
|
||||
if detected_lang is None and part.get("language"):
|
||||
detected_lang = part["language"]
|
||||
asr_speaker_turns.extend(part.get("speaker_turns") or [])
|
||||
for _phrase in part.get("chunks", []) or []:
|
||||
_pts = _phrase.get("timestamp") or (None, None)
|
||||
_ptext = (_phrase.get("text") or "").strip()
|
||||
try:
|
||||
_ps, _pe = float(_pts[0]), float(_pts[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
if _ptext and _pe > _ps:
|
||||
asr_phrase_segments.append({
|
||||
"start": _ps, "end": _pe, "text": _ptext,
|
||||
})
|
||||
chunk_segs = segment_transcript(part, duration=t1, scene_cuts=scene_cuts)
|
||||
# Same word source segment_transcript used (already global-timeline),
|
||||
# kept for the post-diarization speaker re-split (#486).
|
||||
@@ -1313,7 +1442,25 @@ async def dub_transcribe_stream(
|
||||
assigned = assign_speakers_from_diarization(all_segments, diar)
|
||||
# #486: split any segment that spans two speakers' turns at the
|
||||
# word boundary (single-speaker segments pass through unchanged).
|
||||
return resplit_segments_by_diarization(assigned, all_words, diar), None, "pyannote"
|
||||
resplit = resplit_segments_by_diarization(assigned, all_words, diar)
|
||||
recovered = _recover_from_phrase_embeddings(
|
||||
diar_pipe,
|
||||
resplit,
|
||||
phrases=asr_phrase_segments,
|
||||
requested_speakers=num_speakers,
|
||||
audio_target=asr_audio_target,
|
||||
segments=all_segments,
|
||||
words=all_words,
|
||||
)
|
||||
if recovered is not None:
|
||||
recovered_segments, separation = recovered
|
||||
logger.info(
|
||||
"Recovered rapid two-speaker exchange from ASR phrase embeddings "
|
||||
"(phrases=%d, separation=%.3f).",
|
||||
len(asr_phrase_segments), separation,
|
||||
)
|
||||
return recovered_segments, None, "phrase_embeddings"
|
||||
return resplit, None, "pyannote"
|
||||
except Exception as e:
|
||||
logger.exception("Diarization failed")
|
||||
# Inline ASR turns beat the silence-gap heuristic as a crash
|
||||
|
||||
@@ -572,7 +572,7 @@ def _build_audio_export_cmd(
|
||||
async def dub_download(
|
||||
job_id: str,
|
||||
preserve_bg: bool = Query(True, description="Mix background noise into dubbed tracks"),
|
||||
default_track: str = Query("original"),
|
||||
default_track: str = Query("", description="Default audio track; omitted selects the first dubbed track"),
|
||||
include_tracks: str = Query("", description="Comma-separated list of tracks to include (e.g. 'original,de,es'). Empty = include all."),
|
||||
save_authorization: str = Header("", alias="X-VoiceStudio-Path-Authorization"),
|
||||
burn_subs: bool = Query(False, description="Burn subtitles into the video stream (forces re-encode). Uses dual-subtitle layout when dual=1."),
|
||||
@@ -607,6 +607,18 @@ async def dub_download(
|
||||
for key, value in filtered_tracks.items()
|
||||
}
|
||||
|
||||
# A dub export should play the dub without requiring player-specific track
|
||||
# selection. Keep ``original`` as an explicit opt-in, but when callers omit
|
||||
# the preference choose the first generated dub consistently (#1575).
|
||||
if (
|
||||
filtered_tracks
|
||||
and not (default_track == "original" and include_original)
|
||||
and default_track not in filtered_tracks
|
||||
):
|
||||
default_track = next(iter(filtered_tracks))
|
||||
elif not filtered_tracks and include_original:
|
||||
default_track = "original"
|
||||
|
||||
if not filtered_tracks and not include_original:
|
||||
raise HTTPException(status_code=400, detail="No tracks selected for export")
|
||||
|
||||
@@ -631,12 +643,17 @@ async def dub_download(
|
||||
fmt = (out_format or "m4a").lower()
|
||||
if fmt not in _AUDIO_FORMAT_CODECS:
|
||||
fmt = "m4a"
|
||||
# lang_code is already constrained to an existing track key, but
|
||||
# allowlist-sanitize it before it reaches the output path so a path
|
||||
# component can never carry separators/traversal (same pattern as
|
||||
# safe_name below).
|
||||
safe_lang = "".join(c for c in lang_code if c.isalnum() or c in "-_") or "track"
|
||||
out_path = os.path.join(exports_dir, f"dubbed_audio_{safe_lang}_{stamp}.{fmt}")
|
||||
# Keep route/job data out of the filesystem and logging trust boundary.
|
||||
# The selected format reaches the path only through literal branches.
|
||||
if fmt == "wav":
|
||||
output_name = f"dubbed_audio_{stamp}.wav"
|
||||
elif fmt == "mp3":
|
||||
output_name = f"dubbed_audio_{stamp}.mp3"
|
||||
elif fmt == "flac":
|
||||
output_name = f"dubbed_audio_{stamp}.flac"
|
||||
else:
|
||||
output_name = f"dubbed_audio_{stamp}.m4a"
|
||||
out_path = os.path.join(exports_dir, output_name)
|
||||
bg = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
|
||||
cmd = _build_audio_export_cmd(ffmpeg, track_info["path"], bg, out_path, fmt)
|
||||
try:
|
||||
@@ -654,15 +671,28 @@ async def dub_download(
|
||||
)
|
||||
if not os.path.exists(out_path) or os.path.getsize(out_path) == 0:
|
||||
raise HTTPException(status_code=500, detail="ffmpeg audio export produced no output file")
|
||||
logger.info("Dub audio export wrote %s (%d bytes)", out_path, os.path.getsize(out_path))
|
||||
logger.info("Dub audio export completed (%d bytes)", os.path.getsize(out_path))
|
||||
|
||||
base_name = os.path.splitext(job.get("filename", "output"))[0]
|
||||
safe_name = "".join(c for c in base_name if c.isalnum() or c in "-_ ").strip() or "output"
|
||||
dl_name = f"dubbed_{safe_name}_{safe_lang}_{stamp}.{fmt}"
|
||||
# Response metadata must not become a second path-like sink for job or
|
||||
# request data. Keep the user-selected format through explicit literal
|
||||
# branches; source names and language keys never enter the label.
|
||||
if fmt == "wav":
|
||||
dl_name = f"dubbed_audio_{stamp}.wav"
|
||||
elif fmt == "mp3":
|
||||
dl_name = f"dubbed_audio_{stamp}.mp3"
|
||||
elif fmt == "flac":
|
||||
dl_name = f"dubbed_audio_{stamp}.flac"
|
||||
else:
|
||||
dl_name = f"dubbed_audio_{stamp}.m4a"
|
||||
media_type = _MEDIA_TYPES.get(f".{fmt}", "audio/mp4")
|
||||
save_path = _consume_native_save(save_authorization)
|
||||
if save_path:
|
||||
return _native_save(out_path, save_path, dl_name, media_type=media_type)
|
||||
# Keep the request-derived download label out of the filesystem
|
||||
# trust boundary. It is response metadata, not a source or
|
||||
# destination path (CodeQL, #1575).
|
||||
result = _native_save(out_path, save_path, "dubbed_audio", media_type=media_type)
|
||||
result["display_name"] = dl_name
|
||||
return result
|
||||
return FileResponse(
|
||||
out_path, media_type=media_type,
|
||||
headers={"Content-Disposition": content_disposition(dl_name)},
|
||||
@@ -887,7 +917,10 @@ async def dub_download(
|
||||
if default_track == "original" and include_original:
|
||||
cmd += ["-disposition:a:0", "default"]
|
||||
else:
|
||||
target_idx = 0
|
||||
# A stale/missing language preference still means "play a dub", not
|
||||
# "silently fall back to the source". The first processed dub is the
|
||||
# deterministic fallback; ``original`` above remains explicit.
|
||||
target_idx = tracks_to_process[0]["stream_idx"] if tracks_to_process else 0
|
||||
for t in tracks_to_process:
|
||||
if t['lang_code'] == default_track:
|
||||
target_idx = t["stream_idx"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import struct
|
||||
import logging
|
||||
import time
|
||||
import asyncio
|
||||
@@ -80,6 +81,62 @@ def _prepare_oom_retry(error: Exception, *, execution_target: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _cached_payload_intact(path: str, info) -> bool:
|
||||
"""Cheap truth check on a cached WAV whose header we are about to trust.
|
||||
|
||||
The natural-rate fast path hands the mixer a PATH instead of decoded
|
||||
audio, so a cache whose header reads fine but whose payload is truncated
|
||||
would only fail later, during assembly — after the timing plan (Smart Fit,
|
||||
video stretch) had been computed from the header's frame count. The plan
|
||||
would then describe audio that no longer exists and the segment would be
|
||||
replaced by slot-length silence, leaving the persisted video plan and the
|
||||
rendered track disagreeing.
|
||||
|
||||
Comparing the declared frame count against the physical ``data`` chunk
|
||||
catches that without decoding: a truncated file cannot hold the samples
|
||||
its header claims. Anything failing here falls through to the decoding path, which
|
||||
already degrades to a warning plus silence. Formats with no fixed
|
||||
bits-per-sample (compressed caches) are left to the decoder as before.
|
||||
"""
|
||||
try:
|
||||
bits = int(getattr(info, "bits_per_sample", 0) or 0)
|
||||
frames = int(getattr(info, "num_frames", 0) or 0)
|
||||
channels = int(getattr(info, "num_channels", 0) or 0)
|
||||
if bits <= 0 or frames <= 0 or channels <= 0:
|
||||
# Undecidable metadata fails CLOSED (review on #1620): these caches
|
||||
# are PCM WAVs this module wrote itself, so anything else is
|
||||
# unexpected — and the decode path this falls through to handles
|
||||
# every format the fast path would have.
|
||||
return False
|
||||
payload = frames * channels * (bits // 8)
|
||||
if payload <= 0:
|
||||
return False
|
||||
|
||||
# A WAV may carry JUNK/LIST metadata before data, so its header is not
|
||||
# necessarily 44 bytes. Locate the data chunk instead of counting
|
||||
# metadata as audio; otherwise an extended header can mask truncation.
|
||||
file_size = os.path.getsize(path)
|
||||
with open(path, "rb") as wav:
|
||||
header = wav.read(12)
|
||||
if len(header) != 12 or header[:4] != b"RIFF" or header[8:12] != b"WAVE":
|
||||
return False
|
||||
offset = 12
|
||||
while offset + 8 <= file_size:
|
||||
wav.seek(offset)
|
||||
chunk_id = wav.read(4)
|
||||
chunk_size_raw = wav.read(4)
|
||||
if len(chunk_id) != 4 or len(chunk_size_raw) != 4:
|
||||
return False
|
||||
chunk_size = struct.unpack("<I", chunk_size_raw)[0]
|
||||
data_offset = offset + 8
|
||||
if chunk_id == b"data":
|
||||
return chunk_size >= payload and file_size >= data_offset + payload
|
||||
offset = data_offset + chunk_size + (chunk_size % 2)
|
||||
return False
|
||||
except Exception: # noqa: BLE001 — an unstattable cache is the decoder's problem
|
||||
return False
|
||||
|
||||
|
||||
def _underrun_min_rate() -> float:
|
||||
"""Floor for the underrun fill (audio slowed toward its slot, never below
|
||||
this rate). Default 0.85 stays natural-sounding; OMNIVOICE_UNDERRUN_MIN_RATE=1.0
|
||||
@@ -593,11 +650,11 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
voice_match = (req.voice_match or "per_line").lower()
|
||||
_consistent_ref_memo: dict = {}
|
||||
remote_audio: dict[int, str] = {}
|
||||
# Strategy-transition guard: smart_fit re-mixes the *natural-rate*
|
||||
# per-segment WAVs from disk. If the previous run used strict_slot,
|
||||
# the on-disk WAVs are slot-squeezed ("slotted") — reusing them would
|
||||
# double-compress. Force one full regen; afterwards seg_wav_kind is
|
||||
# "natural" and partial regen / fit-only re-mix (regen_only=[]) work.
|
||||
# Strategy-transition guard: concise, stretch_video and smart_fit all
|
||||
# re-mix *natural-rate* per-segment WAVs. If the previous run used
|
||||
# strict_slot, the on-disk WAVs are slot-squeezed ("slotted") — the
|
||||
# missing tails cannot be recovered by a re-mix. Force one full regen;
|
||||
# afterwards partial regen / fit-only re-mix (regen_only=[]) is safe.
|
||||
# Jobs predating this field have unknown kind → also regen once.
|
||||
# P1.3: the kind is per-track now (each language renders under its own
|
||||
# strategy); the flat job["seg_wav_kind"] is only consulted for jobs
|
||||
@@ -608,7 +665,7 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
_wav_kind = (
|
||||
_kind_map.get(lang_code) if isinstance(_kind_map, dict) else job.get("seg_wav_kind")
|
||||
)
|
||||
if strategy == "smart_fit" and regen_only is not None and _wav_kind != "natural":
|
||||
if strategy != "strict_slot" and regen_only is not None and _wav_kind != "natural":
|
||||
regen_only = None
|
||||
# Manifest: stable segment id per current index. Per-segment WAVs are
|
||||
# named by stable id (dub_seg_path) so regen reuses the right audio after
|
||||
@@ -759,15 +816,38 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
if os.path.exists(seg_wav_path):
|
||||
try:
|
||||
_t_cache_0 = time.perf_counter()
|
||||
# Natural-rate caches are already the exact assembly
|
||||
# input. Keep the durable path in the manifest so the
|
||||
# mixer decodes it once; the old path decoded here,
|
||||
# wrote an identical mix_<id> scratch WAV, then decoded
|
||||
# that copy again. Header-only inspection preserves
|
||||
# the resample fallback for caches made by an engine
|
||||
# with a different sample rate.
|
||||
if strategy != "strict_slot":
|
||||
try:
|
||||
cached_info = torchaudio.info(seg_wav_path)
|
||||
except Exception:
|
||||
cached_info = None
|
||||
if (
|
||||
cached_info is not None
|
||||
and int(cached_info.sample_rate) == int(backend.sample_rate)
|
||||
and _cached_payload_intact(seg_wav_path, cached_info)
|
||||
):
|
||||
all_segment_wavs.append(
|
||||
(seg.start, seg.end, seg_wav_path, backend.sample_rate)
|
||||
)
|
||||
sync_scores.append(getattr(seg, 'sync_ratio', None) or 1.0)
|
||||
_t_cache += time.perf_counter() - _t_cache_0
|
||||
continue
|
||||
|
||||
cached_wav, cached_sr = torchaudio.load(seg_wav_path)
|
||||
if cached_sr != backend.sample_rate:
|
||||
import torchaudio.functional as AF
|
||||
cached_wav = AF.resample(cached_wav, cached_sr, backend.sample_rate)
|
||||
# Pad/trim to slot — except smart_fit, whose mix
|
||||
# loop needs the natural-rate length to compute the
|
||||
# audio/video split (the seg_wav_kind guard above
|
||||
# guarantees these cached WAVs are natural-rate).
|
||||
if strategy != "smart_fit":
|
||||
# strict_slot persists slot-sized buffers. Every other
|
||||
# strategy consumes natural-rate audio and lets the mix
|
||||
# loop fit it to the current timeline.
|
||||
if strategy == "strict_slot":
|
||||
target_samples = int(seg_duration * backend.sample_rate)
|
||||
current_samples = cached_wav.shape[-1]
|
||||
if target_samples > current_samples:
|
||||
@@ -1091,7 +1171,7 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
_num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
|
||||
),
|
||||
what="Dub generate",
|
||||
timeout=generate_timeout_s(seg.text),
|
||||
timeout=generate_timeout_s(seg.text, engine=backend),
|
||||
)
|
||||
_t_tts += time.perf_counter() - _t_tts_0
|
||||
|
||||
@@ -1164,12 +1244,15 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
if rvc_sr == backend.sample_rate:
|
||||
audio_tensor = rvc_wav
|
||||
|
||||
target_samples = int(seg_duration * backend.sample_rate)
|
||||
current_samples = audio_tensor.shape[-1]
|
||||
if target_samples > current_samples:
|
||||
audio_tensor = torch.nn.functional.pad(audio_tensor, (0, target_samples - current_samples))
|
||||
elif current_samples > target_samples:
|
||||
audio_tensor = audio_tensor[..., :target_samples]
|
||||
if strategy == "strict_slot":
|
||||
target_samples = int(seg_duration * backend.sample_rate)
|
||||
current_samples = audio_tensor.shape[-1]
|
||||
if target_samples > current_samples:
|
||||
audio_tensor = torch.nn.functional.pad(
|
||||
audio_tensor, (0, target_samples - current_samples)
|
||||
)
|
||||
elif current_samples > target_samples:
|
||||
audio_tensor = audio_tensor[..., :target_samples]
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'warning', 'segment': i, 'message': f'RVC skipped: {str(e)[:120]}'})}\n\n"
|
||||
|
||||
@@ -1356,7 +1439,21 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
seg_gain = getattr(seg_ref, "gain", None) if seg_ref is not None else None
|
||||
seg_gain = seg_gain if seg_gain is not None else 1.0
|
||||
seg_gain = max(0.0, min(2.0, seg_gain))
|
||||
wav = _load_entry_wav((start, end, wav_path, sr), sr)
|
||||
try:
|
||||
wav = _load_entry_wav((start, end, wav_path, sr), sr)
|
||||
except Exception as e:
|
||||
# A WAV header can be readable while its payload is
|
||||
# truncated. Direct cache reuse deliberately defers the
|
||||
# decode to assembly, so preserve the old recovery contract
|
||||
# here: warn and fill this slot with silence instead of
|
||||
# aborting the entire dub.
|
||||
warning = {
|
||||
"type": "warning",
|
||||
"segment": i,
|
||||
"message": f"cached seg lost, padding silence: {str(e)[:120]}",
|
||||
}
|
||||
yield f"data: {json.dumps(warning)}\n\n"
|
||||
wav = torch.zeros(1, max(0, int((end - start) * sr)))
|
||||
adjusted = wav * seg_gain
|
||||
if adjusted.ndim == 2 and adjusted.shape[0] > 1:
|
||||
adjusted = adjusted.mean(dim=0, keepdim=True)
|
||||
@@ -1798,7 +1895,7 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
|
||||
from services.model_manager import generate_timeout_s
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
_gen, what="Dub preview generate",
|
||||
timeout=generate_timeout_s(req.text),
|
||||
timeout=generate_timeout_s(req.text, engine=backend),
|
||||
)
|
||||
|
||||
sr = backend.sample_rate
|
||||
|
||||
@@ -596,7 +596,7 @@ def _oom_friendly_reraise(e):
|
||||
) from e
|
||||
|
||||
|
||||
def _generate_timeout_s(text: str) -> float:
|
||||
def _generate_timeout_s(text: str, *, execution_device=None) -> float:
|
||||
"""Wall-clock budget for one generate, scaled to the request.
|
||||
|
||||
Thin alias for the canonical helper, which moved to
|
||||
@@ -605,7 +605,7 @@ def _generate_timeout_s(text: str) -> float:
|
||||
as they did, silently keeping the flat 300s).
|
||||
"""
|
||||
from services.model_manager import generate_timeout_s
|
||||
return generate_timeout_s(text)
|
||||
return generate_timeout_s(text, execution_device=execution_device)
|
||||
|
||||
|
||||
def _run_inference(
|
||||
@@ -870,7 +870,6 @@ async def _finalize_generation(
|
||||
Returns ``(watermarked_tensor, meta)`` where ``meta`` carries
|
||||
``id`` / ``filename`` / ``duration`` / ``gen_time``.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
# Invisible AudioSeal provenance watermark on the final audio. Embedding
|
||||
# was previously only wired into the dub pipeline (dub_generate.py), so
|
||||
# plain TTS came out unmarked despite the setting being on — and the same
|
||||
@@ -882,12 +881,9 @@ async def _finalize_generation(
|
||||
# AudioSeal embedding is CPU work that holds no VRAM, so occupying a GPU
|
||||
# worker with it only delays the next generate on 1-worker hosts.
|
||||
if not already_marked:
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
audio_tensor = await loop.run_in_executor(
|
||||
get_watermark_pool(),
|
||||
functools.partial(mark_synthetic, audio_tensor, sample_rate,
|
||||
context="generate.finalize"),
|
||||
from services.watermark import mark_synthetic_async
|
||||
audio_tensor = await mark_synthetic_async(
|
||||
audio_tensor, sample_rate, context="generate.finalize",
|
||||
)
|
||||
gen_time = round(time.time() - start_time, 2)
|
||||
|
||||
@@ -1198,6 +1194,10 @@ async def generate_speech(
|
||||
_backend = None
|
||||
_engine_min_vram_gb = getattr(backend_cls, "min_vram_gb", 0.0)
|
||||
_routing_notice = None
|
||||
# Remote renders deliberately skip this host's capability gate. Keep the
|
||||
# local fallback call's timeout device-neutral so the closure is valid
|
||||
# without pretending the control plane describes the remote worker.
|
||||
_routing = {"effective_device": None}
|
||||
|
||||
if not _remote:
|
||||
# Single-active-engine memory discipline: hand back any OTHER resident
|
||||
@@ -1406,7 +1406,7 @@ async def generate_speech(
|
||||
# Floor budget (#1190): a reference clip is seconds of audio,
|
||||
# so the length-scaled bonus never applies — but the timeout is
|
||||
# explicit here too, so no dispatch relies on a hidden default.
|
||||
timeout=_generate_timeout_s(""),
|
||||
timeout=_generate_timeout_s("", execution_device=_routing["effective_device"]),
|
||||
)
|
||||
# TimeoutError covers both the execution bound and pool saturation:
|
||||
# this path is best-effort either way.
|
||||
@@ -1523,7 +1523,7 @@ async def generate_speech(
|
||||
local=gpu_gateway.LocalCall(
|
||||
_remote_only_local_call(_target_label),
|
||||
what="TTS generate",
|
||||
timeout=_generate_timeout_s(text),
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
),
|
||||
remote=_remote_call,
|
||||
@@ -1789,7 +1789,7 @@ async def generate_speech(
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text),
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
)
|
||||
sample_rate = _backend.sample_rate
|
||||
else:
|
||||
@@ -1805,7 +1805,7 @@ async def generate_speech(
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text),
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
)
|
||||
sample_rate = _model.sampling_rate
|
||||
yield _line({
|
||||
@@ -1822,12 +1822,10 @@ async def generate_speech(
|
||||
# (#1190): AudioSeal embedding is CPU work that owns no
|
||||
# VRAM, and on a 1-worker host it used to serialize
|
||||
# directly ahead of the next generate.
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
_preview = await asyncio.get_running_loop().run_in_executor(
|
||||
get_watermark_pool(),
|
||||
functools.partial(mark_synthetic, audio_tensor, sample_rate,
|
||||
context="generate.stream_preview"),
|
||||
from services.watermark import mark_synthetic_async
|
||||
_preview = await mark_synthetic_async(
|
||||
audio_tensor, sample_rate,
|
||||
context="generate.stream_preview",
|
||||
)
|
||||
yield _line({"type": "chunk", "seq": 0, "pcm": _pcm16_b64(_preview)})
|
||||
else:
|
||||
@@ -1843,18 +1841,16 @@ async def generate_speech(
|
||||
# Budget scaled to THIS chunk (#1190) — the flat
|
||||
# 300s here is what made long streamed renders fail
|
||||
# even after the v0.3.22 scaled budget shipped.
|
||||
timeout=_generate_timeout_s(chunk_text),
|
||||
timeout=_generate_timeout_s(chunk_text, execution_device=_routing["effective_device"]),
|
||||
)
|
||||
parts.append(raw)
|
||||
# Provenance-mark the streamed copy off the GPU pool
|
||||
# (#1169 mark, #1190 placement): CPU-only AudioSeal
|
||||
# work must not occupy a GPU worker between chunks.
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
preview = await asyncio.get_running_loop().run_in_executor(
|
||||
get_watermark_pool(),
|
||||
functools.partial(mark_synthetic, preview, sample_rate,
|
||||
context="generate.stream_preview"),
|
||||
from services.watermark import mark_synthetic_async
|
||||
preview = await mark_synthetic_async(
|
||||
preview, sample_rate,
|
||||
context="generate.stream_preview",
|
||||
)
|
||||
if i == 0:
|
||||
# After the first render so lazy-loading engines
|
||||
@@ -1869,7 +1865,7 @@ async def generate_speech(
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
functools.partial(_assemble_stream_chunks, parts, sample_rate),
|
||||
what="TTS assemble",
|
||||
timeout=_generate_timeout_s(text),
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
)
|
||||
|
||||
_, meta = await _finalize_generation(
|
||||
@@ -1898,7 +1894,7 @@ async def generate_speech(
|
||||
# Client went away mid-stream — same semantics as aborting a
|
||||
# classic /generate mid-render: nothing is saved.
|
||||
raise
|
||||
except (GpuJobTimeoutError, GpuPoolBusyError) as e:
|
||||
except GpuPoolBusyError as e:
|
||||
# In-band error frame carries the machine-readable retryable
|
||||
# marker (#1190) — an NDJSON consumer can back off instead of
|
||||
# guessing from the prose.
|
||||
@@ -1907,6 +1903,14 @@ async def generate_speech(
|
||||
failure = stream_failure("generation_busy")
|
||||
failure["retry_after"] = getattr(e, "retry_after", 30)
|
||||
yield _line({"type": "error", **failure})
|
||||
except GpuJobTimeoutError:
|
||||
# The worker started and spent its full execution budget. That
|
||||
# is compute time, not queue pressure (#1588).
|
||||
logger.error("Streaming generation exceeded its compute budget")
|
||||
from core.public_errors import stream_failure
|
||||
failure = stream_failure("generation_timeout")
|
||||
failure["retry_after"] = 30
|
||||
yield _line({"type": "error", **failure})
|
||||
except ValueError:
|
||||
logger.error("Streaming generation request rejected")
|
||||
from core.public_errors import stream_failure
|
||||
@@ -1973,7 +1977,7 @@ async def generate_speech(
|
||||
_REMOTE_OP,
|
||||
local=gpu_gateway.LocalCall(
|
||||
_local_render, what="TTS generate",
|
||||
timeout=_generate_timeout_s(text),
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
),
|
||||
decision=_decision,
|
||||
|
||||
@@ -160,7 +160,9 @@ _OPENAI_VOICE_ALIASES = {
|
||||
|
||||
def _resolve_engine(model_id: str):
|
||||
"""Map an OpenAI model name to a VoiceStudio backend."""
|
||||
from services.tts_backend import get_backend_class, get_active_tts_backend
|
||||
from services.tts_backend import (
|
||||
get_backend_class, get_active_tts_backend, get_engine_instance_for,
|
||||
)
|
||||
|
||||
# Accept OpenAI model names as pass-through to the active engine.
|
||||
if model_id in ("tts-1", "tts-1-hd"):
|
||||
@@ -177,8 +179,18 @@ def _resolve_engine(model_id: str):
|
||||
)
|
||||
from services.tts_backend import OmniVoiceBackend
|
||||
if cls is OmniVoiceBackend:
|
||||
# OmniVoice only ever runs as the shared active engine — the
|
||||
# explicit-omnivoice request is the active-engine request.
|
||||
return get_active_tts_backend()
|
||||
return cls()
|
||||
# Cached singleton, not a fresh cls(): SubprocessBackend engines would
|
||||
# spawn a sidecar process and reload their model on EVERY request, and
|
||||
# register a new atexit hook each time (get_engine_instance's contract).
|
||||
# No router-local cache on top of it: the shared cache is keyed by
|
||||
# CLASS precisely so id rebinds/evictions can't serve a stale instance,
|
||||
# and cross-engine memory discipline is create_speech's
|
||||
# evict_other_tts_engines call (the same seam /generate uses) — not a
|
||||
# bespoke unload here.
|
||||
return get_engine_instance_for(model_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -388,6 +400,15 @@ async def create_speech(req: SpeechRequest):
|
||||
# VRAM eviction runs in get_model()'s warm-return path now, covering every
|
||||
# native TTS generate (this route, WS TTS, dub, batch, audiobook).
|
||||
|
||||
# Single-active-engine memory discipline (MM2-01), the same call /generate
|
||||
# makes before its load: hand back every OTHER resident TTS engine's model
|
||||
# before this one warms up, so switching `model` ids across requests —
|
||||
# explicit id → explicit id, or explicit id → the tts-1/omnivoice aliases —
|
||||
# can't stack multi-GB engines/sidecars. No-op when nothing else is
|
||||
# resident; opt out with OMNIVOICE_SINGLE_ENGINE_RESIDENT=0.
|
||||
from services.engine_memory import evict_other_tts_engines
|
||||
await evict_other_tts_engines(backend.id)
|
||||
|
||||
# ── #1033/#1037/#1014: warm the engine under the LOAD budget before the
|
||||
# generate clock starts. The T4 verification (#1014) measured a fresh
|
||||
# install's first /v1/audio/speech burning its whole 300s generate budget
|
||||
@@ -454,7 +475,7 @@ async def create_speech(req: SpeechRequest):
|
||||
from services.model_manager import generate_timeout_s
|
||||
wav, sr = await run_on_gpu_pool_guarded(
|
||||
lambda: _run_tts(backend, text, kw), what="OpenAI TTS generate",
|
||||
timeout=generate_timeout_s(text))
|
||||
timeout=generate_timeout_s(text, engine=backend))
|
||||
except Exception as e:
|
||||
# #1172/#1173: typed failures get their real status + actionable
|
||||
# message (400 bad input / 503 broken engine binary) instead of a
|
||||
|
||||
@@ -62,6 +62,11 @@ def setup_status():
|
||||
_MIN_NVIDIA_DRIVER = 555
|
||||
_RAM_FAIL_GB = 8
|
||||
_RAM_WARN_GB = 12
|
||||
# Installed DIMMs never fully reach the OS: firmware, integrated graphics and
|
||||
# kernel reservations shave off up to ~7% (an "8 GB" Windows laptop reports
|
||||
# ~7.8 GB usable). Thresholds are compared with this allowance applied so the
|
||||
# machines a threshold is meant to admit aren't blocked by that gap (#1618).
|
||||
_RAM_RESERVED_ALLOWANCE = 0.93
|
||||
|
||||
|
||||
def _run_cmd(args: list[str], timeout: float = 2.0) -> tuple[int, str]:
|
||||
@@ -352,17 +357,28 @@ def preflight():
|
||||
|
||||
# ── RAM
|
||||
ram = _ram_gb()
|
||||
# Escape hatch (#1618): a preflight should inform, not brick setup —
|
||||
# OMNIVOICE_RAM_PREFLIGHT=0 downgrades the hard block to a warning for
|
||||
# users who accept the OOM risk. Same opt-out shape as
|
||||
# OMNIVOICE_ASR_VRAM_PREFLIGHT.
|
||||
ram_gate = os.environ.get(
|
||||
"OMNIVOICE_RAM_PREFLIGHT", "1"
|
||||
).strip().lower() not in ("0", "false", "no")
|
||||
if ram == 0:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"warn", "Could not detect system RAM.",
|
||||
"Install psutil in the backend environment or ignore this warning.",
|
||||
)
|
||||
elif ram < _RAM_FAIL_GB:
|
||||
elif ram < _RAM_FAIL_GB * _RAM_RESERVED_ALLOWANCE:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"fail", f"{ram:.1f} GB total (need ≥ {_RAM_FAIL_GB} GB)",
|
||||
"The app will OOM on first dub. Close other apps or upgrade RAM.",
|
||||
"fail" if ram_gate else "warn",
|
||||
f"{ram:.1f} GB total (need ≥ {_RAM_FAIL_GB} GB)",
|
||||
"The app will OOM on first dub. Close other apps or upgrade RAM."
|
||||
if ram_gate else
|
||||
"RAM check disabled via OMNIVOICE_RAM_PREFLIGHT=0 — dubbing may "
|
||||
"OOM on this machine.",
|
||||
)
|
||||
elif ram < _RAM_WARN_GB:
|
||||
elif ram < _RAM_WARN_GB * _RAM_RESERVED_ALLOWANCE:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"warn", f"{ram:.1f} GB total ({_RAM_WARN_GB}+ GB recommended)",
|
||||
"Long videos may hit swap. Keep other apps closed during dubbing.",
|
||||
|
||||
@@ -10,7 +10,8 @@ as they're generated. This unlocks:
|
||||
Protocol:
|
||||
→ Client sends JSON: {"text": "...", "voice": "profile_id", ...}
|
||||
← Server sends binary audio chunks (PCM16 @ 24kHz mono) as generated
|
||||
← Server sends JSON: {"type": "done", "duration_s": 4.2, "gen_time_s": 1.1}
|
||||
← Server sends JSON: {"type": "done", "duration_s": 4.2,
|
||||
"gen_time_s": 1.1, "ttfa_ms": 180.0, "rtf": 0.262}
|
||||
← Server sends JSON: {"type": "error", "detail": "..."}
|
||||
|
||||
The chunked delivery targets <100ms time-to-first-audio (TTFA) on warm models.
|
||||
@@ -33,6 +34,10 @@ logger = logging.getLogger("omnivoice.tts_stream")
|
||||
# Smaller chunks = lower latency but more WebSocket overhead.
|
||||
CHUNK_SAMPLES = int(os.environ.get("OMNIVOICE_STREAM_CHUNK", "4800"))
|
||||
|
||||
# Module seam for deterministic latency-contract tests. Keep every timing
|
||||
# sample on the same monotonic clock.
|
||||
_perf_counter = time.perf_counter
|
||||
|
||||
|
||||
class StreamTTSRequest(BaseModel):
|
||||
"""Client request for streaming TTS."""
|
||||
@@ -85,7 +90,7 @@ async def ws_tts(websocket: WebSocket):
|
||||
})
|
||||
continue
|
||||
|
||||
t0 = time.perf_counter()
|
||||
t0 = _perf_counter()
|
||||
text = data["text"]
|
||||
|
||||
# Remote GPU: this socket stays on this machine, and says so.
|
||||
@@ -258,6 +263,11 @@ async def ws_tts(websocket: WebSocket):
|
||||
from services.model_manager import run_on_gpu_pool_guarded
|
||||
|
||||
def _generate(sentence_text):
|
||||
# Timed INSIDE the pool worker: the guarded dispatch below
|
||||
# can queue behind other jobs, and queue wait is not
|
||||
# synthesis (review on #1620) — under contention it would
|
||||
# inflate rtf without the engine slowing at all.
|
||||
_synth_t0 = _perf_counter()
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
from services.watermark import mark_synthetic
|
||||
wav = backend.generate(sentence_text, **kw)
|
||||
@@ -279,12 +289,19 @@ async def ws_tts(websocket: WebSocket):
|
||||
# watermark._iter_chunks), which is inherent to marking
|
||||
# ultra-short clips, not a coverage gap.
|
||||
wav = mark_synthetic(wav, sr_actual, context="tts_stream.sentence")
|
||||
return wav, sr_actual
|
||||
return wav, sr_actual, _perf_counter() - _synth_t0
|
||||
|
||||
import torch
|
||||
total_samples = 0
|
||||
sr = backend.sample_rate
|
||||
started = False
|
||||
first_audio_at: float | None = None
|
||||
# Synthesis time only. The wall clock below also carries socket
|
||||
# delivery and the per-chunk event-loop yields, so deriving RTF
|
||||
# from it reports "how slow was the client" as if it were engine
|
||||
# throughput — on a slow consumer that inflates RTF without the
|
||||
# engine having changed at all.
|
||||
synth_time = 0.0
|
||||
|
||||
for sentence in sentences:
|
||||
# Bounded + pool-reset on hang so a wedged generate can't
|
||||
@@ -294,11 +311,12 @@ async def ws_tts(websocket: WebSocket):
|
||||
# Length-scaled budget per sentence (#1190) — the flat 300s
|
||||
# default is gone from every dispatch.
|
||||
from services.model_manager import generate_timeout_s
|
||||
wav_tensor, sr = await run_on_gpu_pool_guarded(
|
||||
wav_tensor, sr, sentence_synth_s = await run_on_gpu_pool_guarded(
|
||||
functools.partial(_generate, sentence),
|
||||
what="TTS generate",
|
||||
timeout=generate_timeout_s(sentence),
|
||||
timeout=generate_timeout_s(sentence, engine=backend),
|
||||
)
|
||||
synth_time += sentence_synth_s
|
||||
|
||||
if not started:
|
||||
# Send metadata after the first generation so
|
||||
@@ -325,25 +343,49 @@ async def ws_tts(websocket: WebSocket):
|
||||
end = min(sent_samples + CHUNK_SAMPLES, n_samples)
|
||||
chunk = pcm_bytes[sent_samples * 2: end * 2]
|
||||
await websocket.send_bytes(chunk)
|
||||
if first_audio_at is None:
|
||||
# TTFA ends when the first audio bytes have been
|
||||
# handed to the socket. The previous log used the
|
||||
# whole-render duration and called it TTFA.
|
||||
first_audio_at = _perf_counter()
|
||||
sent_samples = end
|
||||
# Yield to event loop between chunks for responsiveness
|
||||
await asyncio.sleep(0)
|
||||
total_samples += n_samples
|
||||
|
||||
gen_time = round(time.perf_counter() - t0, 3)
|
||||
finished_at = _perf_counter()
|
||||
wall_time_raw = max(0.0, finished_at - t0)
|
||||
synth_time_raw = max(0.0, synth_time)
|
||||
gen_time = round(wall_time_raw, 3)
|
||||
duration = round(total_samples / sr, 3)
|
||||
ttfa_ms = (
|
||||
round(max(0.0, first_audio_at - t0) * 1000.0, 1)
|
||||
if first_audio_at is not None
|
||||
else None
|
||||
)
|
||||
# RTF is a render metric: synthesis seconds per audio second.
|
||||
rtf = (
|
||||
round(synth_time_raw / (total_samples / sr), 3)
|
||||
if total_samples > 0
|
||||
else None
|
||||
)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "done",
|
||||
"duration_s": duration,
|
||||
"gen_time_s": gen_time,
|
||||
"ttfa_ms": ttfa_ms,
|
||||
"rtf": rtf,
|
||||
"samples": total_samples,
|
||||
"sample_rate": sr,
|
||||
"engine": backend.id,
|
||||
})
|
||||
logger.info(
|
||||
"TTS stream: %.1fs audio in %.1fs (TTFA=%.0fms)",
|
||||
duration, gen_time, gen_time * 1000,
|
||||
"TTS stream: %.1fs audio in %.1fs (TTFA=%s, RTF=%s)",
|
||||
duration,
|
||||
gen_time,
|
||||
f"{ttfa_ms:.0f}ms" if ttfa_ms is not None else "n/a",
|
||||
f"{rtf:.3f}" if rtf is not None else "n/a",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+10
-10
@@ -159,17 +159,16 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
|
||||
label: "Parakeet TDT v3 (sherpa-onnx — dictation, 25 EU langs)"
|
||||
role: ASR
|
||||
size_gb: 0.18
|
||||
size_gb: 0.67
|
||||
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."
|
||||
note: "Multilingual European-language dictation. CPU, int8 ONNX. Requires sherpa-onnx."
|
||||
|
||||
- repo_id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8"
|
||||
label: "Parakeet TDT v2 (sherpa-onnx — dictation, English)"
|
||||
role: ASR
|
||||
size_gb: 0.17
|
||||
size_gb: 0.66
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-parakeet-tdt-v2
|
||||
tag: offline
|
||||
@@ -178,7 +177,7 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"
|
||||
label: "Zipformer Bilingual (sherpa-onnx — streaming, zh+en)"
|
||||
role: ASR
|
||||
size_gb: 0.13
|
||||
size_gb: 0.2
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-zipformer-bilingual-zh-en
|
||||
tag: streaming
|
||||
@@ -187,7 +186,7 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en"
|
||||
label: "Paraformer Bilingual (sherpa-onnx — streaming, zh+en)"
|
||||
role: ASR
|
||||
size_gb: 0.115
|
||||
size_gb: 0.24
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-paraformer-bilingual-zh-en
|
||||
tag: streaming
|
||||
@@ -196,7 +195,7 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17"
|
||||
label: "Zipformer Streaming EN 20M (sherpa-onnx — streaming, English)"
|
||||
role: ASR
|
||||
size_gb: 0.128
|
||||
size_gb: 0.044
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-zipformer-en-20m
|
||||
tag: streaming
|
||||
@@ -205,7 +204,7 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23"
|
||||
label: "Zipformer Streaming ZH 14M (sherpa-onnx — streaming, Chinese)"
|
||||
role: ASR
|
||||
size_gb: 0.074
|
||||
size_gb: 0.025
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-zipformer-zh-14m
|
||||
tag: streaming
|
||||
@@ -214,11 +213,12 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-whisper-tiny"
|
||||
label: "Whisper Tiny (sherpa-onnx — dictation, 90+ langs)"
|
||||
role: ASR
|
||||
size_gb: 0.116
|
||||
size_gb: 0.104
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-whisper-tiny
|
||||
tag: offline
|
||||
note: "Multilingual offline dictation (auto-detect). CPU, int8 ONNX. Requires sherpa-onnx."
|
||||
curated_on: [all]
|
||||
note: "Recommended cross-platform dictation default (auto-detect). CPU, int8 ONNX. Requires sherpa-onnx."
|
||||
|
||||
# ── Diarisation ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -23,9 +23,17 @@ logger = logging.getLogger("omnivoice.events")
|
||||
_listeners: list[asyncio.Queue] = []
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
# The loop that serves /ws/events, captured on first use. Sync FastAPI
|
||||
# endpoints (rename/delete profile, revoke consent) run in threadpool workers
|
||||
# where `asyncio.get_running_loop()` raises, which used to silently drop their
|
||||
# events — the UI then never refetched the voice list (#1158 class).
|
||||
_serving_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
async def subscribe() -> asyncio.Queue:
|
||||
"""Register a new listener. Returns a Queue that receives event dicts."""
|
||||
global _serving_loop
|
||||
_serving_loop = asyncio.get_running_loop()
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||
async with _lock:
|
||||
_listeners.append(q)
|
||||
@@ -57,11 +65,29 @@ def emit(kind: str, payload: dict[str, Any] | None = None) -> None:
|
||||
}
|
||||
event_str = json.dumps(event)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(_broadcast(event_str))
|
||||
caller_loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No event loop running (unlikely in FastAPI context but safe)
|
||||
caller_loop = None
|
||||
target_loop = _serving_loop or caller_loop
|
||||
if target_loop is None:
|
||||
# No serving loop yet — nobody to notify; dropping is correct.
|
||||
logger.debug("No event loop — event dropped: %s", kind)
|
||||
return
|
||||
try:
|
||||
if caller_loop is target_loop:
|
||||
target_loop.create_task(_broadcast(event_str))
|
||||
else:
|
||||
# Sync endpoints and async producers on a foreign loop must both
|
||||
# hand off: the lock and listener queues belong to serving_loop.
|
||||
target_loop.call_soon_threadsafe(_schedule_broadcast, event_str)
|
||||
except RuntimeError:
|
||||
# The serving loop closed between capture and use (app shutdown).
|
||||
logger.debug("Event loop closed — event dropped: %s", kind)
|
||||
|
||||
|
||||
def _schedule_broadcast(event_str: str) -> None:
|
||||
"""Run `_broadcast` on the serving loop; called via call_soon_threadsafe."""
|
||||
asyncio.get_running_loop().create_task(_broadcast(event_str))
|
||||
|
||||
|
||||
async def _broadcast(event_str: str) -> None:
|
||||
@@ -73,11 +99,11 @@ async def _broadcast(event_str: str) -> None:
|
||||
q.put_nowait(event_str)
|
||||
except asyncio.QueueFull:
|
||||
# Slow consumer — drop oldest, then push. Not a race (#1163):
|
||||
# every queue op runs on the single event loop, and there is
|
||||
# no await between the QueueFull and this get_nowait/put_nowait
|
||||
# pair — no consumer can interleave, so get_nowait cannot raise
|
||||
# QueueEmpty here. emit() from a foreign thread drops the event
|
||||
# before ever touching a queue (see the RuntimeError branch).
|
||||
# every queue op runs on the single event loop (a foreign
|
||||
# thread's emit() hands off via call_soon_threadsafe first),
|
||||
# and there is no await between the QueueFull and this
|
||||
# get_nowait/put_nowait pair — no consumer can interleave, so
|
||||
# get_nowait cannot raise QueueEmpty here.
|
||||
try:
|
||||
q.get_nowait()
|
||||
q.put_nowait(event_str)
|
||||
|
||||
@@ -28,6 +28,15 @@ def stream_failure(code: str) -> dict[str, object]:
|
||||
"detail": "Generation capacity is busy. Try again shortly.",
|
||||
"retryable": True,
|
||||
},
|
||||
"generation_timeout": {
|
||||
"code": "generation_timeout",
|
||||
"detail": (
|
||||
"Generation exceeded the compute-time limit. The backend is "
|
||||
"still running; try a shorter passage or raise the generation "
|
||||
"timeout."
|
||||
),
|
||||
"retryable": True,
|
||||
},
|
||||
"invalid_request": {
|
||||
"code": "invalid_request",
|
||||
"detail": "The generation request could not be processed.",
|
||||
|
||||
@@ -28,6 +28,7 @@ packages. The parent only ever spawns it as a subprocess.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -164,6 +165,23 @@ class IndexTTS2Backend(SubprocessBackend):
|
||||
from engines.indextts.bootstrap import resolve_indextts_venv
|
||||
return resolve_indextts_venv()
|
||||
|
||||
@property
|
||||
def recv_timeout_s(self) -> float:
|
||||
# IndexTTS was the only sidecar left on the 60s class default while
|
||||
# pockettts and omnivoice-subprocess both raised theirs. infer() is one
|
||||
# blocking upstream call, so a long passage legitimately outruns 60s and
|
||||
# the parent's watchdog killed a healthy synthesis (#1611). main.py also
|
||||
# heartbeats during infer(), which is what actually proves liveness —
|
||||
# this deadline is the ceiling for a sidecar that has gone genuinely
|
||||
# silent. OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S tunes it.
|
||||
try:
|
||||
v = float(os.environ.get("OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S", "900"))
|
||||
except (ValueError, TypeError):
|
||||
return 900.0
|
||||
if not math.isfinite(v): # reject inf/nan so the deadline can't be disabled
|
||||
return 900.0
|
||||
return max(30.0, v)
|
||||
|
||||
@classmethod
|
||||
def sidecar_script(cls):
|
||||
from engines.indextts.bootstrap import INDEXTTS_SIDECAR_SCRIPT
|
||||
|
||||
@@ -63,11 +63,13 @@ Restrictions:
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
|
||||
@@ -117,11 +119,59 @@ EMOTION_KWARGS_ALLOWLIST = frozenset({
|
||||
# ── wire protocol ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
#: Seconds between keep-alive progress frames during a long blocking call.
|
||||
_HEARTBEAT_S = 5.0
|
||||
|
||||
#: Serializes _send across threads (the heartbeat below + the main loop) so
|
||||
#: concurrent length+body writes can't interleave and corrupt the framing.
|
||||
_send_lock = threading.Lock()
|
||||
|
||||
|
||||
def _send(stream, obj: dict) -> None:
|
||||
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
||||
stream.write(struct.pack("!I", len(body)))
|
||||
stream.write(body)
|
||||
stream.flush()
|
||||
with _send_lock:
|
||||
stream.write(struct.pack("!I", len(body)))
|
||||
stream.write(body)
|
||||
stream.flush()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _heartbeat(stdout, stage: str):
|
||||
"""Emit a progress frame every ~5s for the duration of the block.
|
||||
|
||||
IndexTTS spends the whole of a cold load and the whole of ``infer()``
|
||||
inside one blocking upstream call, saying nothing on the wire. The parent
|
||||
reads that silence two ways, and BOTH kill a perfectly healthy synthesis
|
||||
of a long passage (#1611):
|
||||
|
||||
* ``SubprocessBackend.generate`` re-arms its recv watchdog on every
|
||||
frame, so with no frames it hard-kills the sidecar at recv_timeout_s;
|
||||
* each frame also reports activity to the GPU pool's execution clock
|
||||
(#1367), so with no frames the outer generate budget expires and
|
||||
blames the hardware.
|
||||
|
||||
Raising the deadline alone therefore does not fix long-text generation —
|
||||
the sidecar has to prove it is alive. Percent climbs 1..99 because the
|
||||
upstream call exposes no real progress; it is a liveness signal, not a
|
||||
measurement.
|
||||
"""
|
||||
stop = threading.Event()
|
||||
|
||||
def _beat() -> None:
|
||||
pct = 1
|
||||
while not stop.wait(_HEARTBEAT_S):
|
||||
pct = min(pct + 1, 99)
|
||||
try:
|
||||
_send(stdout, {"op": "progress", "stage": stage, "percent": pct})
|
||||
except Exception:
|
||||
return # pipe gone — the main loop will surface it
|
||||
hb = threading.Thread(target=_beat, name=f"indextts-{stage}-heartbeat", daemon=True)
|
||||
hb.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop.set()
|
||||
hb.join(timeout=_HEARTBEAT_S + 1)
|
||||
|
||||
|
||||
def _recv(stream):
|
||||
@@ -160,14 +210,40 @@ def _torch_bf16_supported() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
#: Model-config filenames to look for, most-preferred first, per version.
|
||||
#: IndexTeam/IndexTTS-2.5 ships ``config.yaml``; VoiceStudio used to demand
|
||||
#: ``config_v2_5.yaml``, a name that exists in no upstream revision, so the
|
||||
#: install failed until the user hand-renamed the file (#1611). Both names are
|
||||
#: accepted now — the hand-renamed installs must keep working untouched — and
|
||||
#: the renamed one wins, because a user who created it did so deliberately.
|
||||
_CFG_NAMES = {
|
||||
"2.5": ("config_v2_5.yaml", "config.yaml"),
|
||||
"2": ("config.yaml",),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_cfg_path(model_dir: str, *, version: str) -> str:
|
||||
"""First accepted config that exists in ``model_dir``.
|
||||
|
||||
Falls back to the last candidate when none exist, so the failure surfaces
|
||||
as upstream's own "no such file" naming a real expected path rather than
|
||||
a name no upstream release has ever shipped.
|
||||
"""
|
||||
names = _CFG_NAMES.get(version, _CFG_NAMES["2"])
|
||||
for name in names:
|
||||
candidate = os.path.join(model_dir, name)
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return os.path.join(model_dir, names[-1])
|
||||
|
||||
|
||||
def _model_init_kwargs(
|
||||
repo_dir: str, *, version: str, reduced_precision: bool,
|
||||
) -> dict:
|
||||
"""Build version-specific constructor arguments for IndexTTS 2.5 or 2."""
|
||||
model_dir = os.path.join(repo_dir, "checkpoints")
|
||||
cfg_name = "config_v2_5.yaml" if version == "2.5" else "config.yaml"
|
||||
kwargs = {
|
||||
"cfg_path": os.path.join(model_dir, cfg_name),
|
||||
"cfg_path": _resolve_cfg_path(model_dir, version=version),
|
||||
"model_dir": model_dir,
|
||||
"use_cuda_kernel": False,
|
||||
"use_deepspeed": False,
|
||||
@@ -216,7 +292,8 @@ def _load_model(stdout) -> object:
|
||||
model_kw = _model_init_kwargs(
|
||||
repo_dir, version=_model_version, reduced_precision=reduced_precision,
|
||||
)
|
||||
_model = IndexTTS2(**model_kw)
|
||||
with _heartbeat(stdout, "loading_model"):
|
||||
_model = IndexTTS2(**model_kw)
|
||||
|
||||
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
|
||||
return _model
|
||||
@@ -276,7 +353,10 @@ def _handle_synthesize(msg: dict, stdout) -> None:
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
infer_kw["output_path"] = tmp_path
|
||||
model.infer(**infer_kw)
|
||||
# A long passage keeps infer() busy for minutes with nothing on the
|
||||
# wire; without this the parent kills the sidecar mid-synthesis (#1611).
|
||||
with _heartbeat(stdout, "synthesizing"):
|
||||
model.infer(**infer_kw)
|
||||
pcm_b64, sr, n_samples = _wav_to_pcm_b64(tmp_path)
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -151,6 +151,40 @@ def _pocket_language(raw) -> str:
|
||||
)
|
||||
|
||||
|
||||
_TRUTHY = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _has_24l_config(language: str) -> bool:
|
||||
"""Whether the installed pocket-tts ships a 24-layer checkpoint for
|
||||
``language`` (it/de/es/pt/fr in 2.1.0; english has none)."""
|
||||
try:
|
||||
from pocket_tts.models.tts_model import CONFIGS_DIR # type: ignore[import-not-found] # noqa: PLC0415
|
||||
except Exception as exc: # noqa: BLE001 — absence of the package is not fatal here
|
||||
# Log it, though: if a future pocket-tts moves CONFIGS_DIR, the 24L
|
||||
# opt-in would otherwise go silently inert.
|
||||
print(f"pockettts sidecar: 24l config probe failed: {exc!r}", file=sys.stderr)
|
||||
return False
|
||||
from pathlib import Path # noqa: PLC0415
|
||||
|
||||
return (Path(CONFIGS_DIR) / f"{language}_24l.yaml").is_file()
|
||||
|
||||
|
||||
def _model_config_name(language: str) -> str:
|
||||
"""Pocket-tts config name to load: the 6-layer default, or the 24-layer
|
||||
checkpoint when OMNIVOICE_POCKETTTS_24L is set and one exists for the
|
||||
language. Opt-in only — defaults keep the fast model; the 24-layer variant
|
||||
trades roughly 4x transformer compute for better prosody.
|
||||
|
||||
French is the exception: pocket-tts 2.1.0 only ships a 24-layer French
|
||||
model and load_model(language="french") raises, so French always maps to
|
||||
french_24l regardless of the env var."""
|
||||
if language == "french":
|
||||
return "french_24l"
|
||||
if os.environ.get("OMNIVOICE_POCKETTTS_24L", "").strip().lower() not in _TRUTHY:
|
||||
return language
|
||||
return f"{language}_24l" if _has_24l_config(language) else language
|
||||
|
||||
|
||||
def _load_model(stdout, language: str):
|
||||
"""Cold-construct the PocketTTS model for ``language`` (cached per language).
|
||||
Emits progress frames for the parent watchdog. Raises on failure (e.g.
|
||||
@@ -178,7 +212,7 @@ def _load_model(stdout, language: str):
|
||||
try:
|
||||
from pocket_tts import TTSModel # type: ignore[import-not-found] # noqa: PLC0415
|
||||
|
||||
model = TTSModel.load_model(language=language)
|
||||
model = TTSModel.load_model(language=_model_config_name(language))
|
||||
_MODELS[language] = model
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
+85
-16
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -369,19 +370,36 @@ def _env_flag(name: str, default: bool = False) -> bool:
|
||||
_EAGER = _env_flag("OMNIVOICE_EAGER_INIT", default=("pytest" in sys.modules))
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
"""Parse a float env override, rejecting negative and non-finite values.
|
||||
|
||||
Shared by the preload-delay / timeout knobs: NaN would silently never
|
||||
fire, a negative would fire during startup I/O, so both fall back to the
|
||||
default instead (the bug class CodeRabbit flagged on the watermark knob
|
||||
in PR #1577 — latent in the older copies too, closed here for all)."""
|
||||
raw = os.environ.get(name, "")
|
||||
try:
|
||||
value = float(raw) if raw.strip() else default
|
||||
except ValueError:
|
||||
return default
|
||||
return value if math.isfinite(value) and value >= 0 else default
|
||||
|
||||
|
||||
def _capture_preload_delay_s() -> float:
|
||||
"""Seconds after boot before the dictation (capture ASR) model warms.
|
||||
|
||||
Late enough that it never competes with startup I/O or the TTS preload;
|
||||
overridable via OMNIVOICE_CAPTURE_PRELOAD_DELAY (mostly for tests)."""
|
||||
raw = os.environ.get("OMNIVOICE_CAPTURE_PRELOAD_DELAY", "")
|
||||
try:
|
||||
v = float(raw)
|
||||
if v >= 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 30.0
|
||||
return _env_float("OMNIVOICE_CAPTURE_PRELOAD_DELAY", 30.0)
|
||||
|
||||
def _watermark_preload_delay_s() -> float:
|
||||
"""Seconds after boot before the AudioSeal generator warm-up fires.
|
||||
|
||||
Own knob, NOT ``_capture_preload_delay_s`` + offset: a capture-specific
|
||||
env override must not retime the watermark warm too, and the two cold
|
||||
imports shouldn't fire on the same tick (CodeRabbit, PR #1577). Default
|
||||
35s sits ~5s past the capture-ASR warm for the same reason."""
|
||||
return _env_float("OMNIVOICE_PRELOAD_WATERMARK_DELAY", 35.0)
|
||||
|
||||
|
||||
def _capture_preload_ram_ok(min_free_bytes: int = 4 * 1024**3) -> bool:
|
||||
@@ -398,14 +416,7 @@ def _capture_preload_ram_ok(min_free_bytes: int = 4 * 1024**3) -> bool:
|
||||
def _mcp_start_timeout_s() -> float:
|
||||
"""Seconds to wait for the MCP session manager to start before giving up
|
||||
and serving without it (#632). Overridable via OMNIVOICE_MCP_START_TIMEOUT_S."""
|
||||
raw = os.environ.get("OMNIVOICE_MCP_START_TIMEOUT_S", "")
|
||||
try:
|
||||
v = float(raw)
|
||||
if v > 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 30.0
|
||||
return max(_env_float("OMNIVOICE_MCP_START_TIMEOUT_S", 30.0), 0.001)
|
||||
|
||||
|
||||
async def _serve_mcp(session_manager, ready: "asyncio.Event", stop: "asyncio.Event") -> None:
|
||||
@@ -852,6 +863,8 @@ async def _phase_b(app: FastAPI) -> None:
|
||||
# #1174: arm model loads for THIS run — an in-process relaunch may carry a
|
||||
# stale shutting-down flag from a previous lifespan.
|
||||
model_loads_reset_shutdown()
|
||||
from services.model_manager import begin_watermark_pool_lifecycle
|
||||
begin_watermark_pool_lifecycle()
|
||||
app.state.idle_task = asyncio.create_task(idle_worker())
|
||||
app.state.worker_task = asyncio.create_task(task_manager.worker())
|
||||
# Warm the TTS model in the background so first /generate is instant.
|
||||
@@ -905,6 +918,50 @@ async def _phase_b(app: FastAPI) -> None:
|
||||
else:
|
||||
logger.info("Capture ASR preload disabled; dictation ASR will load on first use.")
|
||||
|
||||
# Watermark: warm the AudioSeal generator in the background so the first
|
||||
# mark_synthetic doesn't serialize the audioseal import + model load
|
||||
# inside the first synthesis (measured ~42 s inline on a cold filesystem,
|
||||
# 2026-08-17 macOS report — 3 s short of the client's 90 s timeout).
|
||||
# Small model on CPU; deferred a few seconds past the capture-ASR warm so
|
||||
# the two cold imports don't contend for the same disk, and no RAM guard
|
||||
# is needed. Runs on the watermark pool — where the model is used — not
|
||||
# the shared default executor.
|
||||
if _env_flag("OMNIVOICE_PRELOAD_WATERMARK", default=True):
|
||||
async def _preload_watermark():
|
||||
await asyncio.sleep(_watermark_preload_delay_s())
|
||||
loop = asyncio.get_running_loop()
|
||||
from services import watermark as _watermark
|
||||
|
||||
# Gate BEFORE touching get_watermark_pool(): the pool is lazy so
|
||||
# hosts with watermarking disabled never spawn its thread, and
|
||||
# creating it unconditionally would break that invariant. The
|
||||
# race with a first embed is benign — pool creation is itself
|
||||
# lock-guarded.
|
||||
if not _watermark.will_mark():
|
||||
logger.debug("Watermark preload skipped (disabled or audioseal absent)")
|
||||
return
|
||||
from services.model_manager import get_watermark_pool
|
||||
|
||||
# Default startup may warm an existing local checkpoint but may
|
||||
# not fetch one. Only an explicit user opt-in permits a download.
|
||||
raw_preload = os.environ.get("OMNIVOICE_PRELOAD_WATERMARK", "")
|
||||
allow_download = raw_preload.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
get_watermark_pool(),
|
||||
lambda: _watermark.prefetch_generator(
|
||||
allow_download=allow_download
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# prefetch_generator swallows its own errors; this guards the
|
||||
# setup half (imports, pool construction) so a broken warm-up
|
||||
# is visible now, not as an unretrieved exception at shutdown.
|
||||
logger.warning("Watermark preload task failed", exc_info=True)
|
||||
|
||||
app.state.watermark_preload_task = asyncio.create_task(_preload_watermark())
|
||||
|
||||
# ── MCP session manager (Wave 2.2) ────────────────────────────────────
|
||||
# Run it in its OWN task owning the full enter→exit lifecycle (anyio
|
||||
# task-affinity, see _serve_mcp); only wait, with a timeout, for ready —
|
||||
@@ -1080,8 +1137,20 @@ async def lifespan(app: FastAPI):
|
||||
getattr(app.state, "worker_task", None),
|
||||
getattr(app.state, "preload_task", None),
|
||||
getattr(app.state, "capture_preload_task", None),
|
||||
getattr(app.state, "watermark_preload_task", None),
|
||||
timeout=20.0,
|
||||
)
|
||||
# The watermark warm-up runs on its dedicated 1-worker pool. Cancellation
|
||||
# detaches the asyncio future but cannot kill a thread inside AudioSeal,
|
||||
# so drain it fully before lifespan teardown reports completion.
|
||||
try:
|
||||
from services.model_manager import shutdown_watermark_pool as _wm_drain
|
||||
|
||||
_wm_drain()
|
||||
except Exception:
|
||||
# Best-effort drain: a failure here must not abort the remaining
|
||||
# shutdown steps (model unload, MCP teardown) below.
|
||||
logger.warning("Watermark pool drain failed at shutdown", exc_info=True)
|
||||
# Unload the model and free GPU memory
|
||||
try:
|
||||
import services.model_manager as mm
|
||||
|
||||
@@ -2564,7 +2564,10 @@ def _auto_detect() -> str:
|
||||
def active_backend_id() -> str:
|
||||
explicit = os.environ.get("OMNIVOICE_ASR_BACKEND")
|
||||
if explicit:
|
||||
return explicit
|
||||
# #1582's public spelling predates the registry name. Keep it as a
|
||||
# compatibility alias for the PyTorch-native Whisper implementation
|
||||
# that can use ROCm/HIP; every ASR consumer resolves through here.
|
||||
return "pytorch-whisper" if explicit == "omnivoice" else explicit
|
||||
from core import prefs
|
||||
picked = prefs.get("asr_backend")
|
||||
if picked:
|
||||
@@ -2992,7 +2995,7 @@ def _capture_prefers_parakeet() -> bool:
|
||||
return _parakeet_mlx_installed()
|
||||
|
||||
|
||||
def get_capture_asr_backend() -> ASRBackend:
|
||||
def get_capture_asr_backend(*, skip_sherpa: bool = False) -> ASRBackend:
|
||||
"""Pick the fastest ASR engine for capture / dictation.
|
||||
|
||||
Selection order:
|
||||
@@ -3017,6 +3020,9 @@ def get_capture_asr_backend() -> ASRBackend:
|
||||
|
||||
Returns a cached singleton so the model stays warm between calls; the
|
||||
singleton is rebuilt if the selected sherpa model changes.
|
||||
|
||||
``skip_sherpa`` is used only to validate a token-silent Sherpa result with
|
||||
the installed capture fallback before persisting model demotion.
|
||||
"""
|
||||
global _capture_backend, _capture_backend_key
|
||||
|
||||
@@ -3025,7 +3031,7 @@ def get_capture_asr_backend() -> ASRBackend:
|
||||
# call get_sherpa_dictation_backend concurrently) can't both build a model.
|
||||
with _capture_backend_lock:
|
||||
# 0. Honor an explicit sherpa dictation model selection.
|
||||
sherpa_id = dictation_model_id()
|
||||
sherpa_id = None if skip_sherpa else dictation_model_id()
|
||||
if sherpa_id:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
if ok:
|
||||
@@ -3192,7 +3198,10 @@ def _capture_whisper_repo() -> str | None:
|
||||
return os.environ.get("OMNIVOICE_PYTORCH_ASR_MODEL", _PYTORCH_ASR_DEFAULT)
|
||||
|
||||
|
||||
def _recommended_asr_model(purpose: str, missing_repo: str | None) -> dict | None:
|
||||
def _recommended_asr_model(
|
||||
purpose: str, missing_repo: str | None, *, prefer_sherpa: bool = True,
|
||||
excluded_sherpa_model_id: str | None = None,
|
||||
) -> dict | None:
|
||||
"""The catalog entry to offer in the download CTA.
|
||||
|
||||
Offline: the missing repo itself when it's in the catalog (guarantees
|
||||
@@ -3212,20 +3221,38 @@ def _recommended_asr_model(purpose: str, missing_repo: str | None) -> dict | Non
|
||||
|
||||
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":
|
||||
|
||||
def _eligible(m: dict, *, sherpa: bool) -> bool:
|
||||
if (m.get("engine") == "sherpa-onnx") != sherpa:
|
||||
return False
|
||||
if sherpa and m.get("dictation_id") == excluded_sherpa_model_id:
|
||||
return False
|
||||
return _model_supported(m)
|
||||
|
||||
if purpose != "dictation":
|
||||
if exact is not None and _model_supported(exact):
|
||||
return _shape(exact)
|
||||
prefer_sherpa = False
|
||||
|
||||
if purpose == "dictation" and prefer_sherpa:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
want_sherpa = ok
|
||||
if not want_sherpa and exact is not None and _model_supported(exact):
|
||||
if ok:
|
||||
if exact is not None and _eligible(exact, sherpa=True):
|
||||
return _shape(exact)
|
||||
for m in KNOWN_MODELS:
|
||||
if (m.get("role") == "ASR" and _eligible(m, sherpa=True)
|
||||
and _model_curated(m)):
|
||||
return _shape(m)
|
||||
|
||||
# No usable Sherpa recommendation remains (runtime unavailable, explicit
|
||||
# fallback probe, or the sole curated entry is the demoted model). Offer
|
||||
# the exact capture fallback so download → retry cannot loop.
|
||||
if exact is not None and _eligible(exact, sherpa=False):
|
||||
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):
|
||||
if _eligible(m, sherpa=False) and _model_curated(m):
|
||||
return _shape(m)
|
||||
return None
|
||||
|
||||
@@ -3256,7 +3283,9 @@ def _repo_installed(repo: str) -> bool:
|
||||
|
||||
def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
sherpa_model_id: str | None = None,
|
||||
backend_id: str | None = None) -> dict | None:
|
||||
backend_id: str | None = None,
|
||||
skip_sherpa: bool = False,
|
||||
require_installed: bool = False) -> 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.
|
||||
@@ -3268,6 +3297,11 @@ def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
``?model=`` override. Installed state comes from the same HF-cache helpers
|
||||
the model store uses (see :func:`_repo_installed`), so the answer matches
|
||||
the Model Catalogue → Models install badges.
|
||||
``skip_sherpa`` probes only the non-Sherpa capture fallback; silent-model
|
||||
recovery uses it before deciding whether persistent demotion is warranted.
|
||||
``require_installed`` makes unknown/custom selections fail closed for that
|
||||
recovery path so it can never turn the normal fail-open policy into an
|
||||
implicit model download.
|
||||
|
||||
FAIL-OPEN rule: a repo the model catalog doesn't know (a custom
|
||||
``ASR_MODEL_*`` pin, pytorch-whisper's default repo, an unrecognized
|
||||
@@ -3277,27 +3311,55 @@ def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
a broken preflight must degrade to the old behaviour, not block ASR.
|
||||
"""
|
||||
try:
|
||||
prefer_sherpa_recommendation = not skip_sherpa
|
||||
excluded_sherpa_model_id = None
|
||||
if purpose == "dictation":
|
||||
sid = sherpa_model_id or dictation_model_id()
|
||||
sid = None if skip_sherpa else (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)
|
||||
# A recognizer observed returning silence must follow the
|
||||
# same capture fallback as execution, even when the
|
||||
# frontend keeps sending its persisted `?model=` value.
|
||||
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),
|
||||
}
|
||||
if _sd.is_demoted(spec.id):
|
||||
excluded_sherpa_model_id = spec.id
|
||||
else:
|
||||
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(backend_id)
|
||||
if repo is None:
|
||||
if require_installed:
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": "unresolved-capture-fallback",
|
||||
"recommended": None,
|
||||
}
|
||||
return None # explicit opt-in engine — can't (and shouldn't) preflight
|
||||
from api.routers.setup.models import get_model_catalog
|
||||
if require_installed:
|
||||
if _repo_installed(repo):
|
||||
return None
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": repo,
|
||||
"recommended": _recommended_asr_model(
|
||||
purpose, repo,
|
||||
prefer_sherpa=prefer_sherpa_recommendation,
|
||||
excluded_sherpa_model_id=excluded_sherpa_model_id,
|
||||
),
|
||||
}
|
||||
if get_model_catalog().get(repo) is None:
|
||||
return None # not installable from the CTA — fail open (see docstring)
|
||||
if _repo_installed(repo):
|
||||
@@ -3305,7 +3367,11 @@ def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": repo,
|
||||
"recommended": _recommended_asr_model(purpose, repo),
|
||||
"recommended": _recommended_asr_model(
|
||||
purpose, repo,
|
||||
prefer_sherpa=prefer_sherpa_recommendation,
|
||||
excluded_sherpa_model_id=excluded_sherpa_model_id,
|
||||
),
|
||||
}
|
||||
except Exception: # noqa: BLE001 — preflight is best-effort, never a blocker
|
||||
logger.warning("ASR install preflight failed — proceeding without it",
|
||||
|
||||
@@ -4,8 +4,9 @@ import sys
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, Executor
|
||||
from concurrent.futures import Executor, Future, ThreadPoolExecutor
|
||||
|
||||
from utils.containment import contain_system_exit
|
||||
|
||||
@@ -397,7 +398,13 @@ def __getattr__(name: str):
|
||||
# (generation.py, tts_stream.py) were the last unguarded dispatch — and the
|
||||
# residual on-main reports all fail on generate:start (audio). This is the same
|
||||
# guard generalised so every GPU dispatch shares one recovery path.
|
||||
_GENERATE_TIMEOUT_EXPLICIT = "OMNIVOICE_GENERATE_TIMEOUT_S" in os.environ
|
||||
GPU_JOB_TIMEOUT_S = float(os.environ.get("OMNIVOICE_GENERATE_TIMEOUT_S", "300.0"))
|
||||
_CONFIGURED_GPU_JOB_TIMEOUT_S = GPU_JOB_TIMEOUT_S
|
||||
# CPU synthesis is healthy but substantially slower than accelerated inference.
|
||||
# Keep a separate, bounded floor so a short render on CPU is not abandoned at
|
||||
# the GPU-oriented five-minute deadline (#1588).
|
||||
CPU_JOB_TIMEOUT_S = float(os.environ.get("OMNIVOICE_CPU_GENERATE_TIMEOUT_S", "600.0"))
|
||||
|
||||
# Queue-wait budget — a SEPARATE, deliberately generous clock (#1190/#1202).
|
||||
# The execution bound above must never be spent waiting in line: a job queued
|
||||
@@ -500,7 +507,9 @@ class GpuPoolBusyError(TimeoutError):
|
||||
self.retry_after = max(1, int(round(retry_after)))
|
||||
|
||||
|
||||
def generate_timeout_s(text: "str | None") -> float:
|
||||
def generate_timeout_s(
|
||||
text: "str | None", *, engine: object = None, execution_device: "str | None" = None,
|
||||
) -> float:
|
||||
"""THE wall-clock execution budget for one synthesis job, scaled to input.
|
||||
|
||||
Single source of truth for every TTS dispatch (#1190/#1202). The
|
||||
@@ -516,10 +525,33 @@ def generate_timeout_s(text: "str | None") -> float:
|
||||
CPU-class hardware, still bounded (a wedged job is caught in minutes, not
|
||||
hours).
|
||||
"""
|
||||
return max(
|
||||
GPU_JOB_TIMEOUT_S,
|
||||
GPU_JOB_TIMEOUT_S + (max(0, len(text or "") - 1200) / 40.0),
|
||||
)
|
||||
base = GPU_JOB_TIMEOUT_S
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
family = execution_device or detect_host_caps().family
|
||||
if execution_device is None and engine is not None:
|
||||
from services.engine_routing import resolve_routing
|
||||
compat = getattr(engine, "gpu_compat", None)
|
||||
if compat is None:
|
||||
compat = getattr(type(engine), "gpu_compat", (family, "cpu"))
|
||||
if tuple(compat) == ("cpu",):
|
||||
family = "cpu"
|
||||
else:
|
||||
family = resolve_routing(
|
||||
compat, detect_host_caps(),
|
||||
float(getattr(engine, "min_vram_gb", 0.0) or 0.0),
|
||||
)["effective_device"]
|
||||
universal_override = (
|
||||
_GENERATE_TIMEOUT_EXPLICIT
|
||||
or GPU_JOB_TIMEOUT_S != _CONFIGURED_GPU_JOB_TIMEOUT_S
|
||||
)
|
||||
if family == "cpu" and not universal_override:
|
||||
base = CPU_JOB_TIMEOUT_S
|
||||
except Exception:
|
||||
# Device probing is advisory here; the configured universal bound is
|
||||
# still safe when a platform probe is unavailable during startup.
|
||||
pass
|
||||
return base + (max(0, len(text or "") - 1200) / 40.0)
|
||||
|
||||
|
||||
def _retry_after_estimate(stats: dict) -> float:
|
||||
@@ -1057,21 +1089,166 @@ def _timeout_guidance(
|
||||
# doubling the effective queue depth of a streamed multi-chunk render.
|
||||
# Giving it its own tiny pool removes that head-of-line blocking with no VRAM
|
||||
# risk, because the work was never on the device to begin with.
|
||||
_watermark_pool_singleton: "ThreadPoolExecutor | None" = None
|
||||
_watermark_pool_lock = threading.Lock()
|
||||
_WATERMARK_STOP = object()
|
||||
|
||||
|
||||
def get_watermark_pool() -> ThreadPoolExecutor:
|
||||
"""Dedicated 1-worker pool for provenance marking. Built lazily so hosts
|
||||
with watermarking disabled never spawn the thread."""
|
||||
global _watermark_pool_singleton
|
||||
if _watermark_pool_singleton is None:
|
||||
with _watermark_pool_lock:
|
||||
if _watermark_pool_singleton is None:
|
||||
_watermark_pool_singleton = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="watermark",
|
||||
class _WatermarkExecutor(Executor):
|
||||
"""Single daemon worker with a bounded shutdown contract.
|
||||
|
||||
``ThreadPoolExecutor`` uses non-daemon workers that Python joins at exit,
|
||||
so ``wait=False`` still delays process exit while ``wait=True`` can hang
|
||||
lifespan teardown forever. AudioSeal loading is not cooperatively
|
||||
cancellable; a daemon worker plus a bounded join is the only thread-based
|
||||
contract that both preserves in-process model warm-up and guarantees exit.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._items: queue.Queue = queue.Queue()
|
||||
self._lock = threading.Lock()
|
||||
self._shutdown = False
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def submit(self, fn, /, *args, **kwargs) -> Future:
|
||||
future: Future = Future()
|
||||
with self._lock:
|
||||
if self._shutdown:
|
||||
raise RuntimeError("cannot schedule new futures after shutdown")
|
||||
if self._thread is None:
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="watermark_0",
|
||||
daemon=True,
|
||||
)
|
||||
return _watermark_pool_singleton
|
||||
self._thread.start()
|
||||
self._items.put((future, fn, args, kwargs))
|
||||
return future
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
item = self._items.get()
|
||||
if item is _WATERMARK_STOP:
|
||||
return
|
||||
future, fn, args, kwargs = item
|
||||
if not future.set_running_or_notify_cancel():
|
||||
continue
|
||||
try:
|
||||
future.set_result(fn(*args, **kwargs))
|
||||
except (Exception, SystemExit, KeyboardInterrupt) as exc:
|
||||
future.set_exception(exc)
|
||||
|
||||
def is_stopped(self) -> bool:
|
||||
"""Whether shutdown has completed and this executor can be replaced."""
|
||||
with self._lock:
|
||||
return self._shutdown and (
|
||||
self._thread is None or not self._thread.is_alive()
|
||||
)
|
||||
|
||||
def is_shutdown(self) -> bool:
|
||||
with self._lock:
|
||||
return self._shutdown
|
||||
|
||||
def shutdown(
|
||||
self,
|
||||
wait: bool = True,
|
||||
*,
|
||||
cancel_futures: bool = False,
|
||||
timeout: float | None = None,
|
||||
) -> bool:
|
||||
with self._lock:
|
||||
self._shutdown = True
|
||||
thread = self._thread
|
||||
if cancel_futures:
|
||||
while True:
|
||||
try:
|
||||
item = self._items.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if item is not _WATERMARK_STOP:
|
||||
item[0].cancel()
|
||||
self._items.put(_WATERMARK_STOP)
|
||||
if wait and thread is not None:
|
||||
thread.join(timeout=timeout)
|
||||
return thread is None or not thread.is_alive()
|
||||
|
||||
|
||||
_watermark_pool_singleton: "_WatermarkExecutor | None" = None
|
||||
_watermark_pool_lock = threading.Lock()
|
||||
_watermark_pool_accepting = True
|
||||
|
||||
|
||||
def begin_watermark_pool_lifecycle() -> None:
|
||||
"""Open watermark submissions for a newly-started app lifespan."""
|
||||
global _watermark_pool_accepting, _watermark_pool_singleton
|
||||
with _watermark_pool_lock:
|
||||
if (
|
||||
_watermark_pool_singleton is not None
|
||||
and _watermark_pool_singleton.is_stopped()
|
||||
):
|
||||
_watermark_pool_singleton = None
|
||||
_watermark_pool_accepting = (
|
||||
_watermark_pool_singleton is None
|
||||
or not _watermark_pool_singleton.is_shutdown()
|
||||
)
|
||||
|
||||
|
||||
def get_watermark_pool() -> _WatermarkExecutor:
|
||||
"""Dedicated 1-worker pool for provenance marking. Built lazily so hosts
|
||||
with watermarking disabled never spawn the thread.
|
||||
|
||||
The executor is captured and returned UNDER the lock: reading the global
|
||||
again after an unlocked null-check could race shutdown_watermark_pool's
|
||||
reset and hand out None (CodeRabbit, PR #1577)."""
|
||||
global _watermark_pool_accepting, _watermark_pool_singleton
|
||||
with _watermark_pool_lock:
|
||||
if not _watermark_pool_accepting:
|
||||
if (
|
||||
_watermark_pool_singleton is not None
|
||||
and _watermark_pool_singleton.is_stopped()
|
||||
):
|
||||
_watermark_pool_singleton = None
|
||||
_watermark_pool_accepting = True
|
||||
else:
|
||||
raise RuntimeError("watermark executor is shutting down")
|
||||
if (
|
||||
_watermark_pool_singleton is not None
|
||||
and _watermark_pool_singleton.is_stopped()
|
||||
):
|
||||
_watermark_pool_singleton = None
|
||||
if _watermark_pool_singleton is None:
|
||||
_watermark_pool_singleton = _WatermarkExecutor()
|
||||
return _watermark_pool_singleton
|
||||
|
||||
|
||||
def shutdown_watermark_pool(*, timeout: float = 20.0) -> None:
|
||||
"""Drain the watermark pool at app shutdown (PR #1577).
|
||||
|
||||
Refuse queued work and wait for the active operation: Python cannot kill
|
||||
a thread inside AudioSeal loading, so returning early would let model
|
||||
initialization continue during interpreter teardown. The draining pool
|
||||
remains published until its worker stops, preventing concurrent producers
|
||||
from creating a replacement that escapes this shutdown. A process that
|
||||
keeps running after lifespan shutdown (the test suite does exactly this)
|
||||
gets a fresh pool once the old worker has actually stopped."""
|
||||
global _watermark_pool_accepting, _watermark_pool_singleton
|
||||
with _watermark_pool_lock:
|
||||
_watermark_pool_accepting = False
|
||||
pool = _watermark_pool_singleton
|
||||
if pool is not None:
|
||||
stopped = pool.shutdown(
|
||||
wait=True,
|
||||
cancel_futures=True,
|
||||
timeout=max(0.0, float(timeout)),
|
||||
)
|
||||
if stopped:
|
||||
with _watermark_pool_lock:
|
||||
if _watermark_pool_singleton is pool:
|
||||
_watermark_pool_singleton = None
|
||||
else:
|
||||
logger.warning(
|
||||
"Watermark worker exceeded the %.1fs shutdown deadline; "
|
||||
"abandoning its daemon thread",
|
||||
timeout,
|
||||
)
|
||||
|
||||
|
||||
model = None # type: ignore
|
||||
|
||||
@@ -110,7 +110,7 @@ class SherpaModelSpec:
|
||||
# the same HF tree API on 2026-08-07 — not estimated. Every one of the seven
|
||||
# was wrong before, and in both directions, which is worse than uniformly
|
||||
# optimistic: the two Parakeets under-reported by ~3.8x (0.18 -> 0.67 GB),
|
||||
# so the recommended default quietly downloaded four times what the picker
|
||||
# so installing v3 quietly downloaded four times what the picker
|
||||
# promised on a metered or small-disk machine; but the two low-RAM
|
||||
# zipformers OVER-reported by ~3x (0.128 -> 0.044), making the fallback
|
||||
# models look bulkier than the heavyweights they exist to rescue users
|
||||
@@ -129,7 +129,6 @@ _MODELS: dict[str, SherpaModelSpec] = {
|
||||
kind="offline-transducer",
|
||||
size_gb=0.67,
|
||||
languages="25 European languages",
|
||||
recommended=True,
|
||||
heavy=True,
|
||||
model_type="nemo_transducer",
|
||||
files={
|
||||
@@ -223,6 +222,7 @@ _MODELS: dict[str, SherpaModelSpec] = {
|
||||
kind="offline-whisper",
|
||||
size_gb=0.104,
|
||||
languages="90+ languages (auto-detect)",
|
||||
recommended=True,
|
||||
files={
|
||||
"encoder": "tiny-encoder.int8.onnx",
|
||||
"decoder": "tiny-decoder.int8.onnx",
|
||||
@@ -231,7 +231,7 @@ _MODELS: dict[str, SherpaModelSpec] = {
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_MODEL_ID = "sherpa-parakeet-tdt-v3"
|
||||
DEFAULT_MODEL_ID = "sherpa-whisper-tiny"
|
||||
|
||||
# repo_id → model id, so the model-store list (keyed by repo_id) can be
|
||||
# enriched with the dictation metadata, and so capture can map either key.
|
||||
@@ -261,6 +261,16 @@ def sherpa_available() -> tuple[bool, str]:
|
||||
return True, "ready"
|
||||
except ImportError as e:
|
||||
return False, f"sherpa-onnx not installed: {e}. Install with: uv add sherpa-onnx"
|
||||
except Exception as e: # noqa: BLE001 — an availability probe must fail closed
|
||||
# Native wheel failures surface as OSError/RuntimeError rather than
|
||||
# ImportError (missing DLL/dylib/so, loader or runtime init failure) —
|
||||
# but the set is open-ended: an extension module is free to raise
|
||||
# anything at init. This is an availability question, so ANY failure to
|
||||
# import means "not available", never an exception escaping to the
|
||||
# caller. SherpaDictationBackend.is_available() calls this directly and
|
||||
# capture_ws.ws_transcribe calls that without a guard, so an unexpected
|
||||
# type here took the WebSocket down instead of falling back (#1610).
|
||||
return False, f"sherpa-onnx unavailable ({type(e).__name__}): {e}"
|
||||
|
||||
|
||||
def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
|
||||
@@ -397,13 +407,10 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
|
||||
# transcribe the same bytes. It is a defect inside sherpa-onnx that the app
|
||||
# cannot fix by configuration.
|
||||
#
|
||||
# The curated default therefore cannot be trusted to WORK just because it is
|
||||
# installed — and which platforms are affected is not knowable up front, so
|
||||
# hard-coding a different default per OS would only be a guess. Instead the app
|
||||
# learns from what it observes: when a session hears real speech and the model
|
||||
# returns nothing, that model is demoted on THIS machine and stops being
|
||||
# selected. Self-correcting wherever the breakage actually is, and a no-op
|
||||
# everywhere it isn't.
|
||||
# Installation alone therefore cannot prove that a recognizer works. When a
|
||||
# session hears real speech and the model returns nothing, that model is
|
||||
# demoted on this machine and stops being selected. This self-corrects wherever
|
||||
# the decoder defect appears and is a no-op everywhere it does not.
|
||||
|
||||
#: prefs key holding the list of model ids demoted on this machine.
|
||||
PREF_SILENT_MODELS = "dictation.silent_models"
|
||||
|
||||
@@ -108,7 +108,11 @@ class SidecarSpec:
|
||||
weights_repo_id: Optional[str] = None # HF repo downloaded into <checkout>/<weights_subdir>
|
||||
weights_revision: Optional[str] = None # reviewed HF commit
|
||||
weights_subdir: str = "checkpoints"
|
||||
weights_config_name: str = "config.yaml" # required model config inside weights_subdir
|
||||
# Model-config filenames accepted inside weights_subdir. A tuple, not a
|
||||
# single name: IndexTTS 2.5's weights repo ships config.yaml, but installs
|
||||
# predating #1611 were only usable after hand-renaming it to
|
||||
# config_v2_5.yaml, and those must keep working without a reinstall.
|
||||
weights_config_names: tuple[str, ...] = ("config.yaml",)
|
||||
docs_path: str = "docs/engines" # where the manual-install fallback lives
|
||||
required_bytes: int = 12 * _GIB # conservative source+venv+weights estimate for preflight
|
||||
# Called after a successful install/uninstall so the engine's memoised
|
||||
@@ -146,7 +150,7 @@ SPECS: dict[str, SidecarSpec] = {
|
||||
weights_repo_id="IndexTeam/IndexTTS-2.5",
|
||||
weights_revision="d0aa86e75bb6f3437f3831e95056fa72842d89ef",
|
||||
weights_subdir="checkpoints",
|
||||
weights_config_name="config_v2_5.yaml",
|
||||
weights_config_names=("config.yaml", "config_v2_5.yaml"),
|
||||
docs_path="docs/engines/indextts.md",
|
||||
# ~0.1 GB source + up to ~6 GB venv (torch + transformers<5) +
|
||||
# ~6 GB weights. Deliberately conservative; the preflight subtracts
|
||||
@@ -879,11 +883,11 @@ def _weights_present(spec: SidecarSpec) -> bool:
|
||||
actual = marker[:2] if len(marker) >= 2 else marker + [""]
|
||||
if actual != expected:
|
||||
return False
|
||||
return _weights_floor_ok(wdir, config_name=spec.weights_config_name)
|
||||
return _weights_floor_ok(wdir, config_names=spec.weights_config_names)
|
||||
|
||||
|
||||
def _weights_floor_ok(wdir: Path, *, config_name: str = "config.yaml") -> bool:
|
||||
if not (wdir / config_name).is_file():
|
||||
def _weights_floor_ok(wdir: Path, *, config_names: tuple[str, ...] = ("config.yaml",)) -> bool:
|
||||
if not any((wdir / name).is_file() for name in config_names):
|
||||
return False
|
||||
floor = 5 * 1024 * 1024
|
||||
try:
|
||||
@@ -969,7 +973,7 @@ def _step_fetch_weights(spec: SidecarSpec, job: dict) -> None:
|
||||
hf_progress.unregister_listener(listener_id)
|
||||
hf_progress.current_repo_id.reset(repo_token)
|
||||
|
||||
if not _weights_floor_ok(wdir, config_name=spec.weights_config_name):
|
||||
if not _weights_floor_ok(wdir, config_names=spec.weights_config_names):
|
||||
raise _StepError(
|
||||
"Weight download finished but no plausible weight files were found — "
|
||||
"the download was likely interrupted.",
|
||||
|
||||
@@ -341,6 +341,44 @@ class TTSBackend(ABC):
|
||||
Engines that don't support this will ignore the parameter.
|
||||
"""
|
||||
|
||||
def generate_batch(
|
||||
self,
|
||||
texts: list[str],
|
||||
*,
|
||||
ref_audio=None,
|
||||
ref_text=None,
|
||||
instruct=None,
|
||||
language=None,
|
||||
duration=None,
|
||||
speed=1.0,
|
||||
**extras,
|
||||
) -> list[torch.Tensor]:
|
||||
"""Synthesize several utterances, preserving the single-item contract.
|
||||
|
||||
Engines with a native batch forward pass override this method. The
|
||||
default keeps every existing adapter correct while giving callers one
|
||||
stable seam and per-item keyword handling.
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
def _item(value, index):
|
||||
return value[index] if isinstance(value, list) else value
|
||||
|
||||
return [
|
||||
self.generate(
|
||||
text,
|
||||
ref_audio=_item(ref_audio, index),
|
||||
ref_text=_item(ref_text, index),
|
||||
instruct=_item(instruct, index),
|
||||
language=_item(language, index),
|
||||
duration=_item(duration, index),
|
||||
speed=_item(speed, index),
|
||||
**extras,
|
||||
)
|
||||
for index, text in enumerate(texts)
|
||||
]
|
||||
|
||||
# ── Lifecycle (Phase 2 will enforce per-engine overrides) ──────────────
|
||||
#
|
||||
# Today every backend lazily loads its weights on first `generate()` and
|
||||
@@ -717,6 +755,73 @@ class OmniVoiceBackend(TTSBackend):
|
||||
)
|
||||
return audios[0]
|
||||
|
||||
def generate_batch(self, texts: list[str], **kw) -> list[torch.Tensor]:
|
||||
"""Use OmniVoice's native variable-length batch generation.
|
||||
|
||||
Batch callers pass per-item language, duration, speed and reference
|
||||
lists. Reusable clone prompts are prepared once and handed to the
|
||||
model together; an incomplete prompt batch falls back to the proven
|
||||
single-item path instead of changing synthesis semantics.
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
def _items(value):
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value] * len(texts)
|
||||
|
||||
def _item_kwargs(index):
|
||||
return {
|
||||
key: value[index] if isinstance(value, list) else value
|
||||
for key, value in kw.items()
|
||||
}
|
||||
|
||||
ref_audios = _items(kw.get("ref_audio"))
|
||||
ref_texts = _items(kw.get("ref_text"))
|
||||
cache_ref = bool(kw.get("cache_ref", True))
|
||||
preprocess_prompt = bool(kw.get("preprocess_prompt", True))
|
||||
prompts = []
|
||||
if any(ref_audios):
|
||||
for ref_audio, ref_text in zip(ref_audios, ref_texts):
|
||||
if not ref_audio:
|
||||
prompts = []
|
||||
break
|
||||
prompt = _get_clone_prompt(
|
||||
self._model,
|
||||
ref_audio,
|
||||
ref_text,
|
||||
preprocess_prompt,
|
||||
store=cache_ref,
|
||||
)
|
||||
if prompt is None:
|
||||
prompts = []
|
||||
break
|
||||
prompts.append(prompt)
|
||||
|
||||
if any(ref_audios) and len(prompts) != len(texts):
|
||||
return [self.generate(text, **_item_kwargs(i))
|
||||
for i, text in enumerate(texts)]
|
||||
|
||||
gen_kw = dict(
|
||||
language=kw.get("language"),
|
||||
instruct=kw.get("instruct"),
|
||||
duration=kw.get("duration"),
|
||||
speed=kw.get("speed", 1.0),
|
||||
denoise=kw.get("denoise", True),
|
||||
postprocess_output=kw.get("postprocess_output", True),
|
||||
num_step=kw.get("num_step", 16),
|
||||
guidance_scale=kw.get("guidance_scale", 2.0),
|
||||
preprocess_prompt=preprocess_prompt,
|
||||
)
|
||||
if prompts:
|
||||
gen_kw["voice_clone_prompt"] = prompts
|
||||
else:
|
||||
gen_kw["ref_audio"] = None
|
||||
gen_kw["ref_text"] = None
|
||||
return self._model.generate(text=texts, **gen_kw)
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Release the OmniVoice model (MM2-02). OmniVoice shares the singleton
|
||||
owned by ``model_manager``, so dropping our local ref isn't enough — we
|
||||
|
||||
+266
-41
@@ -20,12 +20,17 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import torch
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import torch
|
||||
|
||||
from core.prefs import resolve
|
||||
|
||||
logger = logging.getLogger("omnivoice.watermark")
|
||||
@@ -37,6 +42,20 @@ _detector = None
|
||||
_audioseal_available: Optional[bool] = None
|
||||
# Monotonic stamp of the last embed/detect, for the idle release below.
|
||||
_last_used = 0.0
|
||||
# Per-model locks for the lazy builds below: the startup prefetch thread
|
||||
# races the first embed, and both must share ONE build (a double load doubles
|
||||
# the cold-start cost the prefetch exists to hide). One lock PER MODEL — a
|
||||
# single shared lock made the ~42s generator prefetch block unrelated detector
|
||||
# loads and the idle reaper behind it. release_idle_models acquires both, in
|
||||
# this fixed order (nothing else nests them, so no cycle is possible).
|
||||
_generator_lock = threading.Lock()
|
||||
_detector_lock = threading.Lock()
|
||||
|
||||
# True when the generator exists ONLY because the startup prefetch built it
|
||||
# and no embed/detect has used it since. The idle reaper grants one extra
|
||||
# idle window before dropping such a model, so a first synthesis at minute
|
||||
# 20 still finds it warm (code-review finding 2 on the prefetch PR).
|
||||
_prefetched_unused = False
|
||||
|
||||
# 16-bit message: "OM" in ASCII = 0x4F 0x4D = 0100_1111 0100_1101
|
||||
# This is our signature — every VoiceStudio-generated audio carries it.
|
||||
@@ -50,6 +69,86 @@ OMNI_MESSAGE = [0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1]
|
||||
_CHUNK_SECONDS = 30
|
||||
|
||||
|
||||
# AudioSeal vendors moshi's ``@torch_compile_lazy`` on SEANetEncoder.forward,
|
||||
# so the first EMBED — not the model load, which prefetch already warms —
|
||||
# calls torch.compile and drops into Inductor's C++ codegen. On hosts whose
|
||||
# C++ toolchain can't serve Inductor that compile raises CppCompileError, the
|
||||
# embed fail-opens, and audio ships unmarked: a macOS arm64 deployment lost
|
||||
# provenance marking on 10/10 takes while paying 30-40 s for the first failed
|
||||
# compile and 5-8 s for each later one (#1615).
|
||||
#
|
||||
# The compile is pure cost even where it succeeds. Measured on an M3 (5 s of
|
||||
# 24 kHz audio, three consecutive embeds): compiled 9.70 / 0.26 / 0.23 s vs
|
||||
# eager 0.30 / 0.28 / 0.27 s — a ~10 s first-embed tax to save ~0.03 s per
|
||||
# later embed, on CPU work that is already bounded by the 30 s chunk loop.
|
||||
# So watermarking runs eager on every platform.
|
||||
def _moshi_compile_module():
|
||||
"""AudioSeal's vendored moshi compile switch module, or None.
|
||||
|
||||
Resolved per call rather than at import: ``_check_available()`` is what
|
||||
guarantees audioseal is importable, and it runs later than this module.
|
||||
"""
|
||||
try:
|
||||
from audioseal.libs.moshi.utils import compile as moshi_compile
|
||||
except Exception: # noqa: BLE001 — any import shape change degrades, not crashes
|
||||
return None
|
||||
return moshi_compile
|
||||
|
||||
|
||||
_eager_lock = threading.Lock()
|
||||
#: Depth of nested/concurrent eager scopes, and the switch value to put back
|
||||
#: when the last one exits. One dict rather than two module scalars: the
|
||||
#: fields are only meaningful together, and only under _eager_lock.
|
||||
_eager_state: dict = {"depth": 0, "saved": None}
|
||||
_eager_guard_warned = False
|
||||
|
||||
|
||||
def _warn_missing_eager_guard() -> None:
|
||||
global _eager_guard_warned
|
||||
_eager_guard_warned = True
|
||||
logger.info(
|
||||
"audioseal's no_compile switch is unavailable — watermarking may run "
|
||||
"through torch.compile and pay (or fail) an Inductor C++ compile (#1615)."
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _eager_audioseal():
|
||||
"""Run the AudioSeal model eagerly, restoring the switch on the way out.
|
||||
|
||||
Upstream's own ``no_compile()`` saves and restores ``_compile_disabled``
|
||||
per call, which is not safe when two watermark calls overlap: the first to
|
||||
exit restores False while the second is still mid-embed, handing it back
|
||||
the compile this whole fix exists to avoid. So the flag is reference
|
||||
counted here — it goes True on the outermost entry and only comes back on
|
||||
the outermost exit — rather than serializing embeds behind a lock, which
|
||||
would cost real throughput on concurrent generations.
|
||||
|
||||
Degrades to a plain call if a future audioseal drops the helper
|
||||
(``tests/test_watermark_no_torch_compile_1615.py`` fails loudly on that
|
||||
upgrade rather than letting the compile creep back in).
|
||||
"""
|
||||
moshi = _moshi_compile_module()
|
||||
if moshi is None:
|
||||
if not _eager_guard_warned:
|
||||
_warn_missing_eager_guard()
|
||||
yield
|
||||
return
|
||||
with _eager_lock:
|
||||
if _eager_state["depth"] == 0:
|
||||
_eager_state["saved"] = moshi._compile_disabled
|
||||
_eager_state["depth"] += 1
|
||||
moshi._compile_disabled = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with _eager_lock:
|
||||
_eager_state["depth"] -= 1
|
||||
if _eager_state["depth"] == 0:
|
||||
moshi._compile_disabled = _eager_state["saved"]
|
||||
_eager_state["saved"] = None
|
||||
|
||||
|
||||
def _iter_chunks(audio: torch.Tensor, sample_rate: int):
|
||||
"""Yield ≤ ~_CHUNK_SECONDS slices of (batch, channels, samples) audio
|
||||
along the time axis. A sub-second tail is folded into the previous chunk
|
||||
@@ -77,28 +176,82 @@ def _check_available() -> bool:
|
||||
return _audioseal_available
|
||||
|
||||
|
||||
def _get_generator():
|
||||
"""Lazy-load the AudioSeal generator model."""
|
||||
global _generator, _last_used
|
||||
_last_used = time.monotonic()
|
||||
if _generator is None:
|
||||
from audioseal import AudioSeal
|
||||
_generator = AudioSeal.load_generator("audioseal_wm_16bits")
|
||||
_generator.eval()
|
||||
logger.info("AudioSeal generator loaded (16-bit message mode)")
|
||||
return _generator
|
||||
def _get_generator(mark_prefetched: bool = False):
|
||||
"""Lazy-load the AudioSeal generator model.
|
||||
|
||||
Owns the idle-reaper grace in ONE critical section: the startup prefetch
|
||||
claims it (``mark_prefetched=True``) only when THIS call builds the model,
|
||||
and every other call (a real embed) consumes it — no call-site blocks, no
|
||||
window between two lock scopes where the claim could land on an
|
||||
already-used model.
|
||||
"""
|
||||
global _generator, _last_used, _prefetched_unused
|
||||
with _generator_lock:
|
||||
_last_used = time.monotonic()
|
||||
if _generator is None:
|
||||
from audioseal import AudioSeal
|
||||
_generator = AudioSeal.load_generator("audioseal_wm_16bits")
|
||||
_generator.eval()
|
||||
logger.info("AudioSeal generator loaded (16-bit message mode)")
|
||||
_prefetched_unused = mark_prefetched
|
||||
elif not mark_prefetched:
|
||||
_prefetched_unused = False
|
||||
return _generator
|
||||
|
||||
|
||||
def _get_detector():
|
||||
"""Lazy-load the AudioSeal detector model."""
|
||||
global _detector, _last_used
|
||||
_last_used = time.monotonic()
|
||||
if _detector is None:
|
||||
from audioseal import AudioSeal
|
||||
_detector = AudioSeal.load_detector("audioseal_detector_16bits")
|
||||
_detector.eval()
|
||||
logger.info("AudioSeal detector loaded (16-bit message mode)")
|
||||
return _detector
|
||||
with _detector_lock:
|
||||
_last_used = time.monotonic()
|
||||
if _detector is None:
|
||||
from audioseal import AudioSeal
|
||||
_detector = AudioSeal.load_detector("audioseal_detector_16bits")
|
||||
_detector.eval()
|
||||
logger.info("AudioSeal detector loaded (16-bit message mode)")
|
||||
return _detector
|
||||
|
||||
|
||||
def _generator_checkpoint_cached() -> bool:
|
||||
"""Return whether AudioSeal can warm without contacting Hugging Face.
|
||||
|
||||
AudioSeal 0.2 stores the checkpoint in ``<cache>/audioseal`` even though
|
||||
it uses huggingface_hub to fetch it. Keep startup local-first: an ordinary
|
||||
boot may consume that file, but must never turn prefetch into a download.
|
||||
"""
|
||||
cache_root = os.environ.get("AUDIOSEAL_CACHE_DIR") or os.environ.get(
|
||||
"XDG_CACHE_HOME"
|
||||
)
|
||||
root = Path(cache_root).expanduser() if cache_root else Path.home() / ".cache"
|
||||
return (root / "audioseal" / "generator_base.pth").is_file()
|
||||
|
||||
|
||||
def prefetch_generator(*, allow_download: bool = False) -> None:
|
||||
"""Warm the AudioSeal generator eagerly (startup background thread).
|
||||
|
||||
The first ``mark_synthetic`` otherwise pays the audioseal import plus the
|
||||
generator load inline — measured at ~42 s on a cold filesystem (2026-08-17
|
||||
macOS deployment), serialized inside the first synthesis and 3 s short of
|
||||
a 90 s client timeout. Warming here overlaps that span with the TTS model
|
||||
load. No-op when watermarking is off or audioseal is absent; a failure
|
||||
logs and leaves the lazy path to retry on first embed. Default startup is
|
||||
also cache-only; a download is allowed only when the user explicitly set
|
||||
``OMNIVOICE_PRELOAD_WATERMARK=1``.
|
||||
"""
|
||||
try:
|
||||
if not will_mark():
|
||||
logger.debug("Watermark prefetch skipped (disabled or audioseal absent)")
|
||||
return
|
||||
if not allow_download and not _generator_checkpoint_cached():
|
||||
logger.info("Watermark prefetch skipped: AudioSeal checkpoint is not cached")
|
||||
return
|
||||
_get_generator(mark_prefetched=True)
|
||||
logger.info("AudioSeal generator prefetched in the background")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Watermark prefetch failed; the first embed will retry inline",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def release_idle_models(idle_seconds: float, *, now: Optional[float] = None) -> bool:
|
||||
@@ -114,14 +267,28 @@ def release_idle_models(idle_seconds: float, *, now: Optional[float] = None) ->
|
||||
Returns True if anything was released. Never raises: this runs from the
|
||||
idle reaper, which must survive it.
|
||||
"""
|
||||
global _generator, _detector
|
||||
if _generator is None and _detector is None:
|
||||
return False
|
||||
stamp = time.monotonic() if now is None else float(now)
|
||||
if stamp - _last_used < idle_seconds:
|
||||
return False
|
||||
_generator = None
|
||||
_detector = None
|
||||
global _generator, _detector, _prefetched_unused
|
||||
with _generator_lock, _detector_lock:
|
||||
if _generator is None and _detector is None:
|
||||
return False
|
||||
stamp = time.monotonic() if now is None else float(now)
|
||||
if stamp - _last_used < idle_seconds:
|
||||
return False
|
||||
if _prefetched_unused:
|
||||
# The startup prefetch built the generator and nothing has used
|
||||
# it yet. Drop the grace (one extra idle window only) instead of
|
||||
# the model, so a first synthesis shortly after boot still finds
|
||||
# it warm — the exact scenario the prefetch exists for.
|
||||
_prefetched_unused = False
|
||||
logger.info(
|
||||
"Idle watermark models are prefetch-warmed but unused; "
|
||||
"granting one more idle window before releasing."
|
||||
)
|
||||
return False
|
||||
# Under the locks so a release racing the prefetch or a first embed
|
||||
# can't wipe a model the lazy path just built.
|
||||
_generator = None
|
||||
_detector = None
|
||||
logger.info("Idle timeout reached. Released the AudioSeal watermark models.")
|
||||
return True
|
||||
|
||||
@@ -200,6 +367,62 @@ def mark_synthetic(
|
||||
return marked
|
||||
|
||||
|
||||
async def mark_synthetic_async(
|
||||
waveform: torch.Tensor,
|
||||
sample_rate: int,
|
||||
*,
|
||||
context: str,
|
||||
force: bool = False,
|
||||
timeout: float | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Dispatch marking without letting a draining pool lose finished audio."""
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
from services.model_manager import (
|
||||
GpuJobTimeoutError,
|
||||
GpuPoolBusyError,
|
||||
get_watermark_pool,
|
||||
run_on_gpu_pool_guarded,
|
||||
)
|
||||
|
||||
try:
|
||||
pool = get_watermark_pool()
|
||||
except RuntimeError:
|
||||
logger.warning("Watermark skipped while the prior worker is shutting down")
|
||||
return waveform
|
||||
|
||||
job = functools.partial(
|
||||
mark_synthetic, waveform, sample_rate, context=context, force=force
|
||||
)
|
||||
try:
|
||||
if timeout is not None:
|
||||
return await run_on_gpu_pool_guarded(
|
||||
job, what="Audio watermark", timeout=timeout, executor=pool
|
||||
)
|
||||
return await asyncio.get_running_loop().run_in_executor(pool, job)
|
||||
except (GpuJobTimeoutError, GpuPoolBusyError):
|
||||
# Watermarking is provenance best-effort: a typed execution overrun or
|
||||
# queue saturation must not discard synthesis that already completed.
|
||||
logger.warning("Watermark skipped after its bounded dispatch expired")
|
||||
return waveform
|
||||
except asyncio.CancelledError:
|
||||
# A queued future is cancelled during pool teardown. Caller-driven
|
||||
# cancellation while the pool is live must retain normal semantics.
|
||||
if not pool.is_shutdown():
|
||||
raise
|
||||
logger.warning("Watermark skipped while the pool is shutting down")
|
||||
return waveform
|
||||
except RuntimeError:
|
||||
# Shutdown may begin after admission but before Executor.submit().
|
||||
# Preserve unrelated worker failures; only lifecycle rejection is
|
||||
# fail-open because finished synthesis must not be lost to teardown.
|
||||
if not pool.is_shutdown():
|
||||
raise
|
||||
logger.warning("Watermark skipped while the pool is shutting down")
|
||||
return waveform
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def embed_watermark(
|
||||
waveform: torch.Tensor,
|
||||
@@ -243,13 +466,14 @@ def embed_watermark(
|
||||
|
||||
# AudioSeal operates at 16kHz internally; it handles resampling, but
|
||||
# we need to inform it of the source rate for correct embedding.
|
||||
watermarked = torch.cat(
|
||||
[
|
||||
generator(seg, sample_rate=sample_rate, message=msg)
|
||||
for seg in _iter_chunks(audio, sample_rate)
|
||||
],
|
||||
dim=-1,
|
||||
)
|
||||
with _eager_audioseal():
|
||||
watermarked = torch.cat(
|
||||
[
|
||||
generator(seg, sample_rate=sample_rate, message=msg)
|
||||
for seg in _iter_chunks(audio, sample_rate)
|
||||
],
|
||||
dim=-1,
|
||||
)
|
||||
|
||||
# Restore original shape
|
||||
if len(original_shape) == 2:
|
||||
@@ -260,7 +484,7 @@ def embed_watermark(
|
||||
return watermarked
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Watermark embedding failed (passing through original): %s", e)
|
||||
logger.warning("Watermark embedding failed (passing through original): %s", e, exc_info=True)
|
||||
return waveform
|
||||
|
||||
|
||||
@@ -307,12 +531,13 @@ def detect_watermark(
|
||||
# embedding does, and a splice where only part of the file is
|
||||
# VoiceStudio audio still registers (a whole-file average would dilute it).
|
||||
best_conf, decoded_msg = -1.0, None
|
||||
for seg in _iter_chunks(audio, sample_rate):
|
||||
result = detector.detect_watermark(seg, sample_rate=sample_rate, message_threshold=0.5)
|
||||
seg_conf = float(result[0]) if isinstance(result, tuple) else 0.0
|
||||
if seg_conf > best_conf:
|
||||
best_conf = seg_conf
|
||||
decoded_msg = result[1] if isinstance(result, tuple) and len(result) > 1 else None
|
||||
with _eager_audioseal():
|
||||
for seg in _iter_chunks(audio, sample_rate):
|
||||
result = detector.detect_watermark(seg, sample_rate=sample_rate, message_threshold=0.5)
|
||||
seg_conf = float(result[0]) if isinstance(result, tuple) else 0.0
|
||||
if seg_conf > best_conf:
|
||||
best_conf = seg_conf
|
||||
decoded_msg = result[1] if isinstance(result, tuple) and len(result) > 1 else None
|
||||
confidence = max(best_conf, 0.0)
|
||||
|
||||
# Decode message bits
|
||||
@@ -337,7 +562,7 @@ def detect_watermark(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Watermark detection failed: %s", e)
|
||||
logger.warning("Watermark detection failed: %s", e, exc_info=True)
|
||||
return {
|
||||
"is_watermarked": False,
|
||||
"confidence": 0.0,
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"""A dictation model that decodes nothing gets demoted, not re-selected forever.
|
||||
|
||||
`sherpa-parakeet-tdt-v3` is the curated default, and on Windows it installs
|
||||
cleanly, loads without error, and returns an empty token list for clear speech
|
||||
On Windows, `sherpa-parakeet-tdt-v3` installs cleanly, loads without error,
|
||||
and returns an empty token list for clear speech
|
||||
(both quantisations, both decoding methods, sherpa-onnx 1.13.3 and 1.13.4)
|
||||
while whisper and zipformer transcribe the same bytes. The defect is inside
|
||||
sherpa-onnx's NeMo-TDT decoder — unfixable from here by configuration.
|
||||
|
||||
Hard-coding a different default per OS would be a guess: we have evidence for
|
||||
one platform only. So the app observes instead. When a session hears real
|
||||
speech and the model returns nothing, that model is demoted ON THIS MACHINE and
|
||||
stops being auto-selected, which self-corrects wherever the breakage actually
|
||||
is and is a no-op everywhere it isn't.
|
||||
Whisper Tiny is now the cross-platform default, while Parakeet remains
|
||||
selectable. Runtime demotion still protects users who select a recognizer that
|
||||
loads successfully but decodes nothing: it is demoted on this machine and the
|
||||
next session follows the capture fallback.
|
||||
|
||||
These tests pin the demotion round trip and, critically, that the user can
|
||||
always take back control by re-picking the model.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""A dictation model that decodes NOTHING must fall back, not fail silently.
|
||||
|
||||
Found on Windows with the curated default `sherpa-parakeet-tdt-v3`: the model
|
||||
Found on Windows with `sherpa-parakeet-tdt-v3`: the model
|
||||
downloads, loads with zero errors, and is correctly detected as a TDT model
|
||||
(`num_durations: 5`) — then returns an empty token list for clear speech.
|
||||
Measured against the same 18.9s WAV, on the same machine, same sherpa-onnx:
|
||||
|
||||
sherpa-whisper-tiny -> "Alright, here we are. I hope that's all..."
|
||||
sherpa-zipformer-en-20m -> "ANTS BOTH IN WHAT DISGUISED THIS THAT..."
|
||||
parakeet-tdt-v3 (int8) -> '' <-- the curated default
|
||||
parakeet-tdt-v3 (int8) -> ''
|
||||
parakeet-tdt-v3 (fp32) -> ''
|
||||
parakeet-tdt-v2 (int8) -> ''
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""#1618 — RAM preflight must not hard-block the machines it means to admit.
|
||||
|
||||
An "8 GB" machine reports ~7.8 GB usable (firmware/iGPU/kernel reservations),
|
||||
so comparing reported RAM against the marketing-size threshold blocked exactly
|
||||
the boundary hardware the ≥8 GB rule intends to allow. The check now applies
|
||||
``_RAM_RESERVED_ALLOWANCE`` to both thresholds, and
|
||||
``OMNIVOICE_RAM_PREFLIGHT=0`` downgrades a genuine fail to a warning.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from api.routers.setup import wizard
|
||||
|
||||
|
||||
def _ram_check(monkeypatch, ram_gb: float, env: str | None = None) -> dict:
|
||||
# Keep the preflight hermetic: stub the probes that hit the network or
|
||||
# auto-acquire media tools, so each RAM assertion stays fast and offline.
|
||||
monkeypatch.setattr(wizard, "_network_check", lambda: {
|
||||
"id": "network", "label": "Network", "status": "pass",
|
||||
"detail": "stubbed", "fix": None, "mirror_reachable": True,
|
||||
})
|
||||
import services.media_tools as media_tools
|
||||
monkeypatch.setattr(media_tools, "summary", lambda auto_acquire=True: None)
|
||||
monkeypatch.setattr(wizard, "_ram_gb", lambda: ram_gb)
|
||||
if env is None:
|
||||
monkeypatch.delenv("OMNIVOICE_RAM_PREFLIGHT", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("OMNIVOICE_RAM_PREFLIGHT", env)
|
||||
resp = wizard.preflight()
|
||||
checks = resp["checks"] if isinstance(resp, dict) else resp.checks
|
||||
for c in checks:
|
||||
c = c if isinstance(c, dict) else c.model_dump()
|
||||
if c["id"] == "ram":
|
||||
return c
|
||||
raise AssertionError("no ram check in preflight response")
|
||||
|
||||
|
||||
def test_8gb_installed_reporting_7_84_usable_is_not_blocked(monkeypatch):
|
||||
"""The #1618 report: 7.84 GB usable on an 8 GB laptop was a hard fail."""
|
||||
check = _ram_check(monkeypatch, 7.84)
|
||||
assert check["status"] != "fail"
|
||||
|
||||
|
||||
def test_boundary_at_allowance_passes_the_fail_gate(monkeypatch):
|
||||
check = _ram_check(
|
||||
monkeypatch, wizard._RAM_FAIL_GB * wizard._RAM_RESERVED_ALLOWANCE
|
||||
)
|
||||
assert check["status"] != "fail"
|
||||
|
||||
|
||||
def test_genuinely_low_ram_still_fails(monkeypatch):
|
||||
check = _ram_check(monkeypatch, 6.0)
|
||||
assert check["status"] == "fail"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("env", ["0", "false", "no"])
|
||||
def test_escape_hatch_downgrades_fail_to_warn(monkeypatch, env):
|
||||
check = _ram_check(monkeypatch, 6.0, env=env)
|
||||
assert check["status"] == "warn"
|
||||
assert "OMNIVOICE_RAM_PREFLIGHT" in (check["fix"] or "")
|
||||
|
||||
|
||||
def test_escape_hatch_not_triggered_by_other_values(monkeypatch):
|
||||
check = _ram_check(monkeypatch, 6.0, env="1")
|
||||
assert check["status"] == "fail"
|
||||
|
||||
|
||||
def test_12gb_installed_reporting_11_8_usable_passes_clean(monkeypatch):
|
||||
"""Same reservation gap at the warn threshold: 12 GB installed ≈ 11.8."""
|
||||
check = _ram_check(monkeypatch, 11.8)
|
||||
assert check["status"] == "pass"
|
||||
|
||||
|
||||
def test_warn_band_between_thresholds(monkeypatch):
|
||||
check = _ram_check(monkeypatch, 9.0)
|
||||
assert check["status"] == "warn"
|
||||
@@ -76,6 +76,35 @@ class TestUnloadOnABC:
|
||||
)
|
||||
|
||||
|
||||
def test_omnivoice_native_batch_preserves_per_item_controls():
|
||||
"""The adapter forwards variable-length batch controls to OmniVoice."""
|
||||
import torch
|
||||
|
||||
tts = _load_tts_backend_module()
|
||||
calls = []
|
||||
|
||||
class _Model:
|
||||
sampling_rate = 24000
|
||||
|
||||
def generate(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return [torch.zeros(1, 12000), torch.zeros(1, 24000)]
|
||||
|
||||
backend = tts.OmniVoiceBackend(model=_Model())
|
||||
outputs = backend.generate_batch(
|
||||
["short", "long"],
|
||||
language=["en", "es"],
|
||||
duration=[0.5, 1.0],
|
||||
speed=[1.0, 0.8],
|
||||
)
|
||||
|
||||
assert [output.shape[-1] for output in outputs] == [12000, 24000]
|
||||
assert calls[0]["text"] == ["short", "long"]
|
||||
assert calls[0]["language"] == ["en", "es"]
|
||||
assert calls[0]["duration"] == [0.5, 1.0]
|
||||
assert calls[0]["speed"] == [1.0, 0.8]
|
||||
|
||||
|
||||
class TestUnloadDefaultBehavior:
|
||||
"""The default no-op must actually be safe to call."""
|
||||
|
||||
@@ -154,4 +183,4 @@ class TestExistingSubclassesInherit:
|
||||
assert callable(getattr(cls, "unload", None)), (
|
||||
f"{cls.__name__} has no callable unload() — even via the "
|
||||
"ABC inheritance. Did someone shadow it?"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -35,6 +35,9 @@ from typing import Optional
|
||||
# plane may run in a process that never loads torch). test_worker_deadlines.py
|
||||
# asserts the two agree, so a change there cannot silently drift from here.
|
||||
_GENERATE_TIMEOUT_S = float(os.environ.get("OMNIVOICE_GENERATE_TIMEOUT_S", "300.0"))
|
||||
_CPU_GENERATE_TIMEOUT_S = float(
|
||||
os.environ.get("OMNIVOICE_CPU_GENERATE_TIMEOUT_S", "600.0")
|
||||
)
|
||||
_MODEL_LOAD_EXTRA_S = float(os.environ.get("OMNIVOICE_MODEL_LOAD_TIMEOUT_S", "1800.0"))
|
||||
_HEARTBEAT_GRACE_S = float(os.environ.get("OMNIVOICE_MODEL_LOAD_HEARTBEAT_GRACE_S", "30.0"))
|
||||
|
||||
@@ -123,20 +126,40 @@ class Deadlines:
|
||||
}
|
||||
|
||||
|
||||
def _base_execution_seconds(text: Optional[str]) -> float:
|
||||
def _base_execution_seconds(
|
||||
text: Optional[str], *, execution_device: Optional[str] = None
|
||||
) -> float:
|
||||
"""Delegate to model_manager's budget; fall back to its formula.
|
||||
|
||||
The lazy import keeps this module usable in a process that has no torch —
|
||||
the control plane schedules work it never executes.
|
||||
"""
|
||||
target_device = str(execution_device or "cpu").lower()
|
||||
if target_device not in {"cpu", "cuda", "mps", "mlx", "directml", "rocm", "xpu"}:
|
||||
target_device = "cpu"
|
||||
try:
|
||||
from services import model_manager # noqa: PLC0415 — intentionally lazy
|
||||
|
||||
return float(model_manager.generate_timeout_s(text))
|
||||
return float(
|
||||
model_manager.generate_timeout_s(
|
||||
text, execution_device=target_device
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
base = _GENERATE_TIMEOUT_S
|
||||
try:
|
||||
if (
|
||||
target_device == "cpu"
|
||||
and "OMNIVOICE_GENERATE_TIMEOUT_S" not in os.environ
|
||||
):
|
||||
base = _CPU_GENERATE_TIMEOUT_S
|
||||
except Exception:
|
||||
# Capability detection is optional in the torch-free control
|
||||
# plane; retain the configured universal bounded fallback.
|
||||
pass
|
||||
return max(
|
||||
_GENERATE_TIMEOUT_S,
|
||||
_GENERATE_TIMEOUT_S + max(0, len(text or "") - _FREE_CHARS) / _CHARS_PER_SECOND,
|
||||
base,
|
||||
base + max(0, len(text or "") - _FREE_CHARS) / _CHARS_PER_SECOND,
|
||||
)
|
||||
|
||||
|
||||
@@ -147,6 +170,7 @@ def for_task(
|
||||
model_resident: bool = False,
|
||||
model_downloaded: bool = True,
|
||||
input_seconds: float = 0.0,
|
||||
execution_device: Optional[str] = None,
|
||||
) -> Deadlines:
|
||||
"""Compute the deadlines for one attempt.
|
||||
|
||||
@@ -158,7 +182,9 @@ def for_task(
|
||||
op = Operation.coerce(operation)
|
||||
multiplier, grace = _PROFILE[op]
|
||||
|
||||
execution = _base_execution_seconds(text) * multiplier
|
||||
execution = _base_execution_seconds(
|
||||
text, execution_device=execution_device
|
||||
) * multiplier
|
||||
# Media-length operations scale on duration, not characters.
|
||||
if input_seconds > 0:
|
||||
execution = max(execution, input_seconds * multiplier)
|
||||
|
||||
@@ -33,6 +33,9 @@ _HEARTBEAT_MISS_SECONDS = 90.0
|
||||
# ping is a ~25-second view: current enough to notice a link degrading, long
|
||||
# enough that one slow answer cannot move it.
|
||||
_LATENCY_WINDOW = 5
|
||||
_KNOWN_EXECUTION_DEVICES = frozenset(
|
||||
{"cpu", "cuda", "mps", "mlx", "directml", "rocm", "xpu"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -100,6 +103,21 @@ class ConnectedWorker:
|
||||
return bool(cap.get("supported")) and bool(cap.get("installed", True))
|
||||
return False
|
||||
|
||||
def execution_device(self, engine: str, model_id: str, operation: str) -> str:
|
||||
"""Device used by the exact capability selected for this task."""
|
||||
for cap in self.record.capabilities:
|
||||
if cap.get("engine") != engine:
|
||||
continue
|
||||
if model_id and cap.get("model_id") not in (model_id, "", None):
|
||||
continue
|
||||
if operation and operation not in (cap.get("operations") or [operation]):
|
||||
continue
|
||||
if cap.get("cpu_fallback"):
|
||||
return "cpu"
|
||||
backend = str(cap.get("backend") or "").lower()
|
||||
return backend if backend in _KNOWN_EXECUTION_DEVICES else "cpu"
|
||||
return "cpu"
|
||||
|
||||
def is_warm(self, engine: str, model_id: str) -> bool:
|
||||
return self.capacity.is_resident(engine, model_id)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: worker_v1.proto
|
||||
# Protobuf Python Version: 6.33.5
|
||||
# Protobuf Python Version: 7.35.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
@@ -11,9 +11,9 @@ from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
33,
|
||||
5,
|
||||
7,
|
||||
35,
|
||||
1,
|
||||
'',
|
||||
'worker_v1.proto'
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import warnings
|
||||
|
||||
from . import worker_v1_pb2 as worker__v1__pb2
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.81.1'
|
||||
GRPC_GENERATED_VERSION = '1.83.0'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
_version_not_supported = False
|
||||
|
||||
|
||||
@@ -506,6 +506,9 @@ class Scheduler:
|
||||
model_resident=worker.is_warm(task.engine, task.model_id),
|
||||
model_downloaded=True,
|
||||
input_seconds=float(task.params.get("input_seconds") or 0.0),
|
||||
execution_device=worker.execution_device(
|
||||
task.engine, task.model_id, task.operation
|
||||
),
|
||||
)
|
||||
attempt.renew_lease(budget.accept_seconds, now=now)
|
||||
self._save(task, now=now)
|
||||
@@ -997,6 +1000,10 @@ class Scheduler:
|
||||
text=task.params.get("text"),
|
||||
model_resident=bool(worker and worker.is_warm(task.engine, task.model_id)),
|
||||
input_seconds=float(task.params.get("input_seconds") or 0.0),
|
||||
execution_device=(
|
||||
worker.execution_device(task.engine, task.model_id, task.operation)
|
||||
if worker else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+8
-8
@@ -17,7 +17,7 @@ Phase 4 · The two bets ▓▓▓▓▓▓▓▓▓▓ 6 / 6 ✅
|
||||
Phase 5 · Productisation ░░░░░░░░░░ 0 / 5 🚫 demand-driven
|
||||
|
||||
Design track ▓▓▓▓▓▓▓▓▓░ ongoing · 14 primitives + ~67 migrated inline styles · DubTab/Header/Sidebar/CloneDesignTab drained
|
||||
Performance track ░░░░░░░░░░ not started
|
||||
Performance track ▓▓▓░░░░░░░ underway · profiling, preload, isolated engines + cache-remix I/O
|
||||
Feature-magic track ░░░░░░░░░░ not started
|
||||
Quality track ▓▓░░░░░░░░ 12 smoke tests, 10 error messages rewritten
|
||||
```
|
||||
@@ -193,16 +193,16 @@ None on the critical path to world-class. All are answers to real demand.
|
||||
| Design-system primitives (14) | ✅ | Full inventory above. |
|
||||
| Migrate remaining inline styles | 🟡 | **Four biggest offenders drained 2026-04-20** — DubTab **93 → 2**, Header **24 → 1**, Sidebar **21 → 8**, CloneDesignTab **33 → 0**. All remaining are genuinely dynamic (per-row `--row-accent` CSS custom props in Sidebar, per-bar `height/animationDelay` in WaveBars, `opacity` computed from index in skeleton rows, `fontSize` by prop). New class systems: `.dub-*` (DubTab), `.hq-col-*/.hq-stats__*/.hq-logo-*` (Header), `.sidebar-tile--*/.sidebar__scroll/.history-*--*` (Sidebar), `.clone-*/.label-row--*` (CloneDesignTab). Drag-hover on `.file-drag` and `.dub-idle-drop` now toggles `.is-dragging` instead of mutating styles via DOM. Remaining 119 across the tail (Launchpad, KeyboardCheatsheet, DubSegmentRow, WaveformTimeline, etc.) — less concentrated, lower-leverage. |
|
||||
|
||||
### ⚡ Performance track _(⏳ not started)_
|
||||
### ⚡ Performance track _(🟡 underway)_
|
||||
|
||||
| Item | Status | Current measurement |
|
||||
|------|:---:|------|
|
||||
| Batched TTS (8–16 segments per forward pass) | ⏳ | 1 segment per call today. |
|
||||
| Kill per-segment disk round-trip | ⏳ | `dub_generate.py:132-133` saves + re-reads per segment. |
|
||||
| Cold start ≤1.5 s to first audible sample | ⏳ | Currently 4+ s on Apple Silicon. |
|
||||
| Batched TTS (host-derived width per forward pass) | 🟡 | The batch queue feeds OmniVoice's native variable-length forward pass, with the width derived from device headroom (1 on CPU/low-VRAM hosts, up to 8) and overridable via `OMNIVOICE_DUB_BATCH_WIDTH`; adapters without native batching retain the single-segment fallback. |
|
||||
| Kill per-segment disk round-trip | 🟡 | Long-video assembly stays disk-backed to bound RAM. Unchanged same-rate natural segments now skip the redundant decode → scratch encode → decode cycle; fresh segments still persist once and reload for assembly. |
|
||||
| Cold start ≤1.5 s to first audible sample | 🟡 | Installed models preload in the background and `scripts/bench_pipeline.py` measures cold/warm synthesis; target is not yet verified. |
|
||||
| Speculative regeneration on hover | ⏳ | — |
|
||||
| Crash-sandbox engines (subprocess isolation) | ⏳ | Single CUDA OOM still kills server. |
|
||||
| Interaction budgets (<50 ms UI, <200 ms preview, <4 s first seg) | ⏳ | Not measured. |
|
||||
| Crash-sandbox engines (subprocess isolation) | 🟡 | Killable sidecar engines and opt-in `omnivoice-subprocess` are live; the default in-process engine can still take down the server on a native crash. |
|
||||
| Interaction budgets (<50 ms UI, <200 ms preview, <4 s first seg) | 🟡 | `/ws/tts` reports real TTFA, total generation time and RTF; frontend responsiveness instrumentation exists, but no cross-surface budget gate yet. |
|
||||
| Dedicated dev-week per quarter | ⏳ | Cadence not yet booked. |
|
||||
|
||||
### ✨ Feature-magic track _(⏳ not started)_
|
||||
@@ -220,7 +220,7 @@ None on the critical path to world-class. All are answers to real demand.
|
||||
| Item | Status | Notes |
|
||||
|------|:---:|------|
|
||||
| Every bug ships a regression test | ⏳ | Rule written, not yet enforced in CI. |
|
||||
| Perf regression budget (≤5 % on fixture clip) | ⏳ | No fixture clip yet. |
|
||||
| Perf regression budget (≤5 % on fixture clip) | ✅ | Shipped 2026-08-20 as hardware-independent **operation-count budgets** (`tests/test_perf_operation_budgets.py`) — stricter than 5 %, and CI-stable where wall-clock on varying runners is not: one generate per sentence on `/ws/tts`, zero TTS calls on cached dub re-mixes; zero decode/rewrite and ⌈N/W⌉ `generate_batch` guards activate with their respective fast paths. See docs/performance.md §Performance budgets. |
|
||||
| Accessibility (keyboard-first, WCAG AA, ARIA live regions) | 🟡 | Focus rings token defined; full audit pending. |
|
||||
| Privacy (zero telemetry by default, per-feature opt-in) | ✅ | Enforced in Settings → Privacy tab. |
|
||||
| Docs updated per phase | 🟡 | STRUCTURE.md, ROADMAP.md, ui/README.md current (research/ + design/ retired 2026-07-12). |
|
||||
|
||||
+5
-1
@@ -60,10 +60,14 @@ VoiceStudio/
|
||||
│ └── frontend/ Node-based frontend tests
|
||||
│
|
||||
├── scripts/ ⟵ dev / build / release shell + python scripts
|
||||
│ ├── install.sh universal installer
|
||||
│ ├── install.sh universal installer (macOS/Linux/WSL)
|
||||
│ ├── install.ps1 universal installer (Windows)
|
||||
│ ├── run.sh universal launcher
|
||||
│ ├── smoke-test.sh end-to-end validation
|
||||
│ └── desktop-prod.sh production desktop build
|
||||
|
||||
├── infra/ ⟵ edge/deploy workers (not the Docker deploy path)
|
||||
│ └── install-redirect/ voicestudio.sh/install — UA-sniffing installer worker
|
||||
│
|
||||
├── deploy/ ⟵ Docker deployment configs
|
||||
│ ├── Dockerfile single-stage CUDA image
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Exporting dubbed video
|
||||
|
||||
Video exports can contain both the source audio and one or more dubbed tracks.
|
||||
VoiceStudio marks the selected dubbed language as the default so ordinary video
|
||||
players and messaging apps play the dub immediately. Choose **Original** in the
|
||||
Default Track control when the source audio should play first instead.
|
||||
@@ -215,6 +215,14 @@ launching the backend (or in **Settings → Credentials**):
|
||||
pro endpoint).
|
||||
- **Microsoft Translator:** `MICROSOFT_API_KEY` (optionally `MICROSOFT_BASE_URL`).
|
||||
|
||||
## Editing workspace
|
||||
|
||||
After transcription, drag the divider between the video/timeline and transcript
|
||||
columns to give either side more room. The divider also works from the keyboard:
|
||||
focus it, use Left/Right Arrow in 5% steps, or Home/End for the minimum/maximum.
|
||||
VoiceStudio remembers the split on this device. Narrow workspaces stack the two
|
||||
panels instead so neither editor becomes unusably small.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"The 'google' translation engine needs the optional deep_translator Python
|
||||
|
||||
@@ -77,6 +77,14 @@ missing, preventing slow disks or antivirus scans from hiding a valid venv.
|
||||
Set `OMNIVOICE_INDEXTTS_IMPORT_PROBE_TIMEOUT_S` to raise the default 60-second
|
||||
probe limit.
|
||||
|
||||
### Long-text generation
|
||||
|
||||
A long passage can keep `infer()` busy for several minutes. The sidecar emits a
|
||||
keep-alive frame every 5 seconds while it works, so the parent can tell a slow
|
||||
synthesis from a wedged one, and waits up to 900 seconds for a sidecar that has
|
||||
gone genuinely silent. Set `OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S` (minimum 30) to
|
||||
tune that ceiling.
|
||||
|
||||
IndexTTS 2.5 requires a language token. VoiceStudio maps locale codes and
|
||||
language names to the five supported languages and detects Chinese, Japanese,
|
||||
or Arabic script for Auto requests. Ambiguous Latin text defaults to English.
|
||||
@@ -95,9 +103,14 @@ confirm that the configured directory contains:
|
||||
```text
|
||||
pyproject.toml
|
||||
indextts/infer_v2_5.py
|
||||
checkpoints/config_v2_5.yaml
|
||||
checkpoints/config.yaml
|
||||
```
|
||||
|
||||
`IndexTeam/IndexTTS-2.5` ships the model config as `config.yaml`. Earlier
|
||||
installs only worked after hand-renaming it to `config_v2_5.yaml`; both names
|
||||
are accepted, so a renamed checkout keeps working as-is and needs no
|
||||
reinstall.
|
||||
|
||||
### `uv` not found
|
||||
|
||||
Install `uv` from <https://docs.astral.sh/uv/> or configure the bundled binary
|
||||
|
||||
@@ -20,8 +20,9 @@ instead — same model family, no NeMo dependency:
|
||||
|
||||
- **Apple Silicon:** [parakeet-mlx](parakeet-mlx.md) (installed by default on
|
||||
mac-ARM source installs).
|
||||
- **Any platform, CPU:** [sherpa-onnx-asr](sherpa-onnx-asr.md) — its default
|
||||
dictation model is an int8 ONNX export of Parakeet TDT v3.
|
||||
- **Any platform, CPU:** [sherpa-onnx-asr](sherpa-onnx-asr.md) — selectable
|
||||
int8 ONNX exports of Parakeet TDT v2/v3; Whisper Tiny remains the
|
||||
cross-platform dictation default.
|
||||
|
||||
## Selecting it
|
||||
|
||||
|
||||
@@ -50,7 +50,12 @@ The env var overrides the persisted UI choice.
|
||||
- Cloning takes a short reference clip (`ref_audio`); 3–10 seconds is the
|
||||
sweet spot. A transcript of the clip improves conditioning — if the profile
|
||||
has none, VoiceStudio transcribes the clip automatically on first use and
|
||||
saves the result to the profile.
|
||||
saves the result to the profile. A clip with a supplied transcript is limited
|
||||
to 20 seconds so the two stay aligned; trim both to the same passage. Without
|
||||
a transcript, VoiceStudio can search up to 75 seconds in five contiguous,
|
||||
bounded transcription passes and selects the passage with detected speech.
|
||||
Longer clips must be trimmed first. If no spoken words are detected, trim to
|
||||
a clear 3–10 second passage or provide its matching transcript.
|
||||
- Encoded voice references persist on disk (`prompt_cache/` in the app data
|
||||
dir), so the first generation with a known voice after a restart skips the
|
||||
re-encode and any transcription pass. Set `OMNIVOICE_PROMPT_DISK_CACHE=0`
|
||||
|
||||
@@ -55,10 +55,15 @@ for this model.
|
||||
something an in-process engine cannot do.
|
||||
- The first use downloads the gated weights; the sidecar heartbeats
|
||||
progress during the download so the watchdog doesn't fire.
|
||||
- **French always renders through the 24-layer checkpoint** (`french_24l`) —
|
||||
pocket-tts ships no 6-layer French model — so French render speed is the
|
||||
24-layer figure (roughly half this page's headline speed, still faster
|
||||
than real-time), not the 6-layer one.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `OMNIVOICE_POCKETTTS_RECV_TIMEOUT_S` | `600` | Sidecar response deadline in seconds (min 30; cold loads download weights) |
|
||||
| `OMNIVOICE_POCKETTTS_24L` | off | When truthy, load the 24-layer checkpoint for languages that ship one (it/de/es/pt/fr) instead of the 6-layer default. Better prosody at roughly 2x render time (still faster than real-time); no effect where no 24-layer model exists (e.g. English). Opt-in: the default stays the fast model. French always uses `french_24l` — pocket-tts ships no 6-layer French model and rejects `language="french"` |
|
||||
|
||||
## Known limits
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ genuinely uses **AMD ROCm** GPUs, so auto-detect picks it on ROCm hosts
|
||||
|
||||
- **Model Catalogue → Engines**, ASR tab → **Use** on the PyTorch Whisper
|
||||
row, or `OMNIVOICE_ASR_BACKEND=pytorch-whisper`.
|
||||
- `OMNIVOICE_ASR_BACKEND=omnivoice` is accepted as a compatibility alias and
|
||||
selects this same PyTorch-native ASR path on ROCm hosts.
|
||||
- Auto-detect picks it on ROCm, and as the last resort everywhere else.
|
||||
|
||||
## Best at
|
||||
|
||||
@@ -11,10 +11,10 @@ partials either way.
|
||||
|
||||
- Ensure `sherpa-onnx` is installed (`uv add sherpa-onnx` on source installs).
|
||||
- Pick a dictation model in the app (Model Catalogue → Models lists the
|
||||
curated set below), or **Model Catalogue → Engines**, ASR tab → **Use**, or
|
||||
selectable set below), or **Model Catalogue → Engines**, ASR tab → **Use**, or
|
||||
pin `OMNIVOICE_ASR_BACKEND=sherpa-onnx-asr`.
|
||||
- `OMNIVOICE_SHERPA_ASR_MODEL` selects the model — default
|
||||
`sherpa-parakeet-tdt-v3`.
|
||||
`sherpa-whisper-tiny`.
|
||||
|
||||
## Best at
|
||||
|
||||
@@ -25,17 +25,17 @@ partials either way.
|
||||
timestamps, which makes it a dictation/notes tool rather than a dubbing
|
||||
engine.
|
||||
|
||||
## The 7 curated models
|
||||
## The 7 selectable models
|
||||
|
||||
| Id | Type | Languages | Download |
|
||||
| --- | --- | --- | --- |
|
||||
| `sherpa-parakeet-tdt-v3` (default) | offline | 25 European languages | 0.67 GB |
|
||||
| `sherpa-parakeet-tdt-v3` | offline | 25 European languages | 0.67 GB |
|
||||
| `sherpa-parakeet-tdt-v2` | offline | English | 0.66 GB |
|
||||
| `sherpa-zipformer-bilingual-zh-en` | streaming | Chinese + English | 0.20 GB |
|
||||
| `sherpa-paraformer-bilingual-zh-en` | streaming | Chinese + English | 0.24 GB |
|
||||
| `sherpa-zipformer-en-20m` | streaming | English | 0.044 GB |
|
||||
| `sherpa-zipformer-zh-14m` | streaming | Chinese | 0.025 GB |
|
||||
| `sherpa-whisper-tiny` | offline | 90+ languages (auto-detect) | 0.104 GB |
|
||||
| `sherpa-whisper-tiny` (default, recommended) | offline | 90+ languages (auto-detect) | 0.104 GB |
|
||||
|
||||
Sizes are measured on-disk download sizes. Weights are int8 ONNX checkpoints
|
||||
that download on first use through the same HF cache as everything else —
|
||||
@@ -45,7 +45,10 @@ allocator holds onto freed blocks).
|
||||
|
||||
## Platform support
|
||||
|
||||
CPU on every platform, by the strict cross-platform default-parity rule.
|
||||
CPU on every platform, by the strict cross-platform default-parity rule. The
|
||||
[upstream CPU wheels](https://k2-fsa.github.io/sherpa/onnx/python/install.html)
|
||||
cover Linux, macOS, and Windows, and upstream documents Whisper as a
|
||||
[supported non-streaming model family](https://k2-fsa.github.io/sherpa/onnx/pretrained_models/whisper/index.html).
|
||||
`OMNIVOICE_SHERPA_ASR_PROVIDER` can override the ONNX provider on a verified
|
||||
GPU build, but the default never diverges.
|
||||
|
||||
|
||||
@@ -95,4 +95,5 @@ docs:
|
||||
- docs/install/linux.md
|
||||
- docs/install/docker.md
|
||||
- docs/install/troubleshooting.md
|
||||
- docs/features/dictation.md
|
||||
- docs/migration/real-time-voice-cloning.md
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Dictation
|
||||
|
||||
VoiceStudio dictation records from the system-wide shortcut, transcribes
|
||||
locally, and—where the desktop permits it—inserts the result into the app where
|
||||
the shortcut was pressed. The pill never needs keyboard focus.
|
||||
|
||||
## Use it
|
||||
|
||||
1. Choose an installed dictation model in the Model Catalogue.
|
||||
2. Set the shortcut and hold/toggle behavior in **Settings → Hotkey**.
|
||||
3. Put the cursor in a text field, press the shortcut, speak, then release or
|
||||
press again.
|
||||
|
||||
Whisper Tiny is the recommended default on macOS, Windows, and Linux. It
|
||||
auto-detects more than 90 languages. Parakeet TDT v3 remains available for its
|
||||
25 supported European languages, but it is not selected automatically.
|
||||
|
||||
The pill reports **Inserted** only after native delivery succeeds. **Copied**
|
||||
means automatic insertion was unavailable and the complete final transcript is
|
||||
ready for a normal paste. VoiceStudio retries a speech-level empty Sherpa decode
|
||||
only through another ASR model whose weights are already installed; when that
|
||||
fallback confirms the audio contains words, the silent model is demoted. This
|
||||
recovery never starts a download.
|
||||
|
||||
## Destination and clipboard safety
|
||||
|
||||
The desktop captures the target at shortcut-down and carries its session ID
|
||||
through partial, utterance, and summary messages. A late result from an older
|
||||
session cannot use a newer session's target. macOS, Windows, and X11 validate
|
||||
and reactivate the captured process/window before insertion. Wayland does not
|
||||
expose a portable target identity or arbitrary foreign-window activation, so
|
||||
the safe default leaves the transcript copied instead of guessing which app
|
||||
should receive it. The GTK pill remains non-focusable.
|
||||
|
||||
For paste delivery, VoiceStudio snapshots text, HTML (with its plain-text
|
||||
alternative), image, or file-list clipboard content, stages the transcript,
|
||||
and restores the snapshot after the target consumes it.
|
||||
Streaming segments share a generation-tracked lease: a stale restore cannot
|
||||
win over a newer segment, and VoiceStudio never overwrites clipboard content
|
||||
you copied during transcription. Unsupported clipboard formats cannot be
|
||||
round-tripped; in that case the transcript remains on the clipboard instead of
|
||||
attempting a lossy restore.
|
||||
|
||||
## Platform behavior
|
||||
|
||||
| Platform | Automatic insertion |
|
||||
| --- | --- |
|
||||
| macOS | Reactivates the captured application and sends Command-V. Without Accessibility permission, the result stays copied. |
|
||||
| Windows | Validates the captured window and process, requests foreground activation, then sends Ctrl-V. If Windows denies activation, the result stays copied. |
|
||||
| Linux X11 | Reactivates the captured X11 window through EWMH, verifies it, then sends Ctrl-V. |
|
||||
| Wayland | Leaves the complete transcript copied because a portable captured-window identity is unavailable. |
|
||||
| Browser mode | Copies the transcript; browsers cannot target another desktop app. |
|
||||
|
||||
Advanced Wayland users can set `VOICESTUDIO_WAYLAND_UNTARGETED_INSERT=1` to
|
||||
insert into whichever client owns keyboard focus when transcription finishes.
|
||||
wlroots compositors use `wtype`; KDE Plasma and GNOME can use clipboard paste
|
||||
through `dotool` or `ydotool`. Helpers run with host loader variables from an
|
||||
AppImage, have a bounded timeout, and never retry after one may have emitted
|
||||
partial input. This opt-in cannot promise the shortcut-down target if focus
|
||||
changes. `dotool` needs direct write access to `/dev/uinput`; `ydotool` 1.0+
|
||||
needs a running `ydotoold` with that access and a user-readable socket.
|
||||
VoiceStudio checks these prerequisites before selection. Tray-started Wayland
|
||||
dictation always stays copy-only.
|
||||
@@ -52,6 +52,14 @@ Everything above, plus the toolchain:
|
||||
|
||||
## Install (from source)
|
||||
|
||||
One-liner (installs prerequisites, clones, and builds; WSL works too):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://voicestudio.sh/install | sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git
|
||||
cd VoiceStudio
|
||||
@@ -100,6 +108,22 @@ where the protocol gives applications no say in their own placement and the
|
||||
compositor decides where it appears. The capsule works the same either way; only
|
||||
its position is out of the app's hands there.
|
||||
|
||||
Wayland does not expose a portable identity for the app focused at shortcut
|
||||
down, so VoiceStudio safely leaves the complete transcript on the clipboard and
|
||||
the pill says **Copied** instead of risking insertion into a different app.
|
||||
|
||||
Advanced users can opt into current-focus insertion with
|
||||
`VOICESTUDIO_WAYLAND_UNTARGETED_INSERT=1`. wlroots compositors such as Sway and
|
||||
Hyprland use `wtype`; KDE Plasma and GNOME can use `dotool` or `ydotool` to
|
||||
paste the Unicode clipboard payload. The
|
||||
opt-in targets whichever client owns keyboard focus when transcription
|
||||
finishes, not necessarily the app where dictation started. Tray-started
|
||||
dictation remains copy-only. `dotool` needs direct write access to
|
||||
`/dev/uinput` (normally through a distribution udev rule/group). `ydotool`
|
||||
1.0+ needs the `ydotoold` daemon running with that access and its socket
|
||||
available to the desktop user. VoiceStudio skips either helper when its
|
||||
readiness check fails.
|
||||
|
||||
If the global shortcut stops working, restart your desktop's portal service,
|
||||
then save the shortcut again in **Settings → Hotkey** to reopen consent. Portal
|
||||
packages and support vary by desktop; use the backend recommended by your
|
||||
|
||||
@@ -51,6 +51,14 @@ Optional but recommended:
|
||||
|
||||
## Install (from source)
|
||||
|
||||
One-liner (installs prerequisites, clones, and builds):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://voicestudio.sh/install | sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git
|
||||
cd VoiceStudio
|
||||
|
||||
@@ -138,6 +138,28 @@ failing outright.
|
||||
|
||||
**Linked issue:** [#1185](https://github.com/debpalash/VoiceStudio/issues/1185)
|
||||
|
||||
## 1c. Setup blocked: "System RAM … The app will OOM on first dub"
|
||||
|
||||
**Symptom:** the setup wizard's System Check shows **System RAM** in red and
|
||||
"Resolve blockers to continue" stays disabled — often on an 8 GB machine that
|
||||
reports ~7.8 GB usable (firmware and integrated graphics reserve a slice of
|
||||
installed RAM).
|
||||
|
||||
**Cause:** the preflight compares OS-reported RAM against the 8 GB minimum.
|
||||
Since [#1618](https://github.com/debpalash/VoiceStudio/issues/1618) the check
|
||||
tolerates that reserved-memory gap, so 8 GB-installed machines pass.
|
||||
|
||||
**Fix:** update to the latest release. If your machine is genuinely below the
|
||||
minimum and you accept the out-of-memory risk (long dubs may crash), set
|
||||
`OMNIVOICE_RAM_PREFLIGHT=0` before launching — the hard block becomes a
|
||||
warning. On Windows run PowerShell
|
||||
`[Environment]::SetEnvironmentVariable('OMNIVOICE_RAM_PREFLIGHT','0','User')`
|
||||
and relaunch; on macOS/Linux export it in the shell that starts the app. (The
|
||||
in-app Settings panel can't help here — this blocker appears before setup
|
||||
completes.)
|
||||
|
||||
**Linked issues:** [#1618](https://github.com/debpalash/VoiceStudio/issues/1618)
|
||||
|
||||
## 2. HF 401 / pyannote license not accepted
|
||||
|
||||
**Symptom:** dubbing fails with `HfHubHTTPError: 401 Client Error: Unauthorized
|
||||
@@ -524,6 +546,11 @@ and `OMNIVOICE_GENERATE_TIMEOUT_S` (generation) — both in seconds, default 300
|
||||
default 120). **Raise** them for very long single files/generations, **lower**
|
||||
them to fail faster on a small machine.
|
||||
|
||||
CPU-only hosts use a bounded 600-second generation floor because correct CPU
|
||||
synthesis can take longer than the accelerated five-minute budget. Override it
|
||||
with `OMNIVOICE_CPU_GENERATE_TIMEOUT_S`; an explicit higher
|
||||
or lower `OMNIVOICE_GENERATE_TIMEOUT_S` always wins.
|
||||
|
||||
**Two things changed here** ([#1190](https://github.com/debpalash/VoiceStudio/issues/1190)):
|
||||
|
||||
- **Waiting in line is no longer counted as compute.** The generate budget used
|
||||
@@ -763,7 +790,8 @@ unaffected and works normally.
|
||||
> **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.
|
||||
> deep-links the exact OS pane described above. The dictation blocker rechecks
|
||||
> Accessibility while it is visible and closes as soon as macOS reports the grant.
|
||||
|
||||
## Dub: "translation engine needs the optional … package"
|
||||
|
||||
@@ -807,6 +835,15 @@ VoiceStudio now tries, in order: the default GitHub host → a gh-proxy mirror
|
||||
|
||||
**Linked issues:** [#130](https://github.com/debpalash/VoiceStudio/issues/130), [#60](https://github.com/debpalash/VoiceStudio/issues/60), [#57](https://github.com/debpalash/VoiceStudio/issues/57)
|
||||
|
||||
## Workspace navigation crashes with `insertBefore` / `NotFoundError`
|
||||
|
||||
This was a `v0.5.0` workspace-lifecycle bug exposed by rapid Launchpad ↔ Dub
|
||||
navigation while media renderers were cleaning up. Current builds isolate each
|
||||
workspace under its own DOM owner. Update VoiceStudio; no model or project data
|
||||
repair is required.
|
||||
|
||||
**Linked issue:** [#1590](https://github.com/debpalash/VoiceStudio/issues/1590)
|
||||
|
||||
## Uninstalling / removing all of VoiceStudio's data
|
||||
|
||||
VoiceStudio is fully local — no accounts, no services, nothing to deactivate. To
|
||||
|
||||
@@ -51,7 +51,14 @@ see [linux.md — AMD GPU (ROCm)](linux.md#amd-gpu-rocm).
|
||||
|
||||
## Install (from source)
|
||||
|
||||
Run from a regular (non-admin) PowerShell:
|
||||
One-liner from a regular (non-admin) PowerShell — installs Git, FFmpeg, uv,
|
||||
and bun via winget, then clones and builds:
|
||||
|
||||
```powershell
|
||||
irm https://voicestudio.sh/install | iex
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git
|
||||
|
||||
@@ -235,6 +235,47 @@ RAM/VRAM) turns a guessing game into a bisect.
|
||||
Measured results per engine/device — and how to contribute yours — live in
|
||||
[benchmarks.md](benchmarks.md).
|
||||
|
||||
## Performance budgets
|
||||
|
||||
CI guards the hot paths above against regressions — not with wall-clock
|
||||
budgets (CI hardware varies too much for a stable "≤5 % slower" threshold),
|
||||
but with **operation-count budgets** in
|
||||
`tests/test_perf_operation_budgets.py`, which fail on *any* regression:
|
||||
|
||||
- **Streaming TTS (`/ws/tts`)**: exactly one engine `generate` per sentence
|
||||
chunk, and exactly one text-normalization pass per request (never one per
|
||||
sentence).
|
||||
- **Dub re-mix**: a fit-only re-mix (`regen_only=[]`) of cached segments
|
||||
makes **zero** TTS calls. The zero-decode / zero-rewrite budget activates
|
||||
with the natural-rate cached fast path (each cache is then decoded exactly
|
||||
once, by the final assembly).
|
||||
- **Batch dubbing (native batches)**: N renderable segments at batch width W
|
||||
cost exactly ⌈N/W⌉ `generate_batch` calls and zero per-segment `generate`
|
||||
calls when native batching is enabled.
|
||||
|
||||
Updating a budget is a deliberate act: if a change legitimately adds an
|
||||
operation to a guarded path, change the expected count in the same PR with a
|
||||
comment justifying the new floor. Never loosen a budget just to make CI pass
|
||||
— that is the regression the budget exists to catch.
|
||||
|
||||
## Batch and streaming behavior
|
||||
|
||||
Batch dubbing renders several segments in one native forward pass when the
|
||||
selected engine supports it. The width is derived from the host rather than
|
||||
fixed, because a wider forward pass needs proportionally more device memory:
|
||||
CPU hosts and cards with less than ~2 GB of headroom above the engine's
|
||||
single-job requirement stay at one segment, and the width steps up to 2, 4,
|
||||
and 8 as headroom allows. `OMNIVOICE_DUB_BATCH_WIDTH` overrides it (1 disables
|
||||
batching, 16 is the ceiling). Engines without native batching inherit a
|
||||
compatibility fallback that preserves the one-segment behavior.
|
||||
|
||||
Streaming clients also receive measured latency in the `/ws/tts` terminal
|
||||
`done` frame: `ttfa_ms` is request-to-first-audio, `gen_time_s` is the
|
||||
end-to-end wall clock including delivery, and `rtf` is *synthesis* time
|
||||
divided by generated-audio duration — measured around the render calls only,
|
||||
so a slow client cannot inflate it. The backend log records the same values, so a slow first chunk is
|
||||
distinguishable from a fast first chunk followed by a long render.
|
||||
|
||||
## Things that look like knobs but aren't
|
||||
|
||||
- **Deleting and re-adding a voice** doesn't speed anything up; the reference
|
||||
|
||||
@@ -16,6 +16,10 @@ For another device on the same network — e.g. opening the web UI on your phone
|
||||
|
||||
You can also drive this from **Settings → Sharing & Remote Access**.
|
||||
|
||||
Desktop installers include the web interface used by the LAN address; another
|
||||
device does not need VoiceStudio installed and the host does not need a source
|
||||
checkout or a separate frontend development server.
|
||||
|
||||
### How the PIN works
|
||||
- A fresh 6-digit PIN is generated each time you enable sharing; it is never written to disk.
|
||||
- The QR encodes the PIN (`…/?pin=######`) so scanning connects in one step. Typing the bare URL instead prompts for the PIN.
|
||||
|
||||
@@ -30,9 +30,9 @@ VoiceStudio today is **asymmetric** across its three longform surfaces:
|
||||
The dub pipeline **already** content-addresses segments so that editing one line re-synthesizes only that line:
|
||||
|
||||
- **`backend/services/incremental.py`** — `segment_fingerprint(seg)` (52–67) is a sha1 over the **generation inputs that actually affect TTS output**: `_GEN_INPUT_FIELDS = ("text","target_lang","profile_id","instruct","speed","direction","effect_preset")` (line 23). `_canon_value` (32–49) normalizes None/""/missing and int↔float so the **server-parsed view** and the **client-raw view** of the same logical segment hash identically — the root-cause fix for #281 ("1 edit re-dubs all N lines"). `fit_fingerprint(params)` (101–116) hashes the **fit configuration separately and on purpose** (70–76): a fit-knob change must trigger a **re-mix** of already-rendered natural-rate WAVs (`regen_only=[]`), never a re-TTS. `plan_incremental(segments, *, stored_hashes)` (119–157) returns `{stale, fresh, total, fingerprints}`.
|
||||
- **`backend/api/routers/dub_generate.py`** — honors `regen_only` (113): for a segment **not** in `regen_only` it reloads the cached `dub_seg_path(job_id, seg_id)` (160–197, with a legacy index-name fallback at 162–166) instead of re-running TTS; for stale segments it runs `_gen(...)`. After the loop it **always re-stitches the full `dubbed_{lang}.wav`** (766–770) and persists `job["seg_hashes"]` (498–512) + `seg_order` (127). The `done` SSE ships `seg_hashes`/`seg_num_step` back (840). Strategy-transition guard (122–123) and `seg_wav_kind` (830) keep smart_fit reuse correct.
|
||||
- **`backend/api/routers/dub_generate.py`** — honors `regen_only`: for a segment **not** in `regen_only` it reuses the cached WAV at `dub_seg_path(job_id, f"{lang_code}_{seg_key}")` (`_seg_lang_path` — the key is language-qualified, so each target language caches its own render) instead of re-running TTS; same-rate natural caches go straight into the bounded-memory assembly manifest, while strict-slot or sample-rate conversion keeps the transformed scratch path. After the loop it **always re-stitches the full `dubbed_{lang}.wav`** and persists `job["seg_hashes"]` + `seg_order`. The `done` SSE ships `seg_hashes`/`seg_num_step` back. The strategy-transition guard and `seg_wav_kind` prevent every natural-rate mode (`concise`, `stretch_video`, `smart_fit`) from reusing destructively slotted audio.
|
||||
- **`tests/test_redub_incremental.py`** — already asserts the contract end-to-end with a mocked TTS engine: `test_edited_line_produces_different_cached_output` (228–281) proves an edited line's cached WAV changes, the **untouched line's cached WAV is reused byte-for-byte** (272–273), TTS ran exactly once (266), and the final track was rebuilt (281). `test_one_edit_marks_exactly_one_segment_stale` (101–118) proves the planner.
|
||||
- **Per-segment audio + metadata keying.** Audio lives on disk as `{DUB_DIR}/{job_id}/seg_{seg_id}.wav` via `dub_seg_path(job_id, seg_id)` (`backend/core/config.py:54–71`). Metadata lives in the job's `job_data` JSON blob (`dub_history` table, `backend/core/db.py:74–85`), holding `segments`, `seg_order`, `seg_hashes`, `seg_num_step`, `seg_wav_kind`, `dubbed_tracks`, `fit_plans`, `video_stretch_plans`. Stable ids are minted at transcribe time (`s{NNNNN:05x}`, `dub_core.py:600`). Job persistence is `dub_pipeline.get_job`/`save_job`/`put_job` (`backend/services/dub_pipeline.py:158–214`).
|
||||
- **Per-segment audio + metadata keying.** Audio lives on disk as `{DUB_DIR}/{job_id}/seg_{lang}_{seg_id}.wav` via `_seg_lang_path` → `dub_seg_path(job_id, f"{lang_code}_{seg_key}")` (`backend/core/config.py:54–71` for the path guard); legacy unqualified `seg_{seg_id}.wav` files from single-language-era jobs remain readable through the gated `_legacy_seg_cache_ok` fallback. Metadata lives in the job's `job_data` JSON blob (`dub_history` table, `backend/core/db.py:74–85`), holding `segments`, `seg_order`, `seg_hashes`, `seg_num_step`, `seg_wav_kind`, `dubbed_tracks`, `fit_plans`, `video_stretch_plans`. Stable ids are minted at transcribe time (`s{NNNNN:05x}`, `dub_core.py:600`). Job persistence is `dub_pipeline.get_job`/`save_job`/`put_job` (`backend/services/dub_pipeline.py:158–214`).
|
||||
- **Longform (Audiobook + Stories) share one renderer** `_render_longform_sse` (`backend/api/routers/audiobook.py:403–595`) and one **chapter-level** content-addressed key `chapter_cache_key` (`backend/services/longform_render.py:110–136`), cached under `OUTPUTS_DIR/longform_cache` (`_render_chapter_cached`, `audiobook.py:314–355`). The lexicon is folded into the key (338–341). Resume (`audiobook.py:714–759`) reuses already-rendered chapters because the key is content-based. **But the granularity is the whole chapter** (longform_render.py:117–125) — that is the gap.
|
||||
|
||||
**Conclusion:** Dub is the reference implementation. Stories already has the *editor UI* but no incremental backend. Audiobook has neither a per-line editor nor sub-chapter incrementality. This spec makes all three behave like a "Studio" by (1) standardizing the editor affordances and (2) pushing the dub-proven `fingerprint → stale → regen_only → re-stitch` loop down into the longform renderer at **span granularity**.
|
||||
@@ -250,7 +250,7 @@ Each slice is independently shippable, continuous-to-main, with its own regressi
|
||||
| **Fingerprint parity drift** (server vs client hash differently → every span looks stale → degrades to full render, the #281 regression class). | Reuse `incremental._canon_value` verbatim; add the server-vs-client parity test first (TDD), exactly as dub did. |
|
||||
| **Parser-twin divergence** (Python `longform_parser` vs JS `longformParser.js` mint different span ids). | Stable ids derived from `(chapter_index, span_index)` are computed identically in both; the existing golden-corpus byte-for-byte test (#27) gates it. |
|
||||
| **Stitch seams** (per-span WAVs stitched may click vs a monolithic chapter render). | `synthesize_chapter` already crossfades spans (50 ms) and hard-concats silences (`services/audiobook.py:121–139`) — the seam behavior is identical whether a span was freshly rendered or cache-loaded (same WAV bytes). |
|
||||
| **smart_fit-style double-processing on dub** (reusing a slotted WAV under smart_fit). | Already solved: the strategy-transition guard (`dub_generate.py:122–123`) + `seg_wav_kind` (830) force a full regen when the cached WAVs are the wrong kind. No new exposure. |
|
||||
| **Timing-mode double-processing on dub** (reusing a slotted WAV in a natural-rate mode). | The strategy-transition guard + `seg_wav_kind` force a full regen when `concise`, `stretch_video`, or `smart_fit` sees the wrong cache kind. No new exposure. |
|
||||
| **Existing projects forced to re-render.** | First edit treats unknown-hash spans as stale **once** (correct output), never corrupts cache; no upgrade-time mass re-render. |
|
||||
| **Emotion/style field lands in the wrong fingerprint bucket** (re-TTS when it should re-mix, or vice-versa, wasting compute or shipping stale audio). | Q1 decision pins the bucket per field; the `fit_fingerprint` precedent (incremental.py:70–116) gives both buckets a tested home; 03f ships last, after the field's semantics are known. |
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Dictation Flow Program — local WhisperFlow-class dictation on Parakeet
|
||||
# Dictation Flow Program — local cross-platform flow dictation
|
||||
|
||||
*Spec, 2026-07-16. Research inputs: three-agent study — product landscape (Wispr Flow, jamiepine/voicebox, Handy, VoiceInk, Whispering, Talon, Claude Code `/voice`), in-repo capability map, and Parakeet TDT/Nemotron feasibility (sherpa-onnx). Sources cited inline where load-bearing.*
|
||||
*Spec, 2026-07-16. Research inputs: a multi-agent product-landscape study, in-repo capability map, and local ASR feasibility review. Sources are cited inline where load-bearing.*
|
||||
|
||||
## Why
|
||||
|
||||
Dictating prompts to AI agents is the fastest-growing text-input workload (Claude Code shipped built-in `/voice`; Wispr Flow raised at ~$2B on it) — and every polished option is **cloud** (Wispr: cloud-only, no Linux, one privacy scandal already; Claude Code voice: cloud-only, no SSH). The best open competitor, **jamiepine/voicebox** (41.7k★, MIT — our refinement layer is already adapted from it), only ships reliable auto-paste on macOS. VoiceStudio already has the hard parts: a Wispr-style pill, global hotkey, sherpa-onnx streaming WS, **Parakeet TDT v3 int8 as the shipped default**, clipboard-restoring paste, and local-LLM refinement. A local, cross-platform, private flow-dictation experience is reachable and strategically differentiating — the wedge is **local + Linux/Wayland + agent-prompting**, where nobody credible plays.
|
||||
Dictating prompts to AI agents is a rapidly growing text-input workload, while polished options remain cloud-first and Linux support is uneven. VoiceStudio already has the hard parts: a compact capture pill, global hotkey, sherpa-onnx streaming WS, **Whisper Tiny int8 as the shipped cross-platform default**, clipboard-restoring paste, and local-LLM refinement. A local, cross-platform, private flow-dictation experience is reachable and strategically differentiating — the wedge is **local + Linux/Wayland + agent-prompting**.
|
||||
|
||||
## Current state (verified in-repo)
|
||||
|
||||
Widget: pill webview + `tauri-plugin-global-shortcut` (`CmdOrCtrl+Shift+Space`, toggle/hold) on macOS, Windows and X11 + the GlobalShortcuts desktop portal on Wayland + browser-mode keyboard fallback; `getUserMedia` → raw-PCM WS `/ws/transcribe`; paste via arboard+enigo with clipboard restore, macOS a11y fail-loud, Windows no-activate. Backend: 7 sherpa models (Parakeet TDT v3 default), streaming path (zipformer/paraformer) + chunked-offline path (0.8 s partial cadence, **RMS silence gate**), `text_polish` on finals, opt-in LLM refinement (Ollama/LM Studio, ≤4 s wall clock). Gaps: no real VAD, no dictionary/hotwords, no per-app awareness, no command grammar, no language picker, enigo-only Linux insertion, no comprehensive dictation feature guide beyond the Linux installation note, picker understates model size ~4×.
|
||||
Widget: pill webview + `tauri-plugin-global-shortcut` (`CmdOrCtrl+Shift+Space`, toggle/hold) on macOS, Windows and X11 + the GlobalShortcuts desktop portal on Wayland + browser-mode keyboard fallback; `getUserMedia` → 16 kHz raw-PCM WS `/ws/transcribe`; a native session captures the destination before the pill appears, restores an untouched clipboard by generation, reactivates macOS/Windows/X11 targets, and uses a truthful copy fallback on Wayland unless current-focus insertion is explicitly enabled. Backend: 7 sherpa models (Whisper Tiny default), streaming path (zipformer/paraformer) + chunked-offline path (0.8 s partial cadence, **RMS silence gate**), shared speech-evidence model demotion with installed-only ASR fallback, `text_polish` on finals, opt-in LLM refinement (Ollama/LM Studio, ≤4 s wall clock). Gaps: no real VAD, no dictionary/hotwords, no per-app formatting profiles, no command grammar, no language picker, picker understates model size ~4×.
|
||||
|
||||
## Program phases
|
||||
|
||||
@@ -41,7 +41,7 @@ Parakeet's one real weakness is OOV technical terms — and the dictionary is Wi
|
||||
|
||||
### Phase 4 — insertion reliability + Wayland (beat everyone on Linux)
|
||||
- **Reliability engineering** (the boring 20% that reads professional; Wispr does 5 retries): retry-with-backoff on paste, transcript stays on clipboard + toast on failure, password-field refusal, Windows elevated-window detection.
|
||||
- **Wayland insertion chain** replacing bare enigo on Linux: kwtype→wtype→dotool→ydotool→wl-copy+notify fallback (Handy's proven cascade), IBus/Fcitx5 input-method commit path evaluated for GNOME (highest quality, nobody mainstream ships it), libei/RemoteDesktop-portal as the forward bet. Wispr has no Linux at all; voicebox has no Linux paste — this is the moat.
|
||||
- **Wayland insertion chain** replacing bare enigo on Linux: wtype→dotool→ydotool→wl-copy+notify fallback, IBus/Fcitx5 input-method commit path evaluated for GNOME (highest quality, nobody mainstream ships it), libei/RemoteDesktop-portal as the forward bet. Reliable local Linux insertion is the moat.
|
||||
|
||||
### Phase 5 — command mode (headline, local-only differentiator)
|
||||
Second hotkey → speak an instruction over selected text → local LLM rewrite → explicit Apply. Wispr charges for this; ours is local and free. Requires configured LLM; hidden otherwise (existing `llm_ready` plumbing).
|
||||
@@ -56,10 +56,10 @@ Second hotkey → speak an instruction over selected text → local LLM rewrite
|
||||
| Use case | Model | Partials | Final after pause | Disk/RAM |
|
||||
|---|---|---|---|---|
|
||||
| English, best feel | nemotron-streaming-en 160 ms (new, Ph. 1) | 200–400 ms | ~0.5–0.7 s | 0.66 GB / ~1.2 GB |
|
||||
| Multilingual default | parakeet-tdt-v3 + silero-VAD (upgraded path) | 0.8 s cadence | ~0.4–0.7 s | 0.67 GB / ~1.2 GB |
|
||||
| European languages (opt-in) | parakeet-tdt-v3 + silero-VAD (upgraded path) | 0.8 s cadence | ~0.4–0.7 s | 0.67 GB / ~1.2 GB |
|
||||
| Multilingual streaming (opt-in) | Nemotron-3.5 320 ms (new) | ~400 ms | ~0.7 s | 0.68 GB / ~1.2 GB |
|
||||
| Low-RAM | zipformer-20M (existing) | ~100 ms | ~0.6 s | 0.13 GB / ~0.3 GB |
|
||||
| CJK / 90+ langs | whisper-tiny (existing; consider small) | n/a | seconds | 0.12 GB |
|
||||
| Multilingual default / CJK | whisper-tiny (existing; consider small) | n/a | seconds | 0.104 GB |
|
||||
|
||||
## Top risks
|
||||
|
||||
|
||||
+42
-42
@@ -27,67 +27,67 @@
|
||||
"test:visual:update": "playwright test --config=playwright.visual.config.ts --update-snapshots"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-progress": "^1.1.10",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-slider": "^1.4.1",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@radix-ui/react-toggle": "^1.1.12",
|
||||
"@radix-ui/react-toggle-group": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"@scalar/api-reference-react": "^0.9.52",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/source-serif-4": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-progress": "^1.1.16",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-slider": "^1.4.7",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-toggle": "^1.1.18",
|
||||
"@radix-ui/react-toggle-group": "^1.1.19",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@scalar/api-reference-react": "^0.9.63",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"i18next": "^26.3.1",
|
||||
"country-flag-icons": "^1.6.20",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.18.0",
|
||||
"posthog-js": "^1.399.2",
|
||||
"lucide-react": "^1.31.0",
|
||||
"posthog-js": "^1.417.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-window": "^2.2.7",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-window": "^2.3.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wavesurfer.js": "^7.12.8",
|
||||
"zustand": "^5.0.14"
|
||||
"wavesurfer.js": "^7.12.11",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.0",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^10.5.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.6.0",
|
||||
"globals": "^17.11.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.23.0",
|
||||
"knip": "^6.32.2",
|
||||
"oxfmt": "^0.57.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"playwright-core": "1.61.0",
|
||||
"oxlint": "1.71.0",
|
||||
"playwright-core": "1.62.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9"
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+157
-1
@@ -101,6 +101,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -1040,6 +1041,12 @@ dependencies = [
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -1287,6 +1294,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
@@ -2582,6 +2595,15 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
@@ -2687,6 +2709,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -2948,8 +2971,11 @@ dependencies = [
|
||||
"enigo",
|
||||
"fs4",
|
||||
"getrandom 0.3.4",
|
||||
"gtk",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -2973,6 +2999,7 @@ dependencies = [
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
@@ -3017,6 +3044,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
@@ -3097,6 +3134,17 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@@ -3181,7 +3229,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -3369,6 +3417,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -5285,6 +5342,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -5633,6 +5701,76 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.102"
|
||||
@@ -6468,6 +6606,24 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix",
|
||||
"thiserror 2.0.18",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
|
||||
@@ -67,7 +67,13 @@ dirs-next = "2"
|
||||
# Native (OS-side) clipboard write for dictation auto-paste: the widget window
|
||||
# is unfocused on macOS so the simulated ⌘V reaches the target app, which makes
|
||||
# the WebView clipboard APIs fail silently there (#287)
|
||||
arboard = "3"
|
||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Capture the frontmost application at shortcut-down and reactivate that exact
|
||||
# process before transcript delivery.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "libc", "NSRunningApplication", "NSWorkspace"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
@@ -84,13 +90,15 @@ windows-core = "0.61"
|
||||
# `HWND` type — no second copy of the crate enters the dependency graph.
|
||||
# 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"] }
|
||||
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Registry", "Win32_System_Threading"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0"
|
||||
gtk = "0.18"
|
||||
x11rb = "0.13"
|
||||
# Wayland compositors do not expose global keys through X11 grabs. Use the
|
||||
# standard xdg-desktop-portal GlobalShortcuts interface there; zbus is already
|
||||
# present transitively through Tauri's opener/single-instance plugins.
|
||||
|
||||
@@ -845,6 +845,59 @@ pub fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the production SPA beside `backend/`, where the Python server's
|
||||
/// static-file mount resolves it for Network Sharing clients.
|
||||
fn sync_packaged_frontend(resource_root: &Path, project_dir: &Path) -> io::Result<()> {
|
||||
let source = resource_root.join("frontend").join("dist");
|
||||
if !source.join("index.html").is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"bundled frontend is missing index.html",
|
||||
));
|
||||
}
|
||||
|
||||
let destination = project_dir.join("frontend").join("dist");
|
||||
let frontend_dir = destination.parent().expect("frontend dist has a parent");
|
||||
let staging = frontend_dir.join(".dist-staging");
|
||||
let backup = frontend_dir.join(".dist-backup");
|
||||
fs::create_dir_all(frontend_dir)?;
|
||||
if staging.exists() {
|
||||
fs::remove_dir_all(&staging)?;
|
||||
}
|
||||
// A previous process may have died after moving the live shell aside but
|
||||
// before installing staging. Restore the only known-good SPA before doing
|
||||
// any new work; never discard that recovery copy merely because startup
|
||||
// retried.
|
||||
if !destination.exists() && backup.exists() {
|
||||
fs::rename(&backup, &destination)?;
|
||||
}
|
||||
if let Err(error) = copy_dir_recursive(&source, &staging) {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if destination.exists() {
|
||||
if backup.exists() {
|
||||
// An interrupted cleanup can leave an incomplete backup. Remove
|
||||
// it before touching the known-working destination; if cleanup
|
||||
// fails, abort with the live shell still intact.
|
||||
fs::remove_dir_all(&backup)?;
|
||||
}
|
||||
fs::rename(&destination, &backup)?;
|
||||
}
|
||||
if let Err(error) = fs::rename(&staging, &destination) {
|
||||
if backup.exists() {
|
||||
let _ = fs::rename(&backup, &destination);
|
||||
}
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(error);
|
||||
}
|
||||
if backup.exists() {
|
||||
fs::remove_dir_all(backup)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh `pyproject.toml` + `uv.lock` in the project dir from the bundled
|
||||
/// resources, so an upgraded app never runs freshly-synced backend code against
|
||||
/// the stale dependency manifests from when the venv was first created (#307 —
|
||||
@@ -1539,11 +1592,13 @@ manually, then relaunch.",
|
||||
if let Some(ref res) = resource_dir {
|
||||
let flat = res.clone();
|
||||
let up2 = res.join("_up_").join("_up_");
|
||||
let (res_omni, res_backend) = if flat.join("pyproject.toml").is_file() {
|
||||
(flat.join("omnivoice"), flat.join("backend"))
|
||||
let res_root = if flat.join("pyproject.toml").is_file() {
|
||||
flat
|
||||
} else {
|
||||
(up2.join("omnivoice"), up2.join("backend"))
|
||||
up2
|
||||
};
|
||||
let res_omni = res_root.join("omnivoice");
|
||||
let res_backend = res_root.join("backend");
|
||||
if res_omni.is_dir() {
|
||||
let omnivoice_dir = project_dir.join("omnivoice");
|
||||
let _ = fs::remove_dir_all(&omnivoice_dir);
|
||||
@@ -1561,6 +1616,11 @@ manually, then relaunch.",
|
||||
}
|
||||
log::info!("Synced backend/ from bundle");
|
||||
}
|
||||
if let Err(e) = sync_packaged_frontend(&res_root, &project_dir) {
|
||||
fail(progress, &format!("Failed to sync frontend/dist: {}", e));
|
||||
return None;
|
||||
}
|
||||
log::info!("Synced frontend/dist from bundle");
|
||||
// #307: the source dirs above track the bundle, so the
|
||||
// dependency manifests must too — otherwise an upgrade runs
|
||||
// new code against a venv that predates newly added deps.
|
||||
@@ -1659,6 +1719,17 @@ the existing venv; newly added dependencies may be missing (#307)",
|
||||
// copies from when the venv was first created.
|
||||
if let Ok(res) = app.path().resource_dir() {
|
||||
let _ = refresh_project_manifests(&res, &project_dir);
|
||||
let flat = res.clone();
|
||||
let up2 = res.join("_up_").join("_up_");
|
||||
let res_root = if flat.join("pyproject.toml").is_file() {
|
||||
flat
|
||||
} else {
|
||||
up2
|
||||
};
|
||||
if let Err(e) = sync_packaged_frontend(&res_root, &project_dir) {
|
||||
fail(progress, &format!("Failed to sync frontend/dist: {}", e));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let mut repair_cmd = Command::new(&uv_path);
|
||||
scrub_python_env(&mut repair_cmd); // #144: don't inherit AppImage's bundled Python
|
||||
@@ -1763,16 +1834,22 @@ the existing venv; newly added dependencies may be missing (#307)",
|
||||
let flat = resource_dir.clone();
|
||||
let up2 = resource_dir.join("_up_").join("_up_");
|
||||
|
||||
let (resource_pyproject, resource_uvlock, resource_readme, resource_changelog, resource_omnivoice, resource_backend) = if flat.join("pyproject.toml").is_file() {
|
||||
(flat.join("pyproject.toml"), flat.join("uv.lock"), flat.join("README.md"), flat.join("CHANGELOG.md"), flat.join("omnivoice"), flat.join("backend"))
|
||||
let resource_root = if flat.join("pyproject.toml").is_file() {
|
||||
flat
|
||||
} else if up2.join("pyproject.toml").is_file() {
|
||||
(up2.join("pyproject.toml"), up2.join("uv.lock"), up2.join("README.md"), up2.join("CHANGELOG.md"), up2.join("omnivoice"), up2.join("backend"))
|
||||
up2
|
||||
} else {
|
||||
fail(progress, &format!(
|
||||
"Missing bootstrap resources — checked flat={} and _up_={}",
|
||||
flat.display(), up2.display()));
|
||||
return None;
|
||||
};
|
||||
let resource_pyproject = resource_root.join("pyproject.toml");
|
||||
let resource_uvlock = resource_root.join("uv.lock");
|
||||
let resource_readme = resource_root.join("README.md");
|
||||
let resource_changelog = resource_root.join("CHANGELOG.md");
|
||||
let resource_omnivoice = resource_root.join("omnivoice");
|
||||
let resource_backend = resource_root.join("backend");
|
||||
|
||||
if !resource_pyproject.is_file() || !resource_backend.is_dir() {
|
||||
fail(progress, &format!(
|
||||
@@ -1821,6 +1898,10 @@ the existing venv; newly added dependencies may be missing (#307)",
|
||||
fail(progress, &format!("copy backend/: {}", e));
|
||||
return None;
|
||||
}
|
||||
if let Err(e) = sync_packaged_frontend(&resource_root, &project_dir) {
|
||||
fail(progress, &format!("copy frontend/dist: {}", e));
|
||||
return None;
|
||||
}
|
||||
|
||||
let uv_path = match resolve_uv(app, &app_data, progress) {
|
||||
Ok(p) => p,
|
||||
@@ -2051,6 +2132,118 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn packaged_frontend_is_installed_for_the_lan_server() {
|
||||
let resources = tempfile::tempdir().unwrap();
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let source = resources.path().join("frontend").join("dist");
|
||||
fs::create_dir_all(source.join("assets")).unwrap();
|
||||
fs::write(source.join("index.html"), "new shell").unwrap();
|
||||
fs::write(source.join("assets").join("client.js"), "new client").unwrap();
|
||||
|
||||
let installed = project.path().join("frontend").join("dist");
|
||||
fs::create_dir_all(&installed).unwrap();
|
||||
fs::write(installed.join("index.html"), "stale shell").unwrap();
|
||||
|
||||
sync_packaged_frontend(resources.path(), project.path()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(installed.join("index.html")).unwrap(),
|
||||
"new shell"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(installed.join("assets").join("client.js")).unwrap(),
|
||||
"new client"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packaged_frontend_error_does_not_expose_resource_path() {
|
||||
let resources = tempfile::tempdir().unwrap();
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
|
||||
let error = sync_packaged_frontend(resources.path(), project.path()).unwrap_err();
|
||||
|
||||
assert_eq!(error.kind(), io::ErrorKind::NotFound);
|
||||
assert_eq!(error.to_string(), "bundled frontend is missing index.html");
|
||||
assert!(!error.to_string().contains(&resources.path().display().to_string()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_packaged_frontend_copy_preserves_installed_shell() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let resources = tempfile::tempdir().unwrap();
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let source = resources.path().join("frontend").join("dist");
|
||||
fs::create_dir_all(source.join("assets")).unwrap();
|
||||
fs::write(source.join("index.html"), "new shell").unwrap();
|
||||
symlink("missing-client.js", source.join("assets").join("client.js")).unwrap();
|
||||
|
||||
let installed = project.path().join("frontend").join("dist");
|
||||
fs::create_dir_all(&installed).unwrap();
|
||||
fs::write(installed.join("index.html"), "working shell").unwrap();
|
||||
|
||||
sync_packaged_frontend(resources.path(), project.path()).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(installed.join("index.html")).unwrap(),
|
||||
"working shell"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn interrupted_frontend_swap_recovers_backup_before_a_later_copy_failure() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let resources = tempfile::tempdir().unwrap();
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let source = resources.path().join("frontend").join("dist");
|
||||
fs::create_dir_all(source.join("assets")).unwrap();
|
||||
fs::write(source.join("index.html"), "new shell").unwrap();
|
||||
symlink("missing-client.js", source.join("assets").join("client.js")).unwrap();
|
||||
|
||||
let frontend = project.path().join("frontend");
|
||||
let installed = frontend.join("dist");
|
||||
let backup = frontend.join(".dist-backup");
|
||||
fs::create_dir_all(&backup).unwrap();
|
||||
fs::write(backup.join("index.html"), "working backup shell").unwrap();
|
||||
|
||||
sync_packaged_frontend(resources.path(), project.path()).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(installed.join("index.html")).unwrap(),
|
||||
"working backup shell"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_backup_cleanup_failure_preserves_working_destination() {
|
||||
let resources = tempfile::tempdir().unwrap();
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let source = resources.path().join("frontend").join("dist");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
fs::write(source.join("index.html"), "new shell").unwrap();
|
||||
|
||||
let frontend = project.path().join("frontend");
|
||||
let installed = frontend.join("dist");
|
||||
let backup = frontend.join(".dist-backup");
|
||||
fs::create_dir_all(&installed).unwrap();
|
||||
fs::write(installed.join("index.html"), "working shell").unwrap();
|
||||
// A non-directory at the interrupted backup path makes cleanup fail
|
||||
// and would also prevent the live destination from being renamed.
|
||||
fs::write(&backup, "partial backup").unwrap();
|
||||
|
||||
sync_packaged_frontend(resources.path(), project.path()).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(installed.join("index.html")).unwrap(),
|
||||
"working shell"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_drift_sync_preserves_user_installed_engines() {
|
||||
// #1029: the routine update sync must carry --inexact so a
|
||||
|
||||
+185
-161
@@ -7,13 +7,13 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::image::Image;
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
use crate::dictation_shortcut::{DictationShortcutManager, ShortcutInfo, update_tray_hint};
|
||||
use crate::config::{load_config, save_config};
|
||||
use crate::dictation_shortcut::{update_tray_hint, DictationShortcutManager, ShortcutInfo};
|
||||
use crate::{AppFlags, TrayHandle};
|
||||
use crate::{TRAY_ICON_DEFAULT, TRAY_ICON_RECORDING};
|
||||
use crate::config::{load_config, save_config};
|
||||
|
||||
// ── Native host-path authorization ───────────────────────────────────────
|
||||
|
||||
@@ -65,10 +65,7 @@ fn remember_reveal_path<R: tauri::Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reveal_path_is_authorized<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
target: &Path,
|
||||
) -> bool {
|
||||
fn reveal_path_is_authorized<R: tauri::Runtime>(app: &tauri::AppHandle<R>, target: &Path) -> bool {
|
||||
if let Ok(data_root) = fs::canonicalize(
|
||||
crate::setup::resolved_data_dir(app).unwrap_or_else(crate::setup::default_data_dir),
|
||||
) {
|
||||
@@ -89,12 +86,7 @@ fn reveal_path_is_authorized<R: tauri::Runtime>(
|
||||
fn validate_host_path(kind: &str, path: PathBuf) -> Result<PathBuf, String> {
|
||||
if !matches!(
|
||||
kind,
|
||||
"models_dir"
|
||||
| "ffmpeg"
|
||||
| "ffprobe"
|
||||
| "dub_export"
|
||||
| "soni_input"
|
||||
| "soni_output_dir"
|
||||
"models_dir" | "ffmpeg" | "ffprobe" | "dub_export" | "soni_input" | "soni_output_dir"
|
||||
) {
|
||||
return Err("Unsupported host-path capability".into());
|
||||
}
|
||||
@@ -216,8 +208,11 @@ pub async fn authorize_host_path(
|
||||
kind,
|
||||
path: validated.to_string_lossy().into_owned(),
|
||||
};
|
||||
fs::write(&target, serde_json::to_vec(&payload).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| format!("Could not authorize path: {e}"))?;
|
||||
fs::write(
|
||||
&target,
|
||||
serde_json::to_vec(&payload).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| format!("Could not authorize path: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -247,7 +242,10 @@ mod host_path_authorization_tests {
|
||||
|
||||
#[test]
|
||||
fn empty_models_path_is_the_authorized_default_reset() {
|
||||
assert_eq!(validate_host_path("models_dir", PathBuf::new()).unwrap(), PathBuf::new());
|
||||
assert_eq!(
|
||||
validate_host_path("models_dir", PathBuf::new()).unwrap(),
|
||||
PathBuf::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -259,11 +257,9 @@ mod host_path_authorization_tests {
|
||||
destination,
|
||||
);
|
||||
assert!(validate_host_path("dub_export", PathBuf::from("relative/export.wav")).is_err());
|
||||
assert!(validate_host_path(
|
||||
"dub_export",
|
||||
parent.join("missing-directory/export.wav"),
|
||||
)
|
||||
.is_err());
|
||||
assert!(
|
||||
validate_host_path("dub_export", parent.join("missing-directory/export.wav"),).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,12 +312,14 @@ pub fn read_log_tail(source: String, tail: Option<usize>) -> LogTailPayload {
|
||||
let path = match source.as_str() {
|
||||
"backend" => backend_runtime_log_path(),
|
||||
"tauri" => tauri_log_path(),
|
||||
_ => return LogTailPayload {
|
||||
lines: vec![],
|
||||
path: String::new(),
|
||||
exists: false,
|
||||
total_lines: 0,
|
||||
},
|
||||
_ => {
|
||||
return LogTailPayload {
|
||||
lines: vec![],
|
||||
path: String::new(),
|
||||
exists: false,
|
||||
total_lines: 0,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
@@ -363,15 +361,11 @@ fn backend_runtime_log_path() -> PathBuf {
|
||||
let data_dir = if cfg!(target_os = "macos") {
|
||||
dirs_data_dir().join("OmniVoice")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
PathBuf::from(
|
||||
std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()),
|
||||
)
|
||||
.join("OmniVoice")
|
||||
PathBuf::from(std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()))
|
||||
.join("OmniVoice")
|
||||
} else {
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
.join(".omnivoice")
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
|
||||
.join(".omnivoice")
|
||||
};
|
||||
data_dir.join("omnivoice.log")
|
||||
}
|
||||
@@ -379,16 +373,12 @@ fn backend_runtime_log_path() -> PathBuf {
|
||||
fn dirs_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
.join("Library/Application Support")
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
|
||||
.join("Library/Application Support")
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,7 +393,10 @@ fn tauri_log_path() -> PathBuf {
|
||||
.join("tauri.log")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| home.clone());
|
||||
PathBuf::from(appdata).join(bid).join("logs").join("tauri.log")
|
||||
PathBuf::from(appdata)
|
||||
.join(bid)
|
||||
.join("logs")
|
||||
.join("tauri.log")
|
||||
} else {
|
||||
PathBuf::from(&home)
|
||||
.join(".local/share")
|
||||
@@ -508,22 +501,16 @@ fn hf_hub_cache_dir() -> PathBuf {
|
||||
.join("hub")
|
||||
}
|
||||
|
||||
// ── Simulate paste ────────────────────────────────────────────────────────
|
||||
|
||||
use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings};
|
||||
// ── Dictation output ─────────────────────────────────────────────────────
|
||||
|
||||
/// Error-kind builder the dictation widget switches on. Kinds are a plain
|
||||
/// string prefix ("a11y:" | "clipboard:" | "paste:") so the JS side can do
|
||||
/// `err.split(':')[0]` without a serde enum crossing the IPC boundary.
|
||||
/// string prefix ("a11y:" | "clipboard:" | "paste:" | "preflight:") so the
|
||||
/// JS side can do `err.split(':')[0]` without a serde enum crossing the IPC
|
||||
/// boundary.
|
||||
fn kind_err(kind: &str, detail: impl std::fmt::Display) -> String {
|
||||
format!("{kind}:{detail}")
|
||||
}
|
||||
|
||||
/// How long the transcript must sit on the clipboard before the user's
|
||||
/// previous clipboard is restored: ~300ms covers slow paste consumers
|
||||
/// (Electron apps, remote desktops) without being user-noticeable.
|
||||
const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
/// macOS Accessibility grant check — CGEvent key synthesis silently no-ops
|
||||
/// without it. Direct FFI against ApplicationServices: one symbol, not worth
|
||||
/// a crate.
|
||||
@@ -705,7 +692,12 @@ pub fn open_microphone_settings() -> Result<(), String> {
|
||||
.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}")))
|
||||
.map_err(|e| {
|
||||
kind_err(
|
||||
"settings",
|
||||
format!("failed to open microphone settings: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -718,7 +710,12 @@ pub fn open_microphone_settings() -> Result<(), String> {
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| kind_err("settings", format!("failed to open microphone settings: {e}")))
|
||||
.map_err(|e| {
|
||||
kind_err(
|
||||
"settings",
|
||||
format!("failed to open microphone settings: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
@@ -744,7 +741,10 @@ pub fn open_input_monitoring_settings() -> Result<(), String> {
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| {
|
||||
kind_err("settings", format!("failed to open input monitoring settings: {e}"))
|
||||
kind_err(
|
||||
"settings",
|
||||
format!("failed to open input monitoring settings: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -757,71 +757,44 @@ pub fn open_input_monitoring_settings() -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
|
||||
// macOS: fail loud BEFORE touching the clipboard if Accessibility isn't
|
||||
// granted — otherwise the ⌘V below silently goes nowhere and the caller
|
||||
// can't tell (the old fire-and-forget behavior).
|
||||
pub async fn simulate_paste(
|
||||
text: String,
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<crate::dictation_output::DeliveryOutcome, String> {
|
||||
// macOS: a revoked/missing Accessibility grant prevents synthesis, but it
|
||||
// must not discard the result. Keep the full transcript copied and report
|
||||
// the fallback truthfully.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !accessibility_trusted() {
|
||||
return Err(kind_err("a11y", "accessibility permission not granted"));
|
||||
let output = flags.output.clone();
|
||||
return tauri::async_runtime::spawn_blocking(move || {
|
||||
output.copy_for_session(session_id, &text)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| kind_err("clipboard", format!("output worker failed: {error}")))?;
|
||||
}
|
||||
|
||||
// Write the transcript to the clipboard natively first: the widget window
|
||||
// is intentionally unfocused on macOS (so the simulated ⌘V reaches the
|
||||
// target app), which makes the WebView clipboard APIs (navigator.clipboard
|
||||
// / execCommand('copy')) fail silently there (#287). `text` is optional so
|
||||
// call sites that already populated the clipboard keep working.
|
||||
//
|
||||
// Save what the user had there first (text only — restoring images/files
|
||||
// isn't worth the platform-specific surface) so dictation doesn't clobber
|
||||
// their clipboard.
|
||||
let mut saved: Option<String> = None;
|
||||
if let Some(t) = text {
|
||||
let mut cb = arboard::Clipboard::new()
|
||||
.map_err(|e| kind_err("clipboard", format!("init failed: {e}")))?;
|
||||
saved = cb.get_text().ok();
|
||||
cb.set_text(t)
|
||||
.map_err(|e| kind_err("clipboard", format!("write failed: {e}")))?;
|
||||
}
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || output.deliver(session_id, &text))
|
||||
.await
|
||||
.map_err(|error| kind_err("paste", format!("output worker failed: {error}")))?
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(80));
|
||||
|
||||
let mut enigo = Enigo::new(&EnigoSettings::default())
|
||||
.map_err(|e| kind_err("paste", format!("failed to init keyboard sim: {e}")))?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
enigo.key(Key::Meta, Direction::Press)
|
||||
.map_err(|e| kind_err("paste", format!("key press failed: {e}")))?;
|
||||
enigo.key(Key::Unicode('v'), Direction::Click)
|
||||
.map_err(|e| kind_err("paste", format!("key click failed: {e}")))?;
|
||||
enigo.key(Key::Meta, Direction::Release)
|
||||
.map_err(|e| kind_err("paste", format!("key release failed: {e}")))?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
enigo.key(Key::Control, Direction::Press)
|
||||
.map_err(|e| kind_err("paste", format!("key press failed: {e}")))?;
|
||||
enigo.key(Key::Unicode('v'), Direction::Click)
|
||||
.map_err(|e| kind_err("paste", format!("key click failed: {e}")))?;
|
||||
enigo.key(Key::Control, Direction::Release)
|
||||
.map_err(|e| kind_err("paste", format!("key release failed: {e}")))?;
|
||||
}
|
||||
|
||||
// Best-effort restore of the user's clipboard once the target app has
|
||||
// consumed the paste. Only on success — on a paste error the transcript
|
||||
// stays on the clipboard so the user can ⌘V it manually as a fallback.
|
||||
if let Some(prev) = saved {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(CLIPBOARD_RESTORE_DELAY);
|
||||
if let Ok(mut cb) = arboard::Clipboard::new() {
|
||||
let _ = cb.set_text(prev);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
/// Preserve the authoritative transcript without emitting any keyboard input.
|
||||
/// Used after live typing may have left an unknown prefix in the target: a
|
||||
/// second insertion would duplicate text, but losing the complete result is
|
||||
/// not an acceptable fallback.
|
||||
#[tauri::command]
|
||||
pub async fn copy_dictation_output_session(
|
||||
text: String,
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<crate::dictation_output::DeliveryOutcome, String> {
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || output.copy_for_session(session_id, &text))
|
||||
.await
|
||||
.map_err(|error| kind_err("clipboard", format!("output worker failed: {error}")))?
|
||||
}
|
||||
|
||||
// ── Simulate live typing ──────────────────────────────────────────────────
|
||||
@@ -833,18 +806,22 @@ pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
|
||||
/// revised), then `text` is typed. Either may be empty/zero, so a single call
|
||||
/// can correct-then-type in one round trip.
|
||||
///
|
||||
/// Cross-platform: `enigo`'s `.text()` synthesizes Unicode key events on macOS
|
||||
/// (CGEvent), Windows (`SendInput` w/ `KEYEVENTF_UNICODE`), and Linux (X11/
|
||||
/// libei). Backspace is a plain virtual-key `Click`, identical on all three.
|
||||
/// On macOS this reuses the SAME accessibility permission `simulate_paste`
|
||||
/// already requires (both go through `enigo` → CGEvent); no new grant needed.
|
||||
/// The session-bound output layer reactivates the destination captured at
|
||||
/// shortcut-down before emitting input. On Wayland it selects one compatible
|
||||
/// compositor helper before emission and never retries after a possible
|
||||
/// partial write.
|
||||
///
|
||||
/// Returns `Err` if the input layer is unavailable (e.g. accessibility not
|
||||
/// granted) so the JS caller can fall back to the clipboard+paste path for
|
||||
/// that segment without double-inserting. Errors carry the same kind
|
||||
/// prefixes as `simulate_paste` ("a11y:" | "paste:").
|
||||
/// granted). Because a failed input call may already have emitted a prefix,
|
||||
/// the JS caller suppresses later insertion for that session. `preflight:`
|
||||
/// explicitly means nothing was emitted and a final paste remains safe.
|
||||
#[tauri::command]
|
||||
pub fn simulate_type(text: Option<String>, backspaces: Option<u32>) -> Result<(), String> {
|
||||
pub async fn simulate_type(
|
||||
text: String,
|
||||
backspaces: Option<u32>,
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<crate::dictation_output::DeliveryOutcome, String> {
|
||||
// Same a11y gate as simulate_paste — `.text()`/`.key()` go through the
|
||||
// identical CGEvent path on macOS and would silently no-op without it.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -852,24 +829,46 @@ pub fn simulate_type(text: Option<String>, backspaces: Option<u32>) -> Result<()
|
||||
return Err(kind_err("a11y", "accessibility permission not granted"));
|
||||
}
|
||||
|
||||
let mut enigo = Enigo::new(&EnigoSettings::default())
|
||||
.map_err(|e| kind_err("paste", format!("failed to init keyboard sim: {e}")))?;
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
output.type_delta(session_id, &text, backspaces.unwrap_or(0))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| kind_err("paste", format!("output worker failed: {error}")))?
|
||||
}
|
||||
|
||||
let n = backspaces.unwrap_or(0);
|
||||
for _ in 0..n {
|
||||
enigo
|
||||
.key(Key::Backspace, Direction::Click)
|
||||
.map_err(|e| kind_err("paste", format!("backspace failed: {e}")))?;
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn activate_dictation_output_session(
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<(), String> {
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || output.activate_session(session_id))
|
||||
.await
|
||||
.map_err(|error| kind_err("paste", format!("output worker failed: {error}")))?
|
||||
}
|
||||
|
||||
if let Some(t) = text {
|
||||
if !t.is_empty() {
|
||||
enigo
|
||||
.text(&t)
|
||||
.map_err(|e| kind_err("paste", format!("type failed: {e}")))?;
|
||||
}
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn reject_dictation_output_session(
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<(), String> {
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || output.reject_session_candidate(session_id))
|
||||
.await
|
||||
.map_err(|error| kind_err("paste", format!("output worker failed: {error}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn finish_dictation_output_session(
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<(), String> {
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || output.finish_session(session_id))
|
||||
.await
|
||||
.map_err(|error| kind_err("paste", format!("output worker failed: {error}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -889,11 +888,16 @@ pub fn set_tray_recording(
|
||||
// permanently-hidden widget made meaningless.)
|
||||
flags.dictating.store(recording, Ordering::SeqCst);
|
||||
log::info!("Dictation recording state: {recording}");
|
||||
let bytes = if recording { TRAY_ICON_RECORDING } else { TRAY_ICON_DEFAULT };
|
||||
let bytes = if recording {
|
||||
TRAY_ICON_RECORDING
|
||||
} else {
|
||||
TRAY_ICON_DEFAULT
|
||||
};
|
||||
let img = Image::from_bytes(bytes).map_err(|e| format!("decode tray icon: {e}"))?;
|
||||
let lock = tray_handle.tray.lock().map_err(|_| "tray lock poisoned")?;
|
||||
if let Some(ref tray) = *lock {
|
||||
tray.set_icon(Some(img)).map_err(|e| format!("set_icon: {e}"))?;
|
||||
tray.set_icon(Some(img))
|
||||
.map_err(|e| format!("set_icon: {e}"))?;
|
||||
}
|
||||
update_tray_hint(&app, &shortcuts.info().display, recording);
|
||||
Ok(())
|
||||
@@ -987,7 +991,10 @@ fn place_dictation_pill(app: &tauri::AppHandle, win: &tauri::WebviewWindow) {
|
||||
}
|
||||
log::info!(
|
||||
"pill: placed at {x},{y} ({}x{} on a {}x{} monitor)",
|
||||
size.width, size.height, area.width, area.height
|
||||
size.width,
|
||||
size.height,
|
||||
area.width,
|
||||
area.height
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1049,9 +1056,15 @@ pub fn mark_dictation_capture_ready(app: tauri::AppHandle) {
|
||||
return;
|
||||
};
|
||||
capture.ready = true;
|
||||
if let Some(action) = capture.pending.take() {
|
||||
drop(capture);
|
||||
crate::dispatch_dictation_capture(&app, &action);
|
||||
let pending = std::mem::take(&mut capture.pending);
|
||||
drop(capture);
|
||||
for event in pending {
|
||||
if let Err(error) = app.emit(event.name, event.payload) {
|
||||
log::warn!(
|
||||
"Queued dictation event {} could not emit: {error}",
|
||||
event.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1198,7 +1211,8 @@ pub fn reveal_host_path(app: tauri::AppHandle, path: String) -> Result<(), Strin
|
||||
let folder = if target.is_dir() {
|
||||
target.clone()
|
||||
} else {
|
||||
target.parent()
|
||||
target
|
||||
.parent()
|
||||
.ok_or_else(|| "That path has no containing folder".to_string())?
|
||||
.to_path_buf()
|
||||
};
|
||||
@@ -1268,7 +1282,10 @@ const CLEAR_WEBVIEW_RETRY_DELAY: Duration = Duration::from_millis(500);
|
||||
/// Windows — because step 2 runs before an `AppHandle` exists.
|
||||
fn webview_cache_paths() -> Option<(PathBuf, PathBuf)> {
|
||||
let base = dirs_next::data_local_dir()?.join(crate::config::BUNDLE_IDENTIFIER);
|
||||
Some((base.join(CLEAR_WEBVIEW_MARKER), base.join(WEBVIEW_CACHE_DIR)))
|
||||
Some((
|
||||
base.join(CLEAR_WEBVIEW_MARKER),
|
||||
base.join(WEBVIEW_CACHE_DIR),
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1281,8 +1298,11 @@ pub fn clear_webview_cache_and_relaunch(app: tauri::AppHandle) -> Result<(), Str
|
||||
if let Some(parent) = marker.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
fs::write(&marker, b"requested by the splash recovery panel (issue #879)\n")
|
||||
.map_err(|e| format!("write {}: {e}", marker.display()))?;
|
||||
fs::write(
|
||||
&marker,
|
||||
b"requested by the splash recovery panel (issue #879)\n",
|
||||
)
|
||||
.map_err(|e| format!("write {}: {e}", marker.display()))?;
|
||||
log::warn!(
|
||||
"WebView cache repair requested (#879) — relaunching to clear {}",
|
||||
cache.display()
|
||||
@@ -1301,7 +1321,12 @@ pub fn clear_webview_cache_if_marked() {
|
||||
let Some((marker, cache)) = webview_cache_paths() else {
|
||||
return;
|
||||
};
|
||||
clear_webview_cache_at(&marker, &cache, CLEAR_WEBVIEW_ATTEMPTS, CLEAR_WEBVIEW_RETRY_DELAY);
|
||||
clear_webview_cache_at(
|
||||
&marker,
|
||||
&cache,
|
||||
CLEAR_WEBVIEW_ATTEMPTS,
|
||||
CLEAR_WEBVIEW_RETRY_DELAY,
|
||||
);
|
||||
}
|
||||
|
||||
/// Filesystem half of [`clear_webview_cache_if_marked`], parameterized over
|
||||
@@ -1403,7 +1428,10 @@ mod webview_cache_repair_tests {
|
||||
let cache = dir.path().join(super::WEBVIEW_CACHE_DIR);
|
||||
fs::write(&marker, b"test").unwrap();
|
||||
clear_webview_cache_at(&marker, &cache, FEW, NO_WAIT);
|
||||
assert!(!marker.exists(), "marker consumed even with nothing to clear");
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"marker consumed even with nothing to clear"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cache that can't be deleted (Windows: WebView2 file locks; simulated
|
||||
@@ -1419,7 +1447,10 @@ mod webview_cache_repair_tests {
|
||||
// Deny writes on the cache dir so its entries can't be unlinked.
|
||||
fs::set_permissions(&cache, fs::Permissions::from_mode(0o555)).unwrap();
|
||||
clear_webview_cache_at(&marker, &cache, FEW, NO_WAIT);
|
||||
assert!(!marker.exists(), "one-shot: marker consumed even on failure");
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"one-shot: marker consumed even on failure"
|
||||
);
|
||||
assert!(cache.exists(), "a locked cache survives the failed repair");
|
||||
// Restore permissions so TempDir can clean up.
|
||||
fs::set_permissions(&cache, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
@@ -1428,7 +1459,7 @@ mod webview_cache_repair_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod paste_error_tests {
|
||||
use super::{kind_err, CLIPBOARD_RESTORE_DELAY};
|
||||
use super::kind_err;
|
||||
|
||||
#[test]
|
||||
fn kind_err_prefixes_with_kind() {
|
||||
@@ -1450,11 +1481,4 @@ mod paste_error_tests {
|
||||
let e = kind_err("clipboard", "init failed: os error 5");
|
||||
assert_eq!(e.split_once(':').map(|(k, _)| k), Some("clipboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_delay_is_about_300ms() {
|
||||
// Contract with the widget layer: previous clipboard comes back
|
||||
// ~300ms after the paste, long enough for slow paste consumers.
|
||||
assert_eq!(CLIPBOARD_RESTORE_DELAY.as_millis(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+118
-26
@@ -7,33 +7,36 @@
|
||||
//! backend – spawn backend process, port probing, log paths
|
||||
//! commands – Tauri IPC commands (sysinfo, logs, HF cache, paste, tray, dictation)
|
||||
|
||||
pub mod config;
|
||||
pub mod setup;
|
||||
pub mod bootstrap;
|
||||
pub mod tools;
|
||||
pub mod backend;
|
||||
pub mod blank_guard;
|
||||
pub mod bootstrap;
|
||||
pub mod commands;
|
||||
pub mod dictation_shortcut;
|
||||
pub mod config;
|
||||
pub mod crash;
|
||||
pub mod dictation_output;
|
||||
pub mod dictation_shortcut;
|
||||
pub mod reset;
|
||||
pub mod setup;
|
||||
pub mod tools;
|
||||
pub mod uninstall;
|
||||
pub mod updater_channel;
|
||||
pub mod blank_guard;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod wayland_shortcut;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::process::Child;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_positioner::{Position, WindowExt};
|
||||
|
||||
use crate::bootstrap::{BootstrapStage, BootstrapState, set_stage};
|
||||
use crate::bootstrap::{set_stage, BootstrapStage, BootstrapState};
|
||||
use crate::config::load_config;
|
||||
use crate::dictation_output::CaptureOrigin;
|
||||
use crate::dictation_shortcut::DictationShortcutManager;
|
||||
|
||||
// ── Port ──────────────────────────────────────────────────────────────────
|
||||
@@ -63,11 +66,32 @@ pub struct AppFlags {
|
||||
/// the tray icon), so that same call keeps this in step.
|
||||
pub dictating: AtomicBool,
|
||||
pub capture: Mutex<CaptureDispatchState>,
|
||||
pub output: dictation_output::DictationOutput,
|
||||
}
|
||||
|
||||
pub struct CaptureDispatchState {
|
||||
pub ready: bool,
|
||||
pub pending: Option<String>,
|
||||
pub(crate) ready: bool,
|
||||
pub(crate) pending: VecDeque<CaptureEvent>,
|
||||
}
|
||||
|
||||
impl Default for CaptureDispatchState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ready: false,
|
||||
pending: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DictationCapturePayload {
|
||||
pub(crate) session_id: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct CaptureEvent {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) payload: DictationCapturePayload,
|
||||
}
|
||||
|
||||
pub struct TrayHandle {
|
||||
@@ -84,8 +108,24 @@ fn dictation_capture_event(action: &str, dictating: bool) -> &'static str {
|
||||
}
|
||||
|
||||
pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
|
||||
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut);
|
||||
}
|
||||
|
||||
fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin: CaptureOrigin) {
|
||||
let flags = app.state::<AppFlags>();
|
||||
let event = dictation_capture_event(action, flags.dictating.load(Ordering::SeqCst));
|
||||
let session_id = if event == "tray-dictate" {
|
||||
flags.output.begin_session(origin)
|
||||
} else if let Some(session_id) = flags.output.current_session_id() {
|
||||
session_id
|
||||
} else {
|
||||
log::warn!("Dictation capture '{action}' ignored — no active output session");
|
||||
return;
|
||||
};
|
||||
let capture_event = CaptureEvent {
|
||||
name: event,
|
||||
payload: DictationCapturePayload { session_id },
|
||||
};
|
||||
let Ok(mut capture) = flags.capture.lock() else {
|
||||
log::warn!("Dictation capture state lock poisoned");
|
||||
return;
|
||||
@@ -94,7 +134,7 @@ pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
|
||||
// A press that reaches Rust but produces no recording is otherwise
|
||||
// indistinguishable from one the compositor never delivered, so say
|
||||
// which side of the handshake the press left on.
|
||||
if let Err(error) = app.emit(event, ()) {
|
||||
if let Err(error) = app.emit(event, capture_event.payload) {
|
||||
log::warn!("Dictation capture '{action}' could not emit {event}: {error}");
|
||||
} else {
|
||||
log::info!("Dictation capture '{action}' emitted as {event}");
|
||||
@@ -103,21 +143,35 @@ pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
|
||||
log::warn!(
|
||||
"Dictation capture '{action}' queued — the capture window has not registered yet"
|
||||
);
|
||||
capture.pending = Some(action.to_owned());
|
||||
capture.pending.push_back(capture_event);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dictation_capture_tests {
|
||||
use super::dictation_capture_event;
|
||||
use super::{
|
||||
dictation_capture_event, CaptureDispatchState, CaptureEvent, DictationCapturePayload,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn toggle_starts_when_idle_and_stops_when_recording() {
|
||||
assert_eq!(dictation_capture_event("toggle", false), "tray-dictate");
|
||||
assert_eq!(
|
||||
dictation_capture_event("toggle", true),
|
||||
"tray-dictate-stop"
|
||||
);
|
||||
assert_eq!(dictation_capture_event("toggle", true), "tray-dictate-stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_queue_preserves_press_then_release() {
|
||||
let mut state = CaptureDispatchState::default();
|
||||
state.pending.push_back(CaptureEvent {
|
||||
name: "tray-dictate",
|
||||
payload: DictationCapturePayload { session_id: 7 },
|
||||
});
|
||||
state.pending.push_back(CaptureEvent {
|
||||
name: "tray-dictate-stop",
|
||||
payload: DictationCapturePayload { session_id: 7 },
|
||||
});
|
||||
let names: Vec<_> = state.pending.into_iter().map(|event| event.name).collect();
|
||||
assert_eq!(names, ["tray-dictate", "tray-dictate-stop"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,12 +428,19 @@ mod pill_noactivate_tests {
|
||||
WS_EX_NOACTIVATE_BIT,
|
||||
"NOACTIVATE bit must be set"
|
||||
);
|
||||
assert_eq!(updated & topmost, topmost, "pre-existing style bits must survive");
|
||||
assert_eq!(
|
||||
updated & topmost,
|
||||
topmost,
|
||||
"pre-existing style bits must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_if_already_noactivate() {
|
||||
assert_eq!(with_noactivate_style(WS_EX_NOACTIVATE_BIT), WS_EX_NOACTIVATE_BIT);
|
||||
assert_eq!(
|
||||
with_noactivate_style(WS_EX_NOACTIVATE_BIT),
|
||||
WS_EX_NOACTIVATE_BIT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -408,7 +469,11 @@ pub fn run() {
|
||||
if pill_mode {
|
||||
log::info!(
|
||||
"Starting in pill (dictation-only) mode (source: {})",
|
||||
if cli_pill { "--pill flag" } else { "config.launch_as_widget" }
|
||||
if cli_pill {
|
||||
"--pill flag"
|
||||
} else {
|
||||
"config.launch_as_widget"
|
||||
}
|
||||
);
|
||||
// On macOS, hide the Dock icon in pill mode so only the tray shows.
|
||||
// This is handled after the app builds via set_activation_policy.
|
||||
@@ -476,7 +541,11 @@ pub fn run() {
|
||||
commands::read_log_tail,
|
||||
commands::hf_cache_scan,
|
||||
commands::simulate_paste,
|
||||
commands::copy_dictation_output_session,
|
||||
commands::simulate_type,
|
||||
commands::activate_dictation_output_session,
|
||||
commands::reject_dictation_output_session,
|
||||
commands::finish_dictation_output_session,
|
||||
commands::check_accessibility,
|
||||
commands::open_accessibility_settings,
|
||||
commands::check_microphone,
|
||||
@@ -562,6 +631,7 @@ pub fn run() {
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.visible(false)
|
||||
.focused(false)
|
||||
.skip_taskbar(true)
|
||||
.center()
|
||||
// Stamp the window's identity BEFORE any app script runs.
|
||||
@@ -586,15 +656,23 @@ pub fn run() {
|
||||
if let Ok(win) = &result {
|
||||
mark_pill_noactivate(win);
|
||||
}
|
||||
// Wayland cannot reactivate an arbitrary foreign client. The
|
||||
// GTK toplevel therefore must never accept focus when mapped.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(win) = &result {
|
||||
use gtk::prelude::GtkWindowExt;
|
||||
if let Ok(gtk_window) = win.gtk_window() {
|
||||
gtk_window.set_accept_focus(false);
|
||||
gtk_window.set_focus_on_map(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.manage(AppFlags {
|
||||
quitting: AtomicBool::new(false),
|
||||
dictating: AtomicBool::new(false),
|
||||
capture: Mutex::new(CaptureDispatchState {
|
||||
ready: false,
|
||||
pending: None,
|
||||
}),
|
||||
capture: Mutex::new(CaptureDispatchState::default()),
|
||||
output: dictation_output::DictationOutput::default(),
|
||||
});
|
||||
app.manage(TrayHandle {
|
||||
tray: Mutex::new(None),
|
||||
@@ -700,6 +778,20 @@ pub fn run() {
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&tray_menu)
|
||||
.tooltip(if pill_mode_tray { "VoiceStudio Dictation" } else { "VoiceStudio" })
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if matches!(
|
||||
event,
|
||||
tauri::tray::TrayIconEvent::Click {
|
||||
button_state: tauri::tray::MouseButtonState::Down,
|
||||
..
|
||||
}
|
||||
) {
|
||||
tray.app_handle()
|
||||
.state::<AppFlags>()
|
||||
.output
|
||||
.prime_tray_target();
|
||||
}
|
||||
})
|
||||
.on_menu_event(move |app, event| {
|
||||
match event.id().as_ref() {
|
||||
"show" => {
|
||||
@@ -760,9 +852,9 @@ pub fn run() {
|
||||
// current by the frontend's existing
|
||||
// `set_tray_recording` call on every start and stop.
|
||||
if app.state::<AppFlags>().dictating.load(Ordering::SeqCst) {
|
||||
dispatch_dictation_capture(app, "stop");
|
||||
dispatch_dictation_capture_from(app, "stop", CaptureOrigin::Tray);
|
||||
} else {
|
||||
dispatch_dictation_capture(app, "start");
|
||||
dispatch_dictation_capture_from(app, "start", CaptureOrigin::Tray);
|
||||
}
|
||||
}
|
||||
"settings" => {
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
"../../README.md",
|
||||
"../../CHANGELOG.md",
|
||||
"../../omnivoice",
|
||||
"../../backend"
|
||||
"../../backend",
|
||||
"../../frontend/dist"
|
||||
],
|
||||
"externalBin": [
|
||||
"binaries/uv",
|
||||
|
||||
@@ -217,7 +217,8 @@ impl TestApp {
|
||||
app.manage(AppFlags {
|
||||
quitting: AtomicBool::new(false),
|
||||
dictating: AtomicBool::new(false),
|
||||
capture: Mutex::new(CaptureDispatchState { ready: false, pending: None }),
|
||||
capture: Mutex::new(CaptureDispatchState::default()),
|
||||
output: app_lib::dictation_output::DictationOutput::default(),
|
||||
});
|
||||
let stage = Arc::new(Mutex::new(BootstrapStage::Checking));
|
||||
let logs: Arc<Mutex<Vec<LogPayload>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useAppStore, FONT_STACKS } from './store';
|
||||
import { NAV_ITEMS } from './components/navItems';
|
||||
import SearchableSelect from './components/SearchableSelect';
|
||||
import DirectionDialog from './components/DirectionDialog';
|
||||
import ModeLifecycleBoundary from './components/ModeLifecycleBoundary';
|
||||
import { resolveDubDefaultTrack } from './utils/dubDefaultTrack';
|
||||
|
||||
// Lazy-load heavy/conditional components so they don't bloat the initial bundle.
|
||||
const AudioTrimmer = lazy(() => import('./components/AudioTrimmer'));
|
||||
@@ -530,6 +532,7 @@ function App() {
|
||||
pasteTranslations,
|
||||
segmentSplit,
|
||||
segmentMerge,
|
||||
segmentInsert,
|
||||
segmentMoveResize,
|
||||
timelineSelSegId,
|
||||
setTimelineSelSegId,
|
||||
@@ -949,8 +952,9 @@ function App() {
|
||||
});
|
||||
const tracksParam = selected.join(',');
|
||||
const burnParam = burnSubs ? `&burn_subs=1&dual=${dualSubs ? 1 : 0}` : '';
|
||||
const resolvedDefaultTrack = resolveDubDefaultTrack(defaultTrack, dubLangCode, dubTracks);
|
||||
triggerDownload(
|
||||
`${API}/dub/download/${dubJobId}/dubbed_video.mp4?preserve_bg=${preserveBg}&default_track=${defaultTrack}&include_tracks=${encodeURIComponent(tracksParam)}${burnParam}`,
|
||||
`${API}/dub/download/${dubJobId}/dubbed_video.mp4?preserve_bg=${preserveBg}&default_track=${resolvedDefaultTrack}&include_tracks=${encodeURIComponent(tracksParam)}${burnParam}`,
|
||||
'dubbed_video.mp4',
|
||||
);
|
||||
};
|
||||
@@ -1063,7 +1067,7 @@ function App() {
|
||||
setDubTracks(s.dubTracks || []);
|
||||
setDubTranscript(s.dubTranscript || '');
|
||||
setPreserveBg(s.preserveBg !== undefined ? s.preserveBg : true);
|
||||
setDefaultTrack(s.defaultTrack !== undefined ? s.defaultTrack : 'original');
|
||||
setDefaultTrack(s.defaultTrack !== undefined ? s.defaultTrack : '');
|
||||
setDubStep(s.dubStep === 'done' ? 'done' : s.dubSegments?.length ? 'editing' : 'idle');
|
||||
// Phase 4.5 — rehydrate per-segment fingerprints. The incremental plan
|
||||
// immediately shows "N segments changed" for any segments edited after
|
||||
@@ -1449,7 +1453,7 @@ function App() {
|
||||
<NavRail mode={mode} setMode={setMode} side={navRailSide} onFlipSide={flipNavRailSide} />
|
||||
)}
|
||||
|
||||
<div className="main-content">
|
||||
<ModeLifecycleBoundary mode={mode}>
|
||||
{/* ═══ LAUNCHPAD TAB ═══ */}
|
||||
{mode === 'settings' ? (
|
||||
<ErrorBoundary name="settings">
|
||||
@@ -1643,6 +1647,7 @@ function App() {
|
||||
pasteTranslations={pasteTranslations}
|
||||
segmentSplit={segmentSplit}
|
||||
segmentMerge={segmentMerge}
|
||||
segmentInsert={segmentInsert}
|
||||
segmentMoveResize={segmentMoveResize}
|
||||
timelineSelSegId={timelineSelSegId}
|
||||
setTimelineSelSegId={setTimelineSelSegId}
|
||||
@@ -1777,7 +1782,7 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModeLifecycleBoundary>
|
||||
|
||||
{/* ── SIDEBAR ── */}
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import {
|
||||
Scissors,
|
||||
Merge,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { formatTime } from '../utils/format';
|
||||
@@ -53,7 +54,8 @@ function bestSplitPoint(text) {
|
||||
function parseTime(s) {
|
||||
const m = /^\s*(\d+):([0-5]?\d(?:\.\d+)?)\s*$/.exec(s);
|
||||
if (m) return parseInt(m[1], 10) * 60 + parseFloat(m[2]);
|
||||
const n = parseFloat(s);
|
||||
if (String(s).includes(':') || !/^\s*\d+(?:\.\d+)?\s*$/.test(s)) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
@@ -76,7 +78,10 @@ function DubSegmentRow({
|
||||
onSelect,
|
||||
onSplit,
|
||||
onMerge,
|
||||
onInsert,
|
||||
onMoveResize,
|
||||
canMerge,
|
||||
canMergePrev,
|
||||
onDirect,
|
||||
onSeek,
|
||||
timelineSelected,
|
||||
@@ -167,6 +172,38 @@ function DubSegmentRow({
|
||||
const overBudget =
|
||||
seg.text_original && seg.text.length > Math.ceil(seg.text_original.length * CHAR_BUDGET_RATIO);
|
||||
|
||||
// Both time fields commit through the SAME path the timeline drag handles
|
||||
// use (segmentMoveResize → commitMoveResize), so typing a time and dragging
|
||||
// its edge produce identical results — including the speed recompute that
|
||||
// keeps the dubbed audio inside a resized slot. The numeric start field used
|
||||
// to write `start` raw and skip that compensation, so the two UIs disagreed.
|
||||
const timeKeyDown = (edge) => (e) => {
|
||||
if (e.key === 'Enter') e.target.blur();
|
||||
if (e.key === 'Escape') {
|
||||
e.target.value = formatTime(seg[edge]);
|
||||
e.target.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const commitTime = (edge) => (e) => {
|
||||
const v = parseTime(e.target.value);
|
||||
const current = seg[edge];
|
||||
const inRange = edge === 'start' ? v >= 0 && v < seg.end : v > seg.start;
|
||||
if (v == null || !inRange) {
|
||||
e.target.value = formatTime(current);
|
||||
return;
|
||||
}
|
||||
if (Math.abs(v - current) <= 1e-3) {
|
||||
e.target.value = formatTime(current);
|
||||
return;
|
||||
}
|
||||
const next = +v.toFixed(3);
|
||||
onMoveResize(seg.id, {
|
||||
start: edge === 'start' ? next : seg.start,
|
||||
end: edge === 'end' ? next : seg.end,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTextKeyDown = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) {
|
||||
e.preventDefault();
|
||||
@@ -174,7 +211,9 @@ function DubSegmentRow({
|
||||
onSplit(seg.id, pos);
|
||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'm' || e.key === 'M')) {
|
||||
e.preventDefault();
|
||||
if (canMerge) onMerge(seg.id);
|
||||
if (e.shiftKey) {
|
||||
if (canMergePrev) onMerge(seg.id, 'prev');
|
||||
} else if (canMerge) onMerge(seg.id, 'next');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,30 +256,21 @@ function DubSegmentRow({
|
||||
disabled={disabled}
|
||||
title={t('segment.time_edit_title')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.target.blur();
|
||||
if (e.key === 'Escape') {
|
||||
e.target.value = formatTime(seg.start);
|
||||
e.target.blur();
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const v = parseTime(e.target.value);
|
||||
if (v == null || v < 0 || v >= seg.end) {
|
||||
e.target.value = formatTime(seg.start);
|
||||
return;
|
||||
}
|
||||
if (Math.abs(v - seg.start) > 1e-3) {
|
||||
onEditField(seg.id, 'start', +v.toFixed(3));
|
||||
} else {
|
||||
e.target.value = formatTime(seg.start);
|
||||
}
|
||||
}}
|
||||
onKeyDown={timeKeyDown('start')}
|
||||
onBlur={commitTime('start')}
|
||||
/>
|
||||
<span className="text-[var(--chrome-fg-muted)]">–</span>
|
||||
<span className="text-[var(--chrome-fg-muted)] text-[0.62rem]">
|
||||
{formatTime(seg.end)}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="seg-time-input"
|
||||
defaultValue={formatTime(seg.end)}
|
||||
key={`end-${seg.id}-${seg.end}`}
|
||||
disabled={disabled}
|
||||
title={t('segment.time_edit_end_title')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={timeKeyDown('end')}
|
||||
onBlur={commitTime('end')}
|
||||
/>
|
||||
{seg.speed && seg.speed !== 1.0 && (
|
||||
<span
|
||||
className="text-[0.52rem] ml-[1px]"
|
||||
@@ -499,13 +529,28 @@ function DubSegmentRow({
|
||||
onSplit(seg.id, pos);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'merge-prev',
|
||||
label: t('segment.merge_prev_label'),
|
||||
icon: Merge,
|
||||
shortcut: '⇧⌘M',
|
||||
disabled: !canMergePrev,
|
||||
onSelect: () => onMerge(seg.id, 'prev'),
|
||||
},
|
||||
{
|
||||
id: 'merge',
|
||||
label: t('segment.merge_label'),
|
||||
icon: Merge,
|
||||
shortcut: '⌘M',
|
||||
disabled: !canMerge,
|
||||
onSelect: () => onMerge(seg.id),
|
||||
onSelect: () => onMerge(seg.id, 'next'),
|
||||
},
|
||||
'separator',
|
||||
{
|
||||
id: 'insert',
|
||||
label: t('segment.insert_label'),
|
||||
icon: Plus,
|
||||
onSelect: () => onInsert(seg.id),
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -543,6 +588,7 @@ export default memo(
|
||||
prev.onSeek === next.onSeek &&
|
||||
prev.selected === next.selected &&
|
||||
prev.canMerge === next.canMerge &&
|
||||
prev.canMergePrev === next.canMergePrev &&
|
||||
prev.profiles === next.profiles &&
|
||||
prev.speakerClones === next.speakerClones &&
|
||||
prev.idx === next.idx,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { List } from 'react-window';
|
||||
import DubSegmentRow from './DubSegmentRow';
|
||||
import { Table, Select } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
import { visibleMergeAvailability } from '../utils/segmentParts';
|
||||
|
||||
const BASE_ROW_HEIGHT = 26;
|
||||
const ROW_HEIGHT_WITH_ORIG = 40;
|
||||
@@ -35,6 +36,8 @@ export default function DubSegmentTable({
|
||||
onPreview,
|
||||
onSplit,
|
||||
onMerge,
|
||||
onInsert,
|
||||
onMoveResize,
|
||||
onDirect,
|
||||
onSeek,
|
||||
timelineSelectedId = null,
|
||||
@@ -152,6 +155,8 @@ export default function DubSegmentTable({
|
||||
onPreview,
|
||||
onSplit,
|
||||
onMerge,
|
||||
onInsert,
|
||||
onMoveResize,
|
||||
onDirect,
|
||||
onSeek,
|
||||
segments,
|
||||
@@ -174,6 +179,8 @@ export default function DubSegmentTable({
|
||||
onPreview,
|
||||
onSplit,
|
||||
onMerge,
|
||||
onInsert,
|
||||
onMoveResize,
|
||||
onDirect,
|
||||
onSeek,
|
||||
segments,
|
||||
@@ -201,6 +208,8 @@ export default function DubSegmentTable({
|
||||
onPreview: prev,
|
||||
onSplit: split,
|
||||
onMerge: merge,
|
||||
onInsert: insert,
|
||||
onMoveResize: moveResize,
|
||||
onDirect: direct,
|
||||
onSeek: seek,
|
||||
segments: segs,
|
||||
@@ -216,7 +225,9 @@ export default function DubSegmentTable({
|
||||
(step === 'generating' || step === 'stopping') && prog.current > absoluteIndex + 1;
|
||||
const isPlaying = curId === seg.id;
|
||||
const timelineSelected = tlSel != null && String(tlSel) === String(seg.id);
|
||||
const canMerge = index < fl.length - 1;
|
||||
// Merge operates on source neighbors. Hide the action when a filter
|
||||
// hides that neighbor so the user cannot mutate an unseen subtitle.
|
||||
const { canMerge, canMergePrev } = visibleMergeAvailability(segs, fl, seg);
|
||||
return (
|
||||
<DubSegmentRow
|
||||
seg={seg}
|
||||
@@ -239,6 +250,9 @@ export default function DubSegmentTable({
|
||||
onSelect={pick}
|
||||
onSplit={split}
|
||||
onMerge={merge}
|
||||
onInsert={insert}
|
||||
onMoveResize={moveResize}
|
||||
canMergePrev={canMergePrev}
|
||||
onDirect={direct}
|
||||
onSeek={seek}
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
items: [
|
||||
['Cmd/Ctrl+D', t('keyboard.seg_split')],
|
||||
['Cmd/Ctrl+M', t('keyboard.seg_merge')],
|
||||
['Cmd/Ctrl+Shift+M', t('keyboard.seg_merge_prev')],
|
||||
['Cmd/Ctrl+Z', t('keyboard.seg_undo')],
|
||||
['Cmd/Ctrl+Shift+Z', t('keyboard.seg_redo')],
|
||||
['Click row', t('keyboard.seg_click')],
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Owns the DOM subtree for one top-level workspace.
|
||||
*
|
||||
* Some workspaces host imperative renderers (WaveSurfer, media elements, and
|
||||
* portals). Replacing the host when navigation changes prevents a late
|
||||
* renderer cleanup from mutating the next workspace's React-owned DOM.
|
||||
*/
|
||||
export default function ModeLifecycleBoundary({ mode, children }) {
|
||||
return (
|
||||
<Fragment>
|
||||
<div key={mode} className="main-content" data-mode={mode}>
|
||||
{children}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
import { Fragment } from 'react';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ModeLifecycleBoundary from './ModeLifecycleBoundary';
|
||||
|
||||
function ImperativeWorkspace({ name }) {
|
||||
const hostRef = useRef(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const owned = document.createElement('div');
|
||||
owned.dataset.owner = name;
|
||||
host.appendChild(owned);
|
||||
return () => {
|
||||
// Model a renderer whose asynchronous destroy completes after React has
|
||||
// committed the next route. If the DOM host were reused, this would
|
||||
// erase that route's children and recreate the reported ownership race.
|
||||
setTimeout(() => host.replaceChildren(), 0);
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
return <section ref={hostRef}>{name}</section>;
|
||||
}
|
||||
|
||||
describe('ModeLifecycleBoundary', () => {
|
||||
it('replaces the DOM owner throughout the reported rapid navigation loop', () => {
|
||||
vi.useFakeTimers();
|
||||
const sequence = ['launchpad', 'dub', 'dub', 'launchpad', 'dub'];
|
||||
const view = render(
|
||||
<ModeLifecycleBoundary mode={sequence[0]}>
|
||||
<ImperativeWorkspace name={sequence[0]} />
|
||||
</ModeLifecycleBoundary>,
|
||||
);
|
||||
let previousHost = view.container.firstElementChild;
|
||||
let previousMode = sequence[0];
|
||||
|
||||
for (const mode of sequence.slice(1)) {
|
||||
view.rerender(
|
||||
<ModeLifecycleBoundary mode={mode}>
|
||||
<ImperativeWorkspace name={mode} />
|
||||
</ModeLifecycleBoundary>,
|
||||
);
|
||||
const nextHost = view.container.firstElementChild;
|
||||
if (mode !== previousMode) {
|
||||
expect(nextHost).not.toBe(previousHost);
|
||||
expect(previousHost.isConnected).toBe(false);
|
||||
} else {
|
||||
expect(nextHost).toBe(previousHost);
|
||||
}
|
||||
expect(nextHost.querySelector('[data-owner]')?.dataset.owner).toBe(mode);
|
||||
vi.runAllTimers();
|
||||
expect(nextHost.textContent).toContain(mode);
|
||||
expect(nextHost.querySelector('[data-owner]')?.dataset.owner).toBe(mode);
|
||||
previousHost = nextHost;
|
||||
previousMode = mode;
|
||||
}
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,10 @@ const ONSET_STRIP_H = 8; // px — non-interactive onset tick strip
|
||||
const KB_STEP_S = 0.01; // ←/→ nudge
|
||||
const KB_STEP_BIG_S = 0.1; // Ctrl+←/→ nudge
|
||||
const DRAG_DEADZONE_PX = 3;
|
||||
// Small/medium dubbing jobs are cheap enough to render in full. Avoid tying
|
||||
// visibility to transient WaveSurfer resize/zoom metrics until the transcript
|
||||
// is genuinely large; this also keeps short multi-speaker clips complete.
|
||||
const VIRTUALIZE_THRESHOLD = 200;
|
||||
|
||||
const fmt = (t) => {
|
||||
const m = Math.floor(t / 60);
|
||||
@@ -478,7 +482,8 @@ export default function SegmentTrack({
|
||||
|
||||
const innerWidth = Math.max(viewWidth, Math.ceil(duration * pxPerSec));
|
||||
const playheadX = currentTime * pxPerSec - effScroll;
|
||||
const windowed = effSegments.slice(lo, hi);
|
||||
const windowed =
|
||||
effSegments.length <= VIRTUALIZE_THRESHOLD ? effSegments : effSegments.slice(lo, hi);
|
||||
// Scroll offset baked into each box's `left` (viewport coordinates) instead
|
||||
// of a `translateX` on the lane — an animated lane transform is composited
|
||||
// by Chromium and flashes on some Windows GPU/WebView2 drivers (#373). In
|
||||
@@ -489,7 +494,7 @@ export default function SegmentTrack({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`seg-track relative w-full select-none mt-[2px] ${disabled ? 'is-disabled' : ''}`}
|
||||
className={`seg-track relative w-full select-none mt-[2px] shrink-0 flex-none min-h-[50px] ${disabled ? 'is-disabled' : ''}`}
|
||||
ref={hostRef}
|
||||
>
|
||||
<canvas
|
||||
|
||||
@@ -77,6 +77,18 @@ describe('SegmentTrack — rendering', () => {
|
||||
const { container } = render(<SegmentTrack segments={SEGS} pxPerSec={0} duration={10} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders every segment for ordinary jobs instead of clipping to resize metrics', () => {
|
||||
const segments = Array.from({ length: 200 }, (_, id) => ({
|
||||
id,
|
||||
start: id,
|
||||
end: id + 0.5,
|
||||
text: `line ${id}`,
|
||||
}));
|
||||
setup({ segments, duration: 200, pxPerSec: 100, scrollLeft: 10_000 });
|
||||
|
||||
expect(screen.getAllByRole('option')).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SegmentTrack — keyboard', () => {
|
||||
|
||||
@@ -226,6 +226,7 @@ export default function DubLeftColumn({
|
||||
...seg,
|
||||
text: incoming,
|
||||
translations: { ...seg.translations, [code]: incoming },
|
||||
merge_parts: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -235,7 +236,7 @@ export default function DubLeftColumn({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="studio-panel dub-panel-col">
|
||||
<div className="studio-panel dub-panel-col dub-panel-left">
|
||||
{hasDubbedTrack && (
|
||||
<div
|
||||
className="dub-lang-switch"
|
||||
@@ -356,7 +357,7 @@ export default function DubLeftColumn({
|
||||
isolated vocals), that option becomes first-class in the
|
||||
dropdown. It's also pre-selected on the segments so "new
|
||||
language = same speaker's voice" works by default. */}
|
||||
{dubSegments.some((s) => s.speaker_id) && (
|
||||
{dubSegments.some((s) => s.speaker_id || s.merge_parts?.some((part) => part.speaker_id)) && (
|
||||
<div className="mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-transparent">
|
||||
<div className="flex gap-[var(--space-2)] items-center flex-wrap">
|
||||
<span
|
||||
@@ -365,7 +366,16 @@ export default function DubLeftColumn({
|
||||
>
|
||||
{t('dub.cast')}
|
||||
</span>
|
||||
{[...new Set(dubSegments.map((s) => s.speaker_id).filter(Boolean))].map((spk) => {
|
||||
{[
|
||||
...new Set(
|
||||
dubSegments
|
||||
.flatMap((s) => [
|
||||
s.speaker_id,
|
||||
...(s.merge_parts || []).map((part) => part.speaker_id),
|
||||
])
|
||||
.filter(Boolean),
|
||||
),
|
||||
].map((spk) => {
|
||||
const autoId = autoProfileId(spk);
|
||||
const clone = speakerClones[spk];
|
||||
return (
|
||||
@@ -375,13 +385,41 @@ export default function DubLeftColumn({
|
||||
</span>
|
||||
<select
|
||||
className="input-base dub-cast__select"
|
||||
value={dubSegments.find((s) => s.speaker_id === spk)?.profile_id || ''}
|
||||
value={
|
||||
dubSegments.find((s) => s.speaker_id === spk)?.profile_id ||
|
||||
dubSegments
|
||||
.flatMap((s) => s.merge_parts || [])
|
||||
.find((part) => part.speaker_id === spk)?.profile_id ||
|
||||
''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setDubSegments(
|
||||
dubSegments.map((s) =>
|
||||
s.speaker_id === spk ? { ...s, profile_id: val } : s,
|
||||
),
|
||||
dubSegments.map((s) => {
|
||||
const directMatch = s.speaker_id === spk;
|
||||
const nestedMatch = s.merge_parts?.some(
|
||||
(part) => part.speaker_id === spk,
|
||||
);
|
||||
if (!directMatch && !nestedMatch) return s;
|
||||
return {
|
||||
...s,
|
||||
...(directMatch ? { profile_id: val } : {}),
|
||||
...(s.merge_parts
|
||||
? {
|
||||
merge_parts: s.merge_parts.map((part) =>
|
||||
part.speaker_id === spk ? { ...part, profile_id: val } : part,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(s.merge_parts_original
|
||||
? {
|
||||
merge_parts_original: s.merge_parts_original.map((part) =>
|
||||
part.speaker_id === spk ? { ...part, profile_id: val } : part,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Children, useRef, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'omnivoice.dubSplit.v1';
|
||||
const MIN_LEFT = 25;
|
||||
const MAX_LEFT = 70;
|
||||
const KEYBOARD_STEP = 5;
|
||||
|
||||
const clamp = (value) => Math.min(MAX_LEFT, Math.max(MIN_LEFT, Math.round(value)));
|
||||
|
||||
const loadRatio = () => {
|
||||
if (typeof window === 'undefined') return 50;
|
||||
try {
|
||||
const stored = Number.parseFloat(localStorage.getItem(STORAGE_KEY) ?? '');
|
||||
return Number.isFinite(stored) ? clamp(stored) : 50;
|
||||
} catch {
|
||||
return 50;
|
||||
}
|
||||
};
|
||||
|
||||
export default function DubResizableColumns({ children, resizeLabel }) {
|
||||
const [leftRatio, setLeftRatio] = useState(loadRatio);
|
||||
const ratioRef = useRef(leftRatio);
|
||||
const draggingRef = useRef(false);
|
||||
const columns = Children.toArray(children);
|
||||
|
||||
const isRtl = (element) =>
|
||||
element.closest('[dir]')?.getAttribute('dir') === 'rtl' ||
|
||||
document.documentElement.dir === 'rtl';
|
||||
|
||||
const updateRatio = (nextRatio, persist = false) => {
|
||||
const next = clamp(nextRatio);
|
||||
ratioRef.current = next;
|
||||
setLeftRatio(next);
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(next));
|
||||
} catch {
|
||||
// Storage can be disabled; resizing still works for this session.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ratioFromPointer = (event) => {
|
||||
const bounds = event.currentTarget.parentElement?.getBoundingClientRect();
|
||||
if (!bounds?.width) return ratioRef.current;
|
||||
const offset = isRtl(event.currentTarget)
|
||||
? (bounds.right - event.clientX) / bounds.width
|
||||
: (event.clientX - bounds.left) / bounds.width;
|
||||
return offset * 100;
|
||||
};
|
||||
|
||||
const handlePointerDown = (event) => {
|
||||
draggingRef.current = true;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
updateRatio(ratioFromPointer(event));
|
||||
};
|
||||
|
||||
const handlePointerMove = (event) => {
|
||||
if (draggingRef.current) updateRatio(ratioFromPointer(event));
|
||||
};
|
||||
|
||||
const finishPointerResize = (event) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
updateRatio(ratioRef.current, true);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
let next;
|
||||
const direction = isRtl(event.currentTarget) ? -1 : 1;
|
||||
if (event.key === 'ArrowLeft') next = ratioRef.current - KEYBOARD_STEP * direction;
|
||||
if (event.key === 'ArrowRight') next = ratioRef.current + KEYBOARD_STEP * direction;
|
||||
if (event.key === 'Home') next = MIN_LEFT;
|
||||
if (event.key === 'End') next = MAX_LEFT;
|
||||
if (next === undefined) return;
|
||||
event.preventDefault();
|
||||
updateRatio(next, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dub-editor-grid dub-resizable-columns flex-1 min-h-0 min-w-0 overflow-hidden"
|
||||
style={{ gridTemplateColumns: `${leftRatio}fr 12px ${100 - leftRatio}fr` }}
|
||||
>
|
||||
{columns[0]}
|
||||
<div
|
||||
className="dub-column-splitter"
|
||||
role="separator"
|
||||
aria-label={resizeLabel}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={MIN_LEFT}
|
||||
aria-valuemax={MAX_LEFT}
|
||||
aria-valuenow={leftRatio}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishPointerResize}
|
||||
onPointerCancel={finishPointerResize}
|
||||
/>
|
||||
{columns[1]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import DubResizableColumns from './DubResizableColumns';
|
||||
|
||||
describe('DubResizableColumns', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.dir = '';
|
||||
});
|
||||
|
||||
it('lets keyboard users enlarge the transcript column and persists the split', () => {
|
||||
const { unmount } = render(
|
||||
<DubResizableColumns resizeLabel="Resize video and transcript columns">
|
||||
<div>Video</div>
|
||||
<div>Transcript</div>
|
||||
</DubResizableColumns>,
|
||||
);
|
||||
const separator = screen.getByRole('separator');
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'ArrowLeft' });
|
||||
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '45');
|
||||
expect(separator.parentElement).toHaveStyle({ gridTemplateColumns: '45fr 12px 55fr' });
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<DubResizableColumns resizeLabel="Resize video and transcript columns">
|
||||
<div>Video</div>
|
||||
<div>Transcript</div>
|
||||
</DubResizableColumns>,
|
||||
);
|
||||
expect(screen.getByRole('separator')).toHaveAttribute('aria-valuenow', '45');
|
||||
});
|
||||
|
||||
it('clamps pointer resizing so both editors remain usable', () => {
|
||||
render(
|
||||
<DubResizableColumns resizeLabel="Resize video and transcript columns">
|
||||
<div>Video</div>
|
||||
<div>Transcript</div>
|
||||
</DubResizableColumns>,
|
||||
);
|
||||
const separator = screen.getByRole('separator');
|
||||
separator.parentElement.getBoundingClientRect = () => ({ left: 100, width: 1000 });
|
||||
|
||||
fireEvent.pointerDown(separator, { pointerId: 1, clientX: 500 });
|
||||
fireEvent.pointerMove(separator, { pointerId: 1, clientX: 50 });
|
||||
fireEvent.pointerUp(separator, { pointerId: 1, clientX: 50 });
|
||||
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '25');
|
||||
});
|
||||
|
||||
it('follows physical pointer and arrow direction in RTL layouts', () => {
|
||||
document.documentElement.dir = 'rtl';
|
||||
render(
|
||||
<DubResizableColumns resizeLabel="Resize video and transcript columns">
|
||||
<div>Video</div>
|
||||
<div>Transcript</div>
|
||||
</DubResizableColumns>,
|
||||
);
|
||||
const separator = screen.getByRole('separator');
|
||||
separator.parentElement.getBoundingClientRect = () => ({ left: 100, right: 1100, width: 1000 });
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'ArrowLeft' });
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '55');
|
||||
|
||||
fireEvent.pointerDown(separator, { pointerId: 1, clientX: 800 });
|
||||
fireEvent.pointerMove(separator, { pointerId: 1, clientX: 500 });
|
||||
fireEvent.pointerUp(separator, { pointerId: 1, clientX: 500 });
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '60');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import GlossaryPanel from '../GlossaryPanel';
|
||||
import CheckpointBanner from '../CheckpointBanner';
|
||||
import { LANG_CODES } from '../../utils/languages';
|
||||
import { autoProfileId } from '../../utils/segments';
|
||||
import { resolveDubDefaultTrack } from '../../utils/dubDefaultTrack';
|
||||
|
||||
const DubSegmentTable = lazy(() => import('../DubSegmentTable'));
|
||||
const DubPasteTranslationDialog = lazy(() => import('./DubPasteTranslationDialog'));
|
||||
@@ -74,6 +75,8 @@ export default function DubRightColumn({
|
||||
onDirectSegment,
|
||||
segmentSplit,
|
||||
segmentMerge,
|
||||
segmentInsert,
|
||||
segmentMoveResize,
|
||||
seekWaveform,
|
||||
timelineSelSegId,
|
||||
dubStep,
|
||||
@@ -82,7 +85,7 @@ export default function DubRightColumn({
|
||||
}) {
|
||||
const [pasteOpen, setPasteOpen] = useState(false);
|
||||
return (
|
||||
<div className="studio-panel dub-panel-col">
|
||||
<div className="studio-panel dub-panel-col dub-panel-right">
|
||||
{/* Output options + timing — moved to the top of the right section. */}
|
||||
<div>
|
||||
<div className={OUT_ROW}>
|
||||
@@ -118,7 +121,7 @@ export default function DubRightColumn({
|
||||
{t('dub.default_track')}
|
||||
<select
|
||||
className="input-base !text-[0.6rem] !px-[4px] !py-[2px] !w-[120px]"
|
||||
value={defaultTrack}
|
||||
value={resolveDubDefaultTrack(defaultTrack, dubLangCode, dubTracks)}
|
||||
onChange={(e) => setDefaultTrack(e.target.value)}
|
||||
>
|
||||
<option value="original">{t('dub.original_track')}</option>
|
||||
@@ -163,9 +166,8 @@ export default function DubRightColumn({
|
||||
},
|
||||
{
|
||||
value: 'strict_slot',
|
||||
label: 'Strict slot',
|
||||
title:
|
||||
'Legacy: compress audio to fit the original timing. Can sound rushed/chipmunky on high-density target languages.',
|
||||
label: t('dub.timing_lip_sync'),
|
||||
title: t('dub.timing_lip_sync'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -405,6 +407,8 @@ export default function DubRightColumn({
|
||||
onDirect={onDirectSegment}
|
||||
onSplit={segmentSplit}
|
||||
onMerge={segmentMerge}
|
||||
onInsert={segmentInsert}
|
||||
onMoveResize={segmentMoveResize}
|
||||
onSeek={seekWaveform}
|
||||
timelineSelectedId={timelineSelSegId}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import DubRightColumn from './DubRightColumn';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function column(multiBatchBusy, setDubLang = noop, setDubLangCode = noop) {
|
||||
function column(multiBatchBusy, setDubLang = noop, setDubLangCode = noop, overrides = {}) {
|
||||
return (
|
||||
<DubRightColumn
|
||||
t={(key) => key}
|
||||
@@ -46,11 +46,21 @@ function column(multiBatchBusy, setDubLang = noop, setDubLangCode = noop) {
|
||||
isTranslating={false}
|
||||
dubSegments={[]}
|
||||
dubStep="editing"
|
||||
{...overrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('DubRightColumn language targets', () => {
|
||||
it('localizes the lip-sync timing option', async () => {
|
||||
await act(async () => {
|
||||
render(column(false));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'dub.timing_lip_sync' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables language switches for the full shared batch lock', async () => {
|
||||
const setDubLang = vi.fn();
|
||||
const setDubLangCode = vi.fn();
|
||||
@@ -74,4 +84,16 @@ describe('DubRightColumn language targets', () => {
|
||||
expect(setDubLang).toHaveBeenCalledWith('Spanish');
|
||||
expect(setDubLangCode).toHaveBeenCalledWith('es');
|
||||
});
|
||||
|
||||
it('renders the first available dub when the saved default is stale', () => {
|
||||
render(
|
||||
column(false, noop, noop, {
|
||||
defaultTrack: 'fr',
|
||||
dubLangCode: 'bn',
|
||||
dubTracks: ['es'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'dub.default_track' })).toHaveValue('es');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useAppStore } from '../store';
|
||||
import { listProfiles } from '../api/profiles';
|
||||
import { listHistory } from '../api/generate';
|
||||
@@ -10,6 +10,7 @@ import { useModelStatus } from '../api/hooks';
|
||||
import useRealtimeEvents from './useRealtimeEvents';
|
||||
import { mergeDescribedAttrs } from '../utils/voiceInstruct';
|
||||
import { sanitizeOmniUi } from '../utils/omniUiSchema';
|
||||
import { loadLatest, retryInitialLoad } from '../utils/initialLoadRetry';
|
||||
import { queueJsonWrite } from '../utils/coalescedJsonStorage';
|
||||
|
||||
/**
|
||||
@@ -163,43 +164,37 @@ export default function useAppData() {
|
||||
}, [modelStatus, modelSubStage, modelDetail, modelError, modelProgress]);
|
||||
|
||||
// ── Data loading callbacks ──
|
||||
// Failures keep the previous list (better than blanking the UI), but are
|
||||
// logged so "my voices/history vanished" reports carry a cause (#1158).
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
setProfiles(await listProfiles());
|
||||
} catch (e) {
|
||||
console.warn('Failed to load voice profiles:', e);
|
||||
}
|
||||
}, []);
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
setHistory(await listHistory());
|
||||
} catch (e) {
|
||||
console.warn('Failed to load generation history:', e);
|
||||
}
|
||||
}, []);
|
||||
const loadDubHistory = useCallback(async () => {
|
||||
try {
|
||||
setDubHistory(await listDubHistory());
|
||||
} catch (e) {
|
||||
console.warn('Failed to load dub history:', e);
|
||||
}
|
||||
}, []);
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
setStudioProjects(await listProjects());
|
||||
} catch (e) {
|
||||
console.warn('Failed to load projects:', e);
|
||||
}
|
||||
}, []);
|
||||
const loadExportHistory = useCallback(async () => {
|
||||
try {
|
||||
setExportHistory(await listExportHistory());
|
||||
} catch (e) {
|
||||
console.warn('Failed to load export history:', e);
|
||||
}
|
||||
}, []);
|
||||
// WS-triggered reloads swallow failures: keeping the previous list is
|
||||
// better than blanking the UI, and the warn gives "my voices vanished"
|
||||
// reports a cause (#1158). The INITIAL load passes `{ rethrow: true }` so
|
||||
// retryInitialLoad can retry — there is no previous list to keep yet.
|
||||
// Each loader is last-write-wins by invocation order: a slow in-flight
|
||||
// request (the initial retry loop overlaps freely with WS reloads) must
|
||||
// not overwrite the fresher list a later reload already applied. Plain
|
||||
// per-render closures over stable imports/setters — a useRef-free module
|
||||
// would need hooks inside a helper, which rules-of-hooks forbids.
|
||||
const loadersRef = useRef({ profiles: 0, history: 0, dub: 0, projects: 0, exports: 0 });
|
||||
const makeLoader =
|
||||
(key, fetch, set, label) =>
|
||||
({ rethrow } = {}) =>
|
||||
loadLatest({
|
||||
generations: loadersRef.current,
|
||||
key,
|
||||
fetch,
|
||||
apply: set,
|
||||
label,
|
||||
rethrow,
|
||||
});
|
||||
const loadProfiles = makeLoader('profiles', listProfiles, setProfiles, 'voice profiles');
|
||||
const loadHistory = makeLoader('history', listHistory, setHistory, 'generation history');
|
||||
const loadDubHistory = makeLoader('dub', listDubHistory, setDubHistory, 'dub history');
|
||||
const loadProjects = makeLoader('projects', listProjects, setStudioProjects, 'projects');
|
||||
const loadExportHistory = makeLoader(
|
||||
'exports',
|
||||
listExportHistory,
|
||||
setExportHistory,
|
||||
'export history',
|
||||
);
|
||||
|
||||
// ── WebSocket real-time updates ──
|
||||
useRealtimeEvents({
|
||||
@@ -212,10 +207,10 @@ export default function useAppData() {
|
||||
|
||||
// ── Initial data load with backend retry ──
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const cancelledRef = { cancelled: false };
|
||||
const loadAll = async () => {
|
||||
let delay = 1000;
|
||||
while (!cancelled) {
|
||||
while (!cancelledRef.cancelled) {
|
||||
try {
|
||||
await apiModelStatus();
|
||||
break;
|
||||
@@ -223,12 +218,16 @@ export default function useAppData() {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
delay = Math.min(delay * 2, 4000);
|
||||
}
|
||||
if (cancelled) return;
|
||||
loadProfiles();
|
||||
loadHistory();
|
||||
loadDubHistory();
|
||||
loadProjects();
|
||||
loadExportHistory();
|
||||
if (cancelledRef.cancelled) return;
|
||||
// Initial loads retry until FIRST success (#1158 class): a later
|
||||
// (WS-triggered) reload failure keeps the previous list, but the first
|
||||
// load has no previous list to keep — one transient failure used to
|
||||
// leave the panel empty, which read as "my voices are gone".
|
||||
retryInitialLoad(() => loadProfiles({ rethrow: true }), cancelledRef);
|
||||
retryInitialLoad(() => loadHistory({ rethrow: true }), cancelledRef);
|
||||
retryInitialLoad(() => loadDubHistory({ rethrow: true }), cancelledRef);
|
||||
retryInitialLoad(() => loadProjects({ rethrow: true }), cancelledRef);
|
||||
retryInitialLoad(() => loadExportHistory({ rethrow: true }), cancelledRef);
|
||||
};
|
||||
loadAll();
|
||||
// Restore local UI state
|
||||
@@ -290,7 +289,7 @@ export default function useAppData() {
|
||||
setOmniUiRestoreComplete(true);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelledRef.cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -922,6 +922,7 @@ export default function useDubWorkflow({
|
||||
// contract); switching the target language swaps from this map
|
||||
// instead of destroying the previous language's work.
|
||||
...(gotText ? { translations: { ...s.translations, [targetLang]: hit.text } } : {}),
|
||||
...(gotText ? { merge_parts: undefined } : {}),
|
||||
translate_error: hit.error || undefined,
|
||||
translate_degraded: hit.degraded || undefined,
|
||||
translate_literal: hit.literal || undefined,
|
||||
@@ -1060,7 +1061,7 @@ export default function useDubWorkflow({
|
||||
guidance_scale: cfg,
|
||||
speed,
|
||||
preview,
|
||||
timing_strategy: timingStrategy || 'concise',
|
||||
timing_strategy: timingStrategy || 'strict_slot',
|
||||
// Voice-identity mode for auto-clone bindings (per_line default =
|
||||
// unchanged behaviour; consistent = one reference per speaker).
|
||||
voice_match: voiceMatch || 'per_line',
|
||||
|
||||
@@ -11,6 +11,40 @@ import { apiPost } from '../api/client';
|
||||
import { segmentGenInputs } from '../utils/segments';
|
||||
import { commitMoveResize } from '../utils/timeline';
|
||||
import { buildPastePlan } from '../utils/pasteTranslations';
|
||||
import {
|
||||
ATTRIBUTION_FIELDS,
|
||||
applyAttribution,
|
||||
attributionAt,
|
||||
attributionOf,
|
||||
clipParts,
|
||||
insertionSlot,
|
||||
keepParts,
|
||||
mergedOriginalParts,
|
||||
mergedParts,
|
||||
nextSegmentId,
|
||||
partsFor,
|
||||
} from '../utils/segmentParts';
|
||||
|
||||
const MERGE_PART_FIELDS = new Set(['text', ...ATTRIBUTION_FIELDS]);
|
||||
|
||||
function clearStaleMergeParts(segment, fields) {
|
||||
if (!segment.merge_parts || !fields.some((field) => MERGE_PART_FIELDS.has(field))) return segment;
|
||||
const next = { ...segment };
|
||||
delete next.merge_parts;
|
||||
if (fields.some((field) => ATTRIBUTION_FIELDS.includes(field))) {
|
||||
delete next.merge_parts_original;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function trimmedTextRange(text, from, to) {
|
||||
const raw = text.slice(from, to);
|
||||
return {
|
||||
from: from + raw.length - raw.trimStart().length,
|
||||
to: from + raw.trimEnd().length,
|
||||
text: raw.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Stable empty map so `lastGenFingerprints` keeps a constant identity for a
|
||||
// language with no stored hashes (avoids effect/callback churn).
|
||||
@@ -62,7 +96,7 @@ export default function useSegmentEditing() {
|
||||
setDubSegments((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== id) return s;
|
||||
const next = { ...s, [field]: value };
|
||||
const next = clearStaleMergeParts({ ...s, [field]: value }, [field]);
|
||||
if (field === 'text' && lang) {
|
||||
next.translations = { ...s.translations, [lang]: value };
|
||||
}
|
||||
@@ -107,6 +141,41 @@ export default function useSegmentEditing() {
|
||||
[dubSegments],
|
||||
);
|
||||
|
||||
// Insert a blank line after `id` (#1612 — "add a new phrase"). It takes the
|
||||
// silent gap before the next line when there is a usable one, otherwise a
|
||||
// default-length slot right after this row; either way the existing overlap
|
||||
// detection flags a collision rather than letting two lines play together
|
||||
// unannounced. The new row continues the same speaker, voice and language —
|
||||
// but carries no directorial note or gain, which described the OTHER line's
|
||||
// words. `/dub/generate` skips empty-text segments, so the row is inert
|
||||
// until the user writes it.
|
||||
const segmentInsert = useCallback(
|
||||
(id) => {
|
||||
pushUndo(dubSegments);
|
||||
setDubSegments((prev) => {
|
||||
const idx = prev.findIndex((s) => s.id === id);
|
||||
if (idx < 0) return prev;
|
||||
const seg = prev[idx];
|
||||
const slot = insertionSlot(seg, prev[idx + 1]);
|
||||
const created = {
|
||||
id: nextSegmentId(
|
||||
prev.map((s) => s.id),
|
||||
seg.id,
|
||||
),
|
||||
start: slot.start,
|
||||
end: slot.end,
|
||||
text: '',
|
||||
text_original: '',
|
||||
};
|
||||
for (const f of ['speaker_id', 'profile_id', 'target_lang']) {
|
||||
if (seg[f] !== undefined && seg[f] !== null) created[f] = seg[f];
|
||||
}
|
||||
return [...prev.slice(0, idx + 1), created, ...prev.slice(idx + 1)];
|
||||
});
|
||||
},
|
||||
[dubSegments],
|
||||
);
|
||||
|
||||
// Timeline selection — syncs the segment table (scroll + highlight).
|
||||
const [timelineSelSegId, setTimelineSelSegId] = useState(null);
|
||||
|
||||
@@ -122,13 +191,18 @@ export default function useSegmentEditing() {
|
||||
prev.map((s) => {
|
||||
if (s.id !== id) return s;
|
||||
const restored = s.text_original || s.text;
|
||||
return {
|
||||
...s,
|
||||
text: restored,
|
||||
...(lang ? { translations: { ...s.translations, [lang]: restored } } : {}),
|
||||
translate_error: undefined,
|
||||
translate_degraded: undefined,
|
||||
};
|
||||
const originalParts = s.merge_parts_original;
|
||||
return applyAttribution(
|
||||
{
|
||||
...s,
|
||||
text: restored,
|
||||
...(lang ? { translations: { ...s.translations, [lang]: restored } } : {}),
|
||||
translate_error: undefined,
|
||||
translate_degraded: undefined,
|
||||
merge_parts: originalParts,
|
||||
},
|
||||
originalParts ? attributionAt(originalParts, 0) : attributionOf(s),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -167,6 +241,7 @@ export default function useSegmentEditing() {
|
||||
...(lang ? { translations: { ...s.translations, [lang]: next } } : {}),
|
||||
translate_error: undefined,
|
||||
translate_degraded: undefined,
|
||||
merge_parts: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -212,7 +287,11 @@ export default function useSegmentEditing() {
|
||||
if (!selectedSegIds.size) return;
|
||||
pushUndo(dubSegments);
|
||||
setDubSegments((prev) =>
|
||||
prev.map((s) => (selectedSegIds.has(s.id) ? { ...s, ...patch } : s)),
|
||||
prev.map((s) =>
|
||||
selectedSegIds.has(s.id)
|
||||
? clearStaleMergeParts({ ...s, ...patch }, Object.keys(patch))
|
||||
: s,
|
||||
),
|
||||
);
|
||||
},
|
||||
[dubSegments, selectedSegIds],
|
||||
@@ -246,34 +325,62 @@ export default function useSegmentEditing() {
|
||||
// Other languages' saved texts (P1.2) can't be split at a sensible
|
||||
// position for the halves — drop them; the halves are new segment ids
|
||||
// that need fresh TTS per language anyway.
|
||||
const left = {
|
||||
...seg,
|
||||
id: `${seg.id}_a`,
|
||||
text: text.slice(0, pos).trim(),
|
||||
end: midT,
|
||||
text_original: text.slice(0, pos).trim(),
|
||||
translations: undefined,
|
||||
};
|
||||
const right = {
|
||||
...seg,
|
||||
id: `${seg.id}_b`,
|
||||
text: text.slice(pos).trim(),
|
||||
start: midT,
|
||||
text_original: text.slice(pos).trim(),
|
||||
translations: undefined,
|
||||
};
|
||||
// Attribution follows the SPEAKER, not the parent row. When this
|
||||
// segment is the product of a merge, each half takes the attribution
|
||||
// recorded for the part covering its START, so words carried across a
|
||||
// speaker boundary are dubbed by whoever actually says them (#1612).
|
||||
// A never-merged segment has exactly one part, so both halves inherit
|
||||
// the parent unchanged — the old behaviour.
|
||||
// Attribution is looked up by TEXT OFFSET, not by time. `pos` is where
|
||||
// the user put the caret, and offsets cannot overlap — whereas two
|
||||
// merged parts can cover the same instant the moment someone retimes a
|
||||
// line past its neighbour, which then hands the words to whichever
|
||||
// speaker happened to be checked first (#1612).
|
||||
const parts = partsFor(seg);
|
||||
const leftRange = trimmedTextRange(text, 0, pos);
|
||||
const rightRange = trimmedTextRange(text, pos, text.length);
|
||||
const left = applyAttribution(
|
||||
{
|
||||
...seg,
|
||||
id: `${seg.id}_a`,
|
||||
text: leftRange.text,
|
||||
end: midT,
|
||||
text_original: leftRange.text,
|
||||
translations: undefined,
|
||||
merge_parts: keepParts(clipParts(parts, leftRange.from, leftRange.to)),
|
||||
merge_parts_original: keepParts(clipParts(parts, leftRange.from, leftRange.to)),
|
||||
},
|
||||
attributionAt(parts, leftRange.from),
|
||||
);
|
||||
const right = applyAttribution(
|
||||
{
|
||||
...seg,
|
||||
id: `${seg.id}_b`,
|
||||
text: rightRange.text,
|
||||
start: midT,
|
||||
text_original: rightRange.text,
|
||||
translations: undefined,
|
||||
merge_parts: keepParts(clipParts(parts, rightRange.from, rightRange.to)),
|
||||
merge_parts_original: keepParts(clipParts(parts, rightRange.from, rightRange.to)),
|
||||
},
|
||||
attributionAt(parts, rightRange.from),
|
||||
);
|
||||
return [...prev.slice(0, idx), left, right, ...prev.slice(idx + 1)];
|
||||
});
|
||||
},
|
||||
[dubSegments],
|
||||
);
|
||||
|
||||
// Merge segment with its next sibling.
|
||||
// Merge a segment with its previous or next sibling. Only "next" existed;
|
||||
// #1612 asked for both. Merging is index-based over the full list, so
|
||||
// `direction: 'prev'` is the same operation anchored one row earlier.
|
||||
const segmentMerge = useCallback(
|
||||
(id) => {
|
||||
(id, direction = 'next') => {
|
||||
pushUndo(dubSegments);
|
||||
setDubSegments((prev) => {
|
||||
const idx = prev.findIndex((s) => s.id === id);
|
||||
const at = prev.findIndex((s) => s.id === id);
|
||||
if (at < 0) return prev;
|
||||
const idx = direction === 'prev' ? at - 1 : at;
|
||||
if (idx < 0 || idx >= prev.length - 1) return prev;
|
||||
const a = prev[idx];
|
||||
const b = prev[idx + 1];
|
||||
@@ -295,6 +402,12 @@ export default function useSegmentEditing() {
|
||||
`${a.text_original || a.text || ''} ${b.text_original || b.text || ''}`.trim(),
|
||||
end: b.end,
|
||||
translations: Object.keys(mergedTranslations).length ? mergedTranslations : undefined,
|
||||
// Remember which attribution covered which span. The merged row
|
||||
// still presents as `a` (it starts with a's words), but a later
|
||||
// split can now hand b's words back to b's speaker instead of
|
||||
// dubbing them in a's voice (#1612).
|
||||
merge_parts: mergedParts(a, b),
|
||||
merge_parts_original: mergedOriginalParts(a, b),
|
||||
};
|
||||
return [...prev.slice(0, idx), merged, ...prev.slice(idx + 2)];
|
||||
});
|
||||
@@ -311,7 +424,11 @@ export default function useSegmentEditing() {
|
||||
if (!directionSegId) return;
|
||||
pushUndo(dubSegments);
|
||||
setDubSegments((prev) =>
|
||||
prev.map((s) => (s.id === directionSegId ? { ...s, direction: value || undefined } : s)),
|
||||
prev.map((s) =>
|
||||
s.id === directionSegId
|
||||
? clearStaleMergeParts({ ...s, direction: value || undefined }, ['direction'])
|
||||
: s,
|
||||
),
|
||||
);
|
||||
},
|
||||
[directionSegId, dubSegments],
|
||||
@@ -379,6 +496,7 @@ export default function useSegmentEditing() {
|
||||
pasteTranslations,
|
||||
segmentSplit,
|
||||
segmentMerge,
|
||||
segmentInsert,
|
||||
segmentMoveResize,
|
||||
// Timeline selection (waveform ↔ table sync)
|
||||
timelineSelSegId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { playBlobAudio, playPing } from '../utils/media';
|
||||
import {
|
||||
StreamingPreviewError,
|
||||
resolveRemoteTtsTarget,
|
||||
shouldFallbackToClassic,
|
||||
streamGenerateSpeech,
|
||||
supportsStreamingPreview,
|
||||
} from '../utils/streamingTts';
|
||||
@@ -318,7 +319,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
|
||||
// same error. Surface the backend's actionable message instead.
|
||||
// Non-retryable failures (transport drop, Web Audio glitch) keep the
|
||||
// classic fallback: there the whole-file path genuinely can succeed.
|
||||
if (err.retryable) {
|
||||
if (!shouldFallbackToClassic(err)) {
|
||||
addBreadcrumb('generate:stream-retryable-abort');
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "جارٍ النسخ…",
|
||||
"a11y_setup": "اسمح بتسهيلات الاستخدام حتى يتمكّن الإملاء من الكتابة نيابةً عنك",
|
||||
"pasted": "تم لصقه",
|
||||
"inserted": "تم الإدخال",
|
||||
"copied": "تم النسخ إلى الحافظة",
|
||||
"no_speech": "لم يتم اكتشاف أي كلام",
|
||||
"mic_denied": "تم رفض الوصول إلى الميكروفون",
|
||||
"mic_denied_toast": "تم رفض الوصول إلى الميكروفون. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "يُستنسخ كل سطر من مقطع صوته المصدر الخاص. أفضل تطابق في الأداء لكل سطر، لكن هوية الصوت قد تنحرف من سطر إلى آخر.",
|
||||
"voice_match_consistent": "متّسق",
|
||||
"voice_match_consistent_title": "تُستنسخ كل أسطر المتحدث من مرجع واحد مشترك (نسخة صوت المتحدث، أو أفضل مقطع منفرد عند عدم وجودها). هوية صوت أكثر ثباتًا عبر الدبلجة كاملة.",
|
||||
"timing_lip_sync": "مزامنة الشفاه",
|
||||
"default_track": "المسار الافتراضي:",
|
||||
"original_track": "أصلي",
|
||||
"selected_dub": "{{code}} (الدبلجة المحددة)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "خطأ في الترجمة: {{error}}",
|
||||
"translate_degraded_title": "تمت الترجمة (مباشرة) — تم تخطي خطوة الصقل: {{reason}}",
|
||||
"budget_title": "النص هو {{pct}}% من النص الأصلي — فكر في سرعة أعلى أو صياغة أقصر",
|
||||
"text_title": "Ctrl+D للتقسيم عند المؤشر · Ctrl+M للدمج مع التالي",
|
||||
"text_title": "Cmd/Ctrl+D للتقسيم عند المؤشر · Cmd/Ctrl+M للدمج مع التالي · Cmd/Ctrl+Shift+M للدمج مع السابق",
|
||||
"orig_label": "أصل",
|
||||
"restore_title": "استعادة النص الأصلي",
|
||||
"lang_default": "(مدافع)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "تحرير الاتجاه...",
|
||||
"split_label": "انقسام عند المؤشر",
|
||||
"merge_label": "دمج مع التالي",
|
||||
"merge_prev_label": "دمج مع السابق",
|
||||
"insert_label": "إدراج سطر أدناه",
|
||||
"direction_title": "الاتجاه: {{dir}}",
|
||||
"more_actions_title": "المزيد من الإجراءات",
|
||||
"speaker_pick": "اختر…",
|
||||
"speaker_title_detected": "مكبر الصوت - اختر مما تم اكتشافه، أو اكتب اسمًا مخصصًا",
|
||||
"speaker_title_custom": "مكبر الصوت - اكتب اسمًا (لم يتم اكتشاف أي نسخ للكتابة)",
|
||||
"time_edit_title": "انقر لتعديل وقت البدء (m:ss.s). أدخل للالتزام، Esc للإلغاء.",
|
||||
"time_edit_end_title": "انقر لتعديل وقت الانتهاء (m:ss.s). أدخل للالتزام، Esc للإلغاء.",
|
||||
"fit_fits": "يناسب",
|
||||
"fit_fits_title": "يتناسب الصوت ذو المعدل الطبيعي داخل الفتحة.",
|
||||
"fit_overflows": "الفائض +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "محرر المقطع",
|
||||
"seg_split": "تقسيم الجزء عند المؤشر",
|
||||
"seg_merge": "دمج مع الجزء التالي",
|
||||
"seg_merge_prev": "دمج مع الجزء السابق",
|
||||
"seg_undo": "تراجع",
|
||||
"seg_redo": "إعادة",
|
||||
"seg_click": "العمل الأساسي",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "خطأ: {{message}}",
|
||||
"ignored_unsupported": "تم تجاهل التعليمات غير المدعومة: {{items}}",
|
||||
"ignored_duplicate": "تم التجاهل (تم تعيين الفئة بالفعل): {{items}}",
|
||||
"ref_audio_unusable": "تعذّر سماع أي كلام في الصوت المرجعي — المقطع صامت أو شبه صامت، فلا يوجد صوت لاستنساخه. سجّل مجددًا أقرب إلى الميكروفون (وتأكد من اختيار جهاز الإدخال الصحيح)، أو اختر مقطعًا آخر."
|
||||
"ref_audio_unusable": "تعذّر سماع أي كلام في الصوت المرجعي — المقطع صامت أو شبه صامت، فلا يوجد صوت لاستنساخه. سجّل مجددًا أقرب إلى الميكروفون (وتأكد من اختيار جهاز الإدخال الصحيح)، أو اختر مقطعًا آخر.",
|
||||
"ref_audio_too_long": "يمكن أن يبلغ طول المرجع 20 ثانية كحد أقصى مع نص، أو 75 ثانية من دونه. قصّه إلى مقطع كلام واضح بطول 3–10 ثوانٍ؛ وإذا أرفقت نصًا فقصّه إلى المقطع نفسه.",
|
||||
"ref_audio_no_speech": "لم يكتشف التعرف التلقائي أي كلمات منطوقة في المرجع. قصّه إلى مقطع كلام واضح بطول 3–10 ثوانٍ أو أرفق نصًا مطابقًا."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "المشاركة والوصول عن بعد",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "حذف نموذج الكلام",
|
||||
"engine_unavailable": "محرك الإملاء المباشر غير متوفر في هذا التثبيت. يعود الإملاء إلى مسار النسخ القياسي.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "موصى به. إملاء سريع ودقيق عبر 25 لغة أوروبية.",
|
||||
"sherpa-parakeet-tdt-v3": "إملاء سريع ودقيق عبر 25 لغة أوروبية.",
|
||||
"sherpa-parakeet-tdt-v2": "إملاء سريع ودقيق باللغة الإنجليزية فقط.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "بث الصينية + الإنجليزية مع أجزاء حية.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "تدفق الصينية + الإنجليزية، نموذج مدمج.",
|
||||
"sherpa-zipformer-en-20m": "نموذج صغير للبث باللغة الإنجليزية – أقل زمن وصول.",
|
||||
"sherpa-zipformer-zh-14m": "النموذج الصيني المتدفق الصغير – أقل زمن وصول.",
|
||||
"sherpa-whisper-tiny": "متعدد اللغات (+90 لغة) مع الكشف التلقائي عن اللغة."
|
||||
"sherpa-whisper-tiny": "موصى به. إملاء متعدد اللغات لأكثر من 90 لغة مع اكتشاف اللغة تلقائيًا."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transkribieren…",
|
||||
"a11y_setup": "Bedienungshilfen erlauben, damit das Diktat für Sie tippen kann",
|
||||
"pasted": "Eingefügt",
|
||||
"inserted": "Eingegeben",
|
||||
"copied": "In die Zwischenablage kopiert",
|
||||
"no_speech": "Keine Sprache erkannt",
|
||||
"mic_denied": "Mikrofonzugriff verweigert",
|
||||
"mic_denied_toast": "Mikrofonzugriff verweigert. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "Jede Zeile wird aus einem Clip ihres eigenen Quelltons geklont. Beste Prosodie pro Zeile, aber die Stimmidentität kann von Zeile zu Zeile driften.",
|
||||
"voice_match_consistent": "Konsistent",
|
||||
"voice_match_consistent_title": "Alle Zeilen eines Sprechers werden aus einer gemeinsamen Referenz geklont (Sprecher-Klon oder bester Einzelclip, wenn keiner existiert). Stabilere Stimmidentität über die gesamte Synchronisation.",
|
||||
"timing_lip_sync": "Lippensynchronisation",
|
||||
"default_track": "Standardspur:",
|
||||
"original_track": "Original",
|
||||
"selected_dub": "{{code}} (Ausgewählter Dub)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "Übersetzungsfehler: {{error}}",
|
||||
"translate_degraded_title": "Übersetzt (einfach) — der Feinschliff wurde übersprungen: {{reason}}",
|
||||
"budget_title": "Der Text besteht zu {{pct}} % aus dem Original – erwägen Sie eine höhere Geschwindigkeit oder eine kürzere Formulierung",
|
||||
"text_title": "Strg+D zum Teilen am Cursor · Strg+M zum Zusammenführen mit dem nächsten",
|
||||
"text_title": "Cmd/Strg+D zum Teilen am Cursor · Cmd/Strg+M zum Zusammenführen mit dem nächsten · Cmd/Strg+Umschalt+M zum Zusammenführen mit dem vorherigen",
|
||||
"orig_label": "orig",
|
||||
"restore_title": "Originaltext wiederherstellen",
|
||||
"lang_default": "(Def)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "Richtung bearbeiten…",
|
||||
"split_label": "Am Cursor teilen",
|
||||
"merge_label": "Mit dem nächsten zusammenführen",
|
||||
"merge_prev_label": "Mit dem vorherigen zusammenführen",
|
||||
"insert_label": "Zeile darunter einfügen",
|
||||
"direction_title": "Richtung: {{dir}}",
|
||||
"more_actions_title": "Weitere Aktionen",
|
||||
"speaker_pick": "Wählen Sie…",
|
||||
"speaker_title_detected": "Lautsprecher: Wählen Sie einen der erkannten Namen aus oder geben Sie einen benutzerdefinierten Namen ein",
|
||||
"speaker_title_custom": "Sprecher – Geben Sie einen Namen ein (keine Diarisierungsklone erkannt)",
|
||||
"time_edit_title": "Klicken Sie hier, um die Startzeit (m:ss.s) zu bearbeiten. Geben Sie zum Festschreiben die Eingabetaste ein, zum Abbrechen die Esc-Taste.",
|
||||
"time_edit_end_title": "Klicken Sie hier, um die Endzeit (m:ss.s) zu bearbeiten. Geben Sie zum Festschreiben die Eingabetaste ein, zum Abbrechen die Esc-Taste.",
|
||||
"fit_fits": "Passt",
|
||||
"fit_fits_title": "Audio mit natürlicher Geschwindigkeit passt in den Steckplatz.",
|
||||
"fit_overflows": "Überläufe +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "Segmenteditor",
|
||||
"seg_split": "Segment am Cursor teilen",
|
||||
"seg_merge": "Mit dem nächsten Segment zusammenführen",
|
||||
"seg_merge_prev": "Mit dem vorherigen Segment zusammenführen",
|
||||
"seg_undo": "Rückgängig machen",
|
||||
"seg_redo": "Wiederholen",
|
||||
"seg_click": "Primäre Aktion",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "Fehler: {{message}}",
|
||||
"ignored_unsupported": "Nicht unterstützte Anweisung ignoriert: {{items}}",
|
||||
"ignored_duplicate": "Ignoriert (Kategorie bereits festgelegt): {{items}}",
|
||||
"ref_audio_unusable": "Im Referenz-Audio war keine Sprache zu hören — der Clip ist still oder fast still, es gibt also keine Stimme zum Klonen. Nimm näher am Mikrofon neu auf (prüfe, ob das richtige Eingabegerät gewählt ist und der Pegel ausschlägt) oder wähle einen anderen Clip."
|
||||
"ref_audio_unusable": "Im Referenz-Audio war keine Sprache zu hören — der Clip ist still oder fast still, es gibt also keine Stimme zum Klonen. Nimm näher am Mikrofon neu auf (prüfe, ob das richtige Eingabegerät gewählt ist und der Pegel ausschlägt) oder wähle einen anderen Clip.",
|
||||
"ref_audio_too_long": "Eine Referenz darf mit Transkript höchstens 20 Sekunden, ohne Transkript höchstens 75 Sekunden lang sein. Kürze sie auf eine klare Sprachpassage von 3–10 Sekunden; bei mitgeliefertem Text muss das Transkript denselben Abschnitt abdecken.",
|
||||
"ref_audio_no_speech": "Die automatische Spracherkennung hat keine gesprochenen Wörter gefunden. Kürze die Referenz auf eine klare Sprachpassage von 3–10 Sekunden oder gib ein passendes Transkript an."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Teilen und Fernzugriff",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "Sprachmodell löschen",
|
||||
"engine_unavailable": "Die Live-Diktier-Engine ist bei dieser Installation nicht verfügbar. Beim Diktat wird auf den Standard-Transkriptionspfad zurückgegriffen.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Empfohlen. Schnelles und genaues Diktieren in 25 europäischen Sprachen.",
|
||||
"sherpa-parakeet-tdt-v3": "Schnelles und genaues Diktieren in 25 europäischen Sprachen.",
|
||||
"sherpa-parakeet-tdt-v2": "Schnelles, genaues Diktat nur auf Englisch.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming Chinesisch + Englisch mit Live-Teilabschnitten.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming Chinesisch + Englisch, kompaktes Modell.",
|
||||
"sherpa-zipformer-en-20m": "Winziges englisches Streaming-Modell – niedrigste Latenz.",
|
||||
"sherpa-zipformer-zh-14m": "Winziges chinesisches Streaming-Modell – niedrigste Latenz.",
|
||||
"sherpa-whisper-tiny": "Mehrsprachig (über 90 Sprachen) mit automatischer Spracherkennung."
|
||||
"sherpa-whisper-tiny": "Empfohlen. Mehrsprachiges Diktieren in über 90 Sprachen mit automatischer Spracherkennung."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -1038,6 +1038,7 @@
|
||||
"listening_label": "Listening…",
|
||||
"transcribing_label": "Transcribing…",
|
||||
"pasted": "Pasted",
|
||||
"inserted": "Inserted",
|
||||
"copied": "Copied to clipboard",
|
||||
"no_speech": "No speech detected",
|
||||
"model_downloading": "Downloading voice model…",
|
||||
@@ -1080,13 +1081,13 @@
|
||||
"delete_confirm_title": "Delete speech model",
|
||||
"engine_unavailable": "The live-dictation engine isn't available on this install. Dictation falls back to the standard transcription path.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Recommended. Fast, accurate dictation across 25 European languages.",
|
||||
"sherpa-parakeet-tdt-v3": "Fast, accurate dictation across 25 European languages.",
|
||||
"sherpa-parakeet-tdt-v2": "Fast, accurate English-only dictation.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming Chinese + English with live partials.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming Chinese + English, compact model.",
|
||||
"sherpa-zipformer-en-20m": "Tiny streaming English model — lowest latency.",
|
||||
"sherpa-zipformer-zh-14m": "Tiny streaming Chinese model — lowest latency.",
|
||||
"sherpa-whisper-tiny": "Multilingual (90+ languages) with auto language detection."
|
||||
"sherpa-whisper-tiny": "Recommended. Multilingual dictation across 90+ languages with automatic language detection."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -1311,6 +1312,7 @@
|
||||
"voice_match_per_line_title": "Each line clones from a clip of its own source audio. Best per-line prosody match, but the voice identity can drift from line to line.",
|
||||
"voice_match_consistent": "Consistent",
|
||||
"voice_match_consistent_title": "Every line of a speaker clones from one shared reference (the speaker clone, or the best single clip when none exists). Steadier voice identity across the whole dub.",
|
||||
"timing_lip_sync": "Lip sync",
|
||||
"timing_concise": "Concise",
|
||||
"timing_stretch_video": "Stretch Video",
|
||||
"timing_strict_slot": "Strict slot",
|
||||
@@ -1432,7 +1434,7 @@
|
||||
"translate_degraded_title": "Translated (plain) — the polish pass was skipped: {{reason}}",
|
||||
"translate_error_title": "Translation error: {{error}}",
|
||||
"budget_title": "Text is {{pct}}% of original — consider higher speed or shorter phrasing",
|
||||
"text_title": "Ctrl+D to split at cursor · Ctrl+M to merge with next",
|
||||
"text_title": "Cmd/Ctrl+D to split at cursor · Cmd/Ctrl+M to merge with next · Cmd/Ctrl+Shift+M to merge with previous",
|
||||
"orig_label": "orig",
|
||||
"restore_title": "Restore original text",
|
||||
"lang_default": "(Def)",
|
||||
@@ -1445,12 +1447,15 @@
|
||||
"edit_direction": "Edit direction…",
|
||||
"split_label": "Split at cursor",
|
||||
"merge_label": "Merge with next",
|
||||
"merge_prev_label": "Merge with previous",
|
||||
"insert_label": "Insert line below",
|
||||
"direction_title": "Direction: {{dir}}",
|
||||
"more_actions_title": "More actions",
|
||||
"speaker_pick": "Pick…",
|
||||
"speaker_title_detected": "Speaker — pick from detected, or type a custom name",
|
||||
"speaker_title_custom": "Speaker — type a name (no diarization clones detected)",
|
||||
"time_edit_title": "Click to edit start time (m:ss.s). Enter to commit, Esc to cancel.",
|
||||
"time_edit_end_title": "Click to edit end time (m:ss.s). Enter to commit, Esc to cancel.",
|
||||
"qc_verify": "Verify",
|
||||
"qc_verify_title": "Second-pass ASR heard: \"{{heard}}\" — re-listen or re-dub this line.",
|
||||
"fit_fits": "Fits",
|
||||
@@ -2202,6 +2207,7 @@
|
||||
"segmentEditor": "Segment editor",
|
||||
"seg_split": "Split segment at cursor",
|
||||
"seg_merge": "Merge with next segment",
|
||||
"seg_merge_prev": "Merge with previous segment",
|
||||
"seg_undo": "Undo",
|
||||
"seg_redo": "Redo",
|
||||
"seg_click": "Primary action",
|
||||
@@ -2729,7 +2735,9 @@
|
||||
"error_prefix": "Error: {{message}}",
|
||||
"ignored_unsupported": "Ignored unsupported instruct: {{items}}",
|
||||
"ignored_duplicate": "Ignored (category already set): {{items}}",
|
||||
"ref_audio_unusable": "No speech could be heard in the reference audio — the clip is silent or nearly silent, so there is no voice to clone. Record again closer to the microphone (check that the right input device is selected and its level moves while you speak), or choose a different clip."
|
||||
"ref_audio_unusable": "No speech could be heard in the reference audio — the clip is silent or nearly silent, so there is no voice to clone. Record again closer to the microphone (check that the right input device is selected and its level moves while you speak), or choose a different clip.",
|
||||
"ref_audio_too_long": "A reference can be at most 20 seconds with a supplied transcript, or 75 seconds without one. Trim it to a clear 3–10 second speech passage; if you supply text, trim the transcript to the same passage.",
|
||||
"ref_audio_no_speech": "Automatic speech detection found no spoken words in the reference. Trim it to a clear 3–10 second speech passage, or supply a matching transcript."
|
||||
},
|
||||
"tts": {
|
||||
"routingFallback": "Running on CPU — this engine has no GPU path on your machine, so generation will be slower. {{reason}}",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transcribiendo…",
|
||||
"a11y_setup": "Permite Accesibilidad para que el dictado pueda escribir por ti",
|
||||
"pasted": "Pegado",
|
||||
"inserted": "Insertado",
|
||||
"copied": "Copiado al portapapeles",
|
||||
"no_speech": "No se detectó voz",
|
||||
"mic_denied": "Acceso al micrófono denegado",
|
||||
"mic_denied_toast": "Acceso al micrófono denegado. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "Cada línea se clona desde un clip de su propio audio original. La mejor prosodia por línea, pero la identidad de la voz puede variar de línea a línea.",
|
||||
"voice_match_consistent": "Consistente",
|
||||
"voice_match_consistent_title": "Todas las líneas de un hablante se clonan desde una referencia compartida (el clon del hablante o el mejor clip individual si no existe). Identidad de voz más estable en todo el doblaje.",
|
||||
"timing_lip_sync": "Sincronización labial",
|
||||
"default_track": "Pista predeterminada:",
|
||||
"original_track": "Originales",
|
||||
"selected_dub": "{{code}} (Doblaje seleccionado)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "Error de traducción: {{error}}",
|
||||
"translate_degraded_title": "Traducido (simple) — se omitió el pulido: {{reason}}",
|
||||
"budget_title": "El texto es {{pct}}% del original; considere una mayor velocidad o una redacción más corta",
|
||||
"text_title": "Ctrl+D para dividir en el cursor · Ctrl+M para fusionar con el siguiente",
|
||||
"text_title": "Cmd/Ctrl+D para dividir en el cursor · Cmd/Ctrl+M para fusionar con el siguiente · Cmd/Ctrl+Mayús+M para fusionar con el anterior",
|
||||
"orig_label": "origen",
|
||||
"restore_title": "Restaurar texto original",
|
||||
"lang_default": "(Definitivamente)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "Editar dirección…",
|
||||
"split_label": "Dividir en el cursor",
|
||||
"merge_label": "Fusionarse con el siguiente",
|
||||
"merge_prev_label": "Fusionarse con el anterior",
|
||||
"insert_label": "Insertar línea debajo",
|
||||
"direction_title": "Dirección: {{dir}}",
|
||||
"more_actions_title": "Más acciones",
|
||||
"speaker_pick": "Elige…",
|
||||
"speaker_title_detected": "Altavoz: elija uno de los detectados o escriba un nombre personalizado",
|
||||
"speaker_title_custom": "Orador: escriba un nombre (no se detectaron clones de diarización)",
|
||||
"time_edit_title": "Haga clic para editar la hora de inicio (m:ss.s). Ingrese para confirmar, Esc para cancelar.",
|
||||
"time_edit_end_title": "Haga clic para editar la hora de finalización (m:ss.s). Ingrese para confirmar, Esc para cancelar.",
|
||||
"fit_fits": "Se adapta",
|
||||
"fit_fits_title": "El audio de velocidad natural cabe dentro de la ranura.",
|
||||
"fit_overflows": "Se desborda +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "editor de segmentos",
|
||||
"seg_split": "Dividir segmento en el cursor",
|
||||
"seg_merge": "Fusionarse con el siguiente segmento",
|
||||
"seg_merge_prev": "Fusionarse con el segmento anterior",
|
||||
"seg_undo": "Deshacer",
|
||||
"seg_redo": "Rehacer",
|
||||
"seg_click": "acción primaria",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "Error: {{message}}",
|
||||
"ignored_unsupported": "Instrucción no admitida ignorada: {{items}}",
|
||||
"ignored_duplicate": "Ignorado (categoría ya establecida): {{items}}",
|
||||
"ref_audio_unusable": "No se detectó voz en el audio de referencia: el clip está en silencio o casi en silencio, así que no hay voz que clonar. Graba de nuevo más cerca del micrófono (comprueba que está seleccionado el dispositivo de entrada correcto y que el nivel se mueve) o elige otro clip."
|
||||
"ref_audio_unusable": "No se detectó voz en el audio de referencia: el clip está en silencio o casi en silencio, así que no hay voz que clonar. Graba de nuevo más cerca del micrófono (comprueba que está seleccionado el dispositivo de entrada correcto y que el nivel se mueve) o elige otro clip.",
|
||||
"ref_audio_too_long": "Una referencia puede durar como máximo 20 segundos con transcripción o 75 segundos sin ella. Recórtala a un fragmento de voz claro de 3–10 segundos; si aportas texto, recorta la transcripción al mismo fragmento.",
|
||||
"ref_audio_no_speech": "La detección automática no encontró palabras habladas en la referencia. Recórtala a un fragmento de voz claro de 3–10 segundos o proporciona una transcripción coincidente."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Compartir y acceso remoto",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "Eliminar modelo de voz",
|
||||
"engine_unavailable": "El motor de dictado en vivo no está disponible en esta instalación. El dictado vuelve a la ruta de transcripción estándar.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Recomendado. Dictado rápido y preciso en 25 idiomas europeos.",
|
||||
"sherpa-parakeet-tdt-v3": "Dictado rápido y preciso en 25 idiomas europeos.",
|
||||
"sherpa-parakeet-tdt-v2": "Dictado rápido y preciso solo en inglés.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming chino + inglés con parciales en vivo.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming chino + inglés, modelo compacto.",
|
||||
"sherpa-zipformer-en-20m": "Pequeño modelo de transmisión en inglés: latencia más baja.",
|
||||
"sherpa-zipformer-zh-14m": "Pequeño modelo chino de transmisión: latencia más baja.",
|
||||
"sherpa-whisper-tiny": "Multilingüe (más de 90 idiomas) con detección automática de idioma."
|
||||
"sherpa-whisper-tiny": "Recomendado. Dictado multilingüe en más de 90 idiomas con detección automática del idioma."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transcription…",
|
||||
"a11y_setup": "Autorisez l'accessibilité pour que la dictée puisse écrire à votre place",
|
||||
"pasted": "Collé",
|
||||
"inserted": "Inséré",
|
||||
"copied": "Copié dans le presse-papiers",
|
||||
"no_speech": "Aucune parole détectée",
|
||||
"mic_denied": "Accès au micro refusé",
|
||||
"mic_denied_toast": "Accès au microphone refusé. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "Chaque ligne est clonée depuis un extrait de son propre audio source. Meilleure prosodie par ligne, mais l'identité de la voix peut dériver d'une ligne à l'autre.",
|
||||
"voice_match_consistent": "Cohérente",
|
||||
"voice_match_consistent_title": "Toutes les lignes d'un locuteur sont clonées depuis une référence partagée (le clone du locuteur, ou le meilleur extrait unique à défaut). Identité vocale plus stable sur tout le doublage.",
|
||||
"timing_lip_sync": "Synchronisation labiale",
|
||||
"default_track": "Piste par défaut :",
|
||||
"original_track": "Originale",
|
||||
"selected_dub": "{{code}} (doublage sélectionné)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "Erreur de traduction : {{error}}",
|
||||
"translate_degraded_title": "Traduit (brut) — la passe de polissage a été ignorée : {{reason}}",
|
||||
"budget_title": "Le texte représente {{pct}} % de l'original – envisagez une vitesse plus élevée ou une formulation plus courte",
|
||||
"text_title": "Ctrl+D pour diviser au niveau du curseur · Ctrl+M pour fusionner avec le suivant",
|
||||
"text_title": "Cmd/Ctrl+D pour diviser au niveau du curseur · Cmd/Ctrl+M pour fusionner avec le suivant · Cmd/Ctrl+Maj+M pour fusionner avec le précédent",
|
||||
"orig_label": "orig",
|
||||
"restore_title": "Restaurer le texte original",
|
||||
"lang_default": "(Déf)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "Modifier la direction…",
|
||||
"split_label": "Diviser au curseur",
|
||||
"merge_label": "Fusionner avec le suivant",
|
||||
"merge_prev_label": "Fusionner avec le précédent",
|
||||
"insert_label": "Insérer une ligne en dessous",
|
||||
"direction_title": "Direction : {{dir}}",
|
||||
"more_actions_title": "Plus de propositions",
|
||||
"more_actions_title": "Plus d’actions",
|
||||
"speaker_pick": "Choisissez…",
|
||||
"speaker_title_detected": "Haut-parleur : choisissez parmi ceux détectés ou saisissez un nom personnalisé",
|
||||
"speaker_title_custom": "Haut-parleur : saisissez un nom (aucun clone de diarisation détecté)",
|
||||
"time_edit_title": "Cliquez pour modifier l'heure de début (m:ss.s). Entrez pour valider, Esc pour annuler.",
|
||||
"time_edit_end_title": "Cliquez pour modifier l'heure de fin (m:ss.s). Entrez pour valider, Esc pour annuler.",
|
||||
"fit_fits": "Convient",
|
||||
"fit_fits_title": "L'audio à débit naturel s'adapte à l'intérieur de la fente.",
|
||||
"fit_overflows": "Débordements +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "Editeur de segments",
|
||||
"seg_split": "Diviser le segment au niveau du curseur",
|
||||
"seg_merge": "Fusionner avec le segment suivant",
|
||||
"seg_merge_prev": "Fusionner avec le segment précédent",
|
||||
"seg_undo": "Annuler",
|
||||
"seg_redo": "Refaire",
|
||||
"seg_click": "Action primaire",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "Erreur : {{message}}",
|
||||
"ignored_unsupported": "Instruction non prise en charge ignorée : {{items}}",
|
||||
"ignored_duplicate": "Ignoré (catégorie déjà définie) : {{items}}",
|
||||
"ref_audio_unusable": "Aucune voix n'a été détectée dans l'audio de référence — le clip est silencieux ou presque, il n'y a donc aucune voix à cloner. Réenregistrez plus près du micro (vérifiez que le bon périphérique d'entrée est sélectionné et que le niveau bouge) ou choisissez un autre clip."
|
||||
"ref_audio_unusable": "Aucune voix n'a été détectée dans l'audio de référence — le clip est silencieux ou presque, il n'y a donc aucune voix à cloner. Réenregistrez plus près du micro (vérifiez que le bon périphérique d'entrée est sélectionné et que le niveau bouge) ou choisissez un autre clip.",
|
||||
"ref_audio_too_long": "Une référence peut durer au maximum 20 secondes avec transcription, ou 75 secondes sans transcription. Coupez-la sur un passage vocal clair de 3 à 10 secondes ; si vous fournissez du texte, coupez la transcription sur le même passage.",
|
||||
"ref_audio_no_speech": "La détection automatique n'a trouvé aucune parole dans la référence. Coupez-la sur un passage vocal clair de 3 à 10 secondes ou fournissez une transcription correspondante."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Partage et accès à distance",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "Supprimer le modèle vocal",
|
||||
"engine_unavailable": "Le moteur de dictée en direct n'est pas disponible sur cette installation. La dictée revient au chemin de transcription standard.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Recommandé. Dictée rapide et précise dans 25 langues européennes.",
|
||||
"sherpa-parakeet-tdt-v3": "Dictée rapide et précise dans 25 langues européennes.",
|
||||
"sherpa-parakeet-tdt-v2": "Dictée rapide et précise en anglais uniquement.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming chinois + anglais avec partiels en direct.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming chinois + anglais, modèle compact.",
|
||||
"sherpa-zipformer-en-20m": "Petit modèle anglais de streaming – latence la plus faible.",
|
||||
"sherpa-zipformer-zh-14m": "Petit modèle chinois de streaming – latence la plus faible.",
|
||||
"sherpa-whisper-tiny": "Multilingue (plus de 90 langues) avec détection automatique de la langue."
|
||||
"sherpa-whisper-tiny": "Recommandé. Dictée multilingue dans plus de 90 langues avec détection automatique de la langue."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "प्रतिलेखन...",
|
||||
"a11y_setup": "एक्सेसिबिलिटी की अनुमति दें ताकि श्रुतलेख आपके लिए टाइप कर सके",
|
||||
"pasted": "चिपकाया गया",
|
||||
"inserted": "दर्ज किया गया",
|
||||
"copied": "क्लिपबोर्ड पर कॉपी किया गया",
|
||||
"no_speech": "कोई भाषण नहीं मिला",
|
||||
"mic_denied": "माइक का उपयोग अस्वीकृत",
|
||||
"mic_denied_toast": "माइक्रोफ़ोन पहुंच अस्वीकृत. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "हर लाइन अपने ही स्रोत ऑडियो की क्लिप से क्लोन होती है। हर लाइन का लहजा सबसे अच्छा मिलता है, पर आवाज़ की पहचान लाइन-दर-लाइन बदल सकती है।",
|
||||
"voice_match_consistent": "एकरूप",
|
||||
"voice_match_consistent_title": "एक वक्ता की सभी लाइनें एक साझा संदर्भ से क्लोन होती हैं (वक्ता क्लोन, या न होने पर सबसे अच्छी एक क्लिप)। पूरे डब में आवाज़ की पहचान अधिक स्थिर रहती है।",
|
||||
"timing_lip_sync": "होंठ समन्वय",
|
||||
"default_track": "डिफ़ॉल्ट ट्रैक:",
|
||||
"original_track": "मौलिक",
|
||||
"selected_dub": "{{code}} (चयनित डब)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "अनुवाद त्रुटि: {{error}}",
|
||||
"translate_degraded_title": "अनुवादित (सादा) — परिष्करण चरण छोड़ा गया: {{reason}}",
|
||||
"budget_title": "पाठ मूल का {{pct}}% है - उच्च गति या छोटे वाक्यांश पर विचार करें",
|
||||
"text_title": "कर्सर पर विभाजित करने के लिए Ctrl+D · अगले के साथ विलय करने के लिए Ctrl+M",
|
||||
"text_title": "कर्सर पर विभाजित करने के लिए Cmd/Ctrl+D · अगले के साथ विलय करने के लिए Cmd/Ctrl+M · पिछले के साथ विलय करने के लिए Cmd/Ctrl+Shift+M",
|
||||
"orig_label": "मूल",
|
||||
"restore_title": "मूल पाठ पुनर्स्थापित करें",
|
||||
"lang_default": "(डीईएफ़)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "दिशा संपादित करें...",
|
||||
"split_label": "कर्सर पर विभाजित करें",
|
||||
"merge_label": "अगले के साथ विलय करें",
|
||||
"merge_prev_label": "पिछले के साथ विलय करें",
|
||||
"insert_label": "नीचे पंक्ति सम्मिलित करें",
|
||||
"direction_title": "दिशा: {{dir}}",
|
||||
"more_actions_title": "अधिक क्रियाएं",
|
||||
"speaker_pick": "उठाओ...",
|
||||
"speaker_title_detected": "स्पीकर - पता लगाए गए में से चुनें, या एक कस्टम नाम टाइप करें",
|
||||
"speaker_title_custom": "स्पीकर - एक नाम टाइप करें (कोई डायराइज़ेशन क्लोन नहीं पाया गया)",
|
||||
"time_edit_title": "प्रारंभ समय (m:ss.s) संपादित करने के लिए क्लिक करें। प्रतिबद्ध करने के लिए दर्ज करें, रद्द करने के लिए Esc।",
|
||||
"time_edit_end_title": "समाप्ति समय (m:ss.s) संपादित करने के लिए क्लिक करें। प्रतिबद्ध करने के लिए दर्ज करें, रद्द करने के लिए Esc।",
|
||||
"fit_fits": "फिट बैठता है",
|
||||
"fit_fits_title": "प्राकृतिक दर वाला ऑडियो स्लॉट के अंदर फ़िट हो जाता है।",
|
||||
"fit_overflows": "अतिप्रवाह +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "खंड संपादक",
|
||||
"seg_split": "कर्सर पर खंड विभाजित करें",
|
||||
"seg_merge": "अगले खंड के साथ विलय करें",
|
||||
"seg_merge_prev": "पिछले खंड के साथ विलय करें",
|
||||
"seg_undo": "पूर्ववत करें",
|
||||
"seg_redo": "पुनः करें",
|
||||
"seg_click": "प्राथमिक क्रिया",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "त्रुटि: {{message}}",
|
||||
"ignored_unsupported": "अनदेखा असमर्थित निर्देश: {{items}}",
|
||||
"ignored_duplicate": "अनदेखा (श्रेणी पहले से ही सेट): {{items}}",
|
||||
"ref_audio_unusable": "संदर्भ ऑडियो में कोई आवाज़ सुनाई नहीं दी — क्लिप मौन या लगभग मौन है, इसलिए क्लोन करने के लिए कोई आवाज़ नहीं है। माइक्रोफ़ोन के और पास से दोबारा रिकॉर्ड करें (जांचें कि सही इनपुट डिवाइस चुना गया है) या कोई दूसरी क्लिप चुनें।"
|
||||
"ref_audio_unusable": "संदर्भ ऑडियो में कोई आवाज़ सुनाई नहीं दी — क्लिप मौन या लगभग मौन है, इसलिए क्लोन करने के लिए कोई आवाज़ नहीं है। माइक्रोफ़ोन के और पास से दोबारा रिकॉर्ड करें (जांचें कि सही इनपुट डिवाइस चुना गया है) या कोई दूसरी क्लिप चुनें।",
|
||||
"ref_audio_too_long": "ट्रांसक्रिप्ट के साथ संदर्भ अधिकतम 20 सेकंड और उसके बिना अधिकतम 75 सेकंड का हो सकता है। इसे 3–10 सेकंड के स्पष्ट भाषण अंश तक काटें; टेक्स्ट देने पर ट्रांसक्रिप्ट को भी उसी अंश तक काटें।",
|
||||
"ref_audio_no_speech": "स्वचालित पहचान को संदर्भ में बोले गए शब्द नहीं मिले। इसे 3–10 सेकंड के स्पष्ट भाषण अंश तक काटें या मेल खाता ट्रांसक्रिप्ट दें।"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "साझाकरण और रिमोट एक्सेस",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "वाक् मॉडल हटाएँ",
|
||||
"engine_unavailable": "इस इंस्टाल पर लाइव-डिक्टेशन इंजन उपलब्ध नहीं है। श्रुतलेखन मानक प्रतिलेखन पथ पर वापस आ जाता है।",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "अनुशंसित. 25 यूरोपीय भाषाओं में तेज़, सटीक श्रुतलेख।",
|
||||
"sherpa-parakeet-tdt-v3": "25 यूरोपीय भाषाओं में तेज़ और सटीक श्रुतलेख।",
|
||||
"sherpa-parakeet-tdt-v2": "तेज़, सटीक केवल अंग्रेज़ी श्रुतलेख।",
|
||||
"sherpa-zipformer-bilingual-zh-en": "लाइव आंशिक भाग के साथ चीनी + अंग्रेजी स्ट्रीमिंग।",
|
||||
"sherpa-paraformer-bilingual-zh-en": "स्ट्रीमिंग चीनी + अंग्रेजी, कॉम्पैक्ट मॉडल।",
|
||||
"sherpa-zipformer-en-20m": "छोटा स्ट्रीमिंग अंग्रेजी मॉडल - सबसे कम विलंबता।",
|
||||
"sherpa-zipformer-zh-14m": "छोटा स्ट्रीमिंग चीनी मॉडल - सबसे कम विलंबता।",
|
||||
"sherpa-whisper-tiny": "ऑटो भाषा पहचान के साथ बहुभाषी (90+ भाषाएँ)।"
|
||||
"sherpa-whisper-tiny": "अनुशंसित। स्वचालित भाषा पहचान के साथ 90 से अधिक भाषाओं में बहुभाषी श्रुतलेख।"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Mentranskripsikan…",
|
||||
"a11y_setup": "Izinkan Aksesibilitas agar dikte dapat mengetik untuk Anda",
|
||||
"pasted": "Ditempel",
|
||||
"inserted": "Dimasukkan",
|
||||
"copied": "Disalin ke papan klip",
|
||||
"no_speech": "Tidak ada ucapan yang terdeteksi",
|
||||
"mic_denied": "Akses mikrofon ditolak",
|
||||
"mic_denied_toast": "Akses mikrofon ditolak. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "Setiap baris dikloning dari klip audio sumbernya sendiri. Prosodi per baris paling cocok, tetapi identitas suara bisa bergeser antarbaris.",
|
||||
"voice_match_consistent": "Konsisten",
|
||||
"voice_match_consistent_title": "Semua baris satu pembicara dikloning dari satu referensi bersama (klon pembicara, atau satu klip terbaik jika tidak ada). Identitas suara lebih stabil di seluruh sulih suara.",
|
||||
"timing_lip_sync": "Sinkronisasi bibir",
|
||||
"default_track": "Lagu Bawaan:",
|
||||
"original_track": "Asli",
|
||||
"selected_dub": "{{code}} (Suara yang Dipilih)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "Kesalahan terjemahan: {{error}}",
|
||||
"translate_degraded_title": "Diterjemahkan (biasa) — tahap penyempurnaan dilewati: {{reason}}",
|
||||
"budget_title": "Teks {{pct}}% dari aslinya — pertimbangkan kecepatan yang lebih tinggi atau frasa yang lebih pendek",
|
||||
"text_title": "Ctrl+D untuk memisahkan kursor · Ctrl+M untuk menggabungkan dengan yang berikutnya",
|
||||
"text_title": "Cmd/Ctrl+D untuk memisahkan kursor · Cmd/Ctrl+M untuk menggabungkan dengan yang berikutnya · Cmd/Ctrl+Shift+M untuk menggabungkan dengan yang sebelumnya",
|
||||
"orig_label": "asal",
|
||||
"restore_title": "Kembalikan teks asli",
|
||||
"lang_default": "(Tentu)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "Edit arah…",
|
||||
"split_label": "Pisahkan di kursor",
|
||||
"merge_label": "Gabungkan dengan berikutnya",
|
||||
"merge_prev_label": "Gabungkan dengan sebelumnya",
|
||||
"insert_label": "Sisipkan baris di bawah",
|
||||
"direction_title": "Arah: {{dir}}",
|
||||
"more_actions_title": "Lebih banyak tindakan",
|
||||
"speaker_pick": "Pilih…",
|
||||
"speaker_title_detected": "Speaker — pilih dari yang terdeteksi, atau ketikkan nama khusus",
|
||||
"speaker_title_custom": "Pembicara — ketikkan nama (tidak ada klon diarisasi yang terdeteksi)",
|
||||
"time_edit_title": "Klik untuk mengedit waktu mulai (m:ss.s). Enter untuk melakukan, Esc untuk membatalkan.",
|
||||
"time_edit_end_title": "Klik untuk mengedit waktu selesai (m:ss.s). Enter untuk melakukan, Esc untuk membatalkan.",
|
||||
"fit_fits": "Cocok",
|
||||
"fit_fits_title": "Audio dengan kecepatan alami pas di dalam slot.",
|
||||
"fit_overflows": "Meluap +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "Editor segmen",
|
||||
"seg_split": "Pisahkan segmen di kursor",
|
||||
"seg_merge": "Gabungkan dengan segmen berikutnya",
|
||||
"seg_merge_prev": "Gabungkan dengan segmen sebelumnya",
|
||||
"seg_undo": "Membatalkan",
|
||||
"seg_redo": "Ulangi",
|
||||
"seg_click": "Tindakan utama",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "Kesalahan: {{message}}",
|
||||
"ignored_unsupported": "Mengabaikan instruksi yang tidak didukung: {{items}}",
|
||||
"ignored_duplicate": "Diabaikan (kategori sudah ditetapkan): {{items}}",
|
||||
"ref_audio_unusable": "Tidak ada suara yang terdengar di audio referensi — klipnya senyap atau hampir senyap, jadi tidak ada suara untuk dikloning. Rekam ulang lebih dekat ke mikrofon (pastikan perangkat input yang benar dipilih dan meterannya bergerak), atau pilih klip lain."
|
||||
"ref_audio_unusable": "Tidak ada suara yang terdengar di audio referensi — klipnya senyap atau hampir senyap, jadi tidak ada suara untuk dikloning. Rekam ulang lebih dekat ke mikrofon (pastikan perangkat input yang benar dipilih dan meterannya bergerak), atau pilih klip lain.",
|
||||
"ref_audio_too_long": "Referensi maksimal 20 detik dengan transkrip atau 75 detik tanpa transkrip. Potong menjadi bagian ucapan jelas sepanjang 3–10 detik; jika menyertakan teks, potong transkrip ke bagian yang sama.",
|
||||
"ref_audio_no_speech": "Deteksi otomatis tidak menemukan kata yang diucapkan dalam referensi. Potong menjadi bagian ucapan jelas sepanjang 3–10 detik atau sertakan transkrip yang cocok."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Berbagi & Akses Jarak Jauh",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "Hapus model ucapan",
|
||||
"engine_unavailable": "Mesin pendiktean langsung tidak tersedia pada instalasi ini. Dikte kembali ke jalur transkripsi standar.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Direkomendasikan. Dikte yang cepat dan akurat dalam 25 bahasa Eropa.",
|
||||
"sherpa-parakeet-tdt-v3": "Dikte cepat dan akurat dalam 25 bahasa Eropa.",
|
||||
"sherpa-parakeet-tdt-v2": "Dikte khusus bahasa Inggris yang cepat dan akurat.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming bahasa Mandarin + Inggris dengan siaran langsung sebagian.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming bahasa Mandarin + Inggris, model ringkas.",
|
||||
"sherpa-zipformer-en-20m": "Model streaming bahasa Inggris yang kecil — latensi terendah.",
|
||||
"sherpa-zipformer-zh-14m": "Model streaming kecil Tiongkok — latensi terendah.",
|
||||
"sherpa-whisper-tiny": "Multibahasa (90+ bahasa) dengan deteksi bahasa otomatis."
|
||||
"sherpa-whisper-tiny": "Direkomendasikan. Dikte multibahasa dalam lebih dari 90 bahasa dengan deteksi bahasa otomatis."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Trascrizione…",
|
||||
"a11y_setup": "Consenti Accessibilità così la dettatura può digitare per te",
|
||||
"pasted": "Incollato",
|
||||
"inserted": "Inserito",
|
||||
"copied": "Copiato negli appunti",
|
||||
"no_speech": "Nessun parlato rilevato",
|
||||
"mic_denied": "Accesso al microfono negato",
|
||||
"mic_denied_toast": "Accesso al microfono negato. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "Ogni battuta viene clonata da un clip del proprio audio sorgente. Migliore prosodia per battuta, ma l'identità della voce può variare da battuta a battuta.",
|
||||
"voice_match_consistent": "Coerente",
|
||||
"voice_match_consistent_title": "Tutte le battute di un parlante vengono clonate da un riferimento condiviso (il clone del parlante o il miglior clip singolo se assente). Identità vocale più stabile in tutto il doppiaggio.",
|
||||
"timing_lip_sync": "Sincronizzazione labiale",
|
||||
"default_track": "Traccia predefinita:",
|
||||
"original_track": "Originale",
|
||||
"selected_dub": "{{code}} (duplicazione selezionata)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "Errore di traduzione: {{error}}",
|
||||
"translate_degraded_title": "Tradotto (semplice) — rifinitura saltata: {{reason}}",
|
||||
"budget_title": "Il testo è il {{pct}}% dell'originale: considera una velocità maggiore o una frase più breve",
|
||||
"text_title": "Ctrl+D per dividere in corrispondenza del cursore · Ctrl+M per unire con il successivo",
|
||||
"text_title": "Cmd/Ctrl+D per dividere in corrispondenza del cursore · Cmd/Ctrl+M per unire con il successivo · Cmd/Ctrl+Maiusc+M per unire con il precedente",
|
||||
"orig_label": "orig",
|
||||
"restore_title": "Ripristina il testo originale",
|
||||
"lang_default": "(Dif)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "Modifica direzione...",
|
||||
"split_label": "Dividi al cursore",
|
||||
"merge_label": "Unisci con il successivo",
|
||||
"merge_prev_label": "Unisci con il precedente",
|
||||
"insert_label": "Inserisci riga sotto",
|
||||
"direction_title": "Direzione: {{dir}}",
|
||||
"more_actions_title": "Più azioni",
|
||||
"speaker_pick": "Scegli…",
|
||||
"speaker_title_detected": "Altoparlante: scegli tra quelli rilevati o digita un nome personalizzato",
|
||||
"speaker_title_custom": "Altoparlante: digita un nome (nessun clone di diarizzazione rilevato)",
|
||||
"time_edit_title": "Fare clic per modificare l'ora di inizio (m:ss.s). Invio per confermare, Esc per annullare.",
|
||||
"time_edit_end_title": "Fare clic per modificare l'ora di fine (m:ss.s). Invio per confermare, Esc per annullare.",
|
||||
"fit_fits": "Adatto",
|
||||
"fit_fits_title": "L'audio a velocità naturale si adatta all'interno dello slot.",
|
||||
"fit_overflows": "Overflow +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "Redattore di segmenti",
|
||||
"seg_split": "Segmento diviso in corrispondenza del cursore",
|
||||
"seg_merge": "Unisci con il segmento successivo",
|
||||
"seg_merge_prev": "Unisci con il segmento precedente",
|
||||
"seg_undo": "Annulla",
|
||||
"seg_redo": "Rifare",
|
||||
"seg_click": "Azione primaria",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "Errore: {{message}}",
|
||||
"ignored_unsupported": "Istruzione non supportata ignorata: {{items}}",
|
||||
"ignored_duplicate": "Ignorato (categoria già impostata): {{items}}",
|
||||
"ref_audio_unusable": "Nessuna voce rilevata nell'audio di riferimento: la clip è silenziosa o quasi, quindi non c'è alcuna voce da clonare. Registra di nuovo più vicino al microfono (verifica che sia selezionato il dispositivo di ingresso corretto e che il livello si muova) oppure scegli un'altra clip."
|
||||
"ref_audio_unusable": "Nessuna voce rilevata nell'audio di riferimento: la clip è silenziosa o quasi, quindi non c'è alcuna voce da clonare. Registra di nuovo più vicino al microfono (verifica che sia selezionato il dispositivo di ingresso corretto e che il livello si muova) oppure scegli un'altra clip.",
|
||||
"ref_audio_too_long": "Un riferimento può durare al massimo 20 secondi con trascrizione o 75 secondi senza. Taglialo a un passaggio parlato chiaro di 3–10 secondi; se fornisci il testo, taglia la trascrizione sullo stesso passaggio.",
|
||||
"ref_audio_no_speech": "Il rilevamento automatico non ha trovato parole pronunciate nel riferimento. Taglialo a un passaggio parlato chiaro di 3–10 secondi o fornisci una trascrizione corrispondente."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Condivisione e accesso remoto",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "Elimina modello vocale",
|
||||
"engine_unavailable": "Il motore di dettatura dal vivo non è disponibile su questa installazione. La dettatura ritorna al percorso di trascrizione standard.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Consigliato. Dettatura rapida e accurata in 25 lingue europee.",
|
||||
"sherpa-parakeet-tdt-v3": "Dettatura rapida e accurata in 25 lingue europee.",
|
||||
"sherpa-parakeet-tdt-v2": "Dettatura veloce e accurata solo in inglese.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming cinese + inglese con parziali dal vivo.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming cinese + inglese, modello compatto.",
|
||||
"sherpa-zipformer-en-20m": "Piccolo modello inglese di streaming: latenza più bassa.",
|
||||
"sherpa-zipformer-zh-14m": "Piccolo modello cinese in streaming: latenza più bassa.",
|
||||
"sherpa-whisper-tiny": "Multilingue (oltre 90 lingue) con rilevamento automatico della lingua."
|
||||
"sherpa-whisper-tiny": "Consigliato. Dettatura multilingue in oltre 90 lingue con rilevamento automatico della lingua."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "文字起こし中…",
|
||||
"a11y_setup": "音声入力が代わりに入力できるよう、アクセシビリティを許可してください",
|
||||
"pasted": "貼り付けた",
|
||||
"inserted": "入力しました",
|
||||
"copied": "クリップボードにコピーしました",
|
||||
"no_speech": "音声が検出されませんでした",
|
||||
"mic_denied": "マイクアクセスが拒否されました",
|
||||
"mic_denied_toast": "マイクへのアクセスが拒否されました。 {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "各セリフを自身のソース音声のクリップからクローンします。行ごとの抑揚は最も合いますが、声の同一性が行ごとにぶれることがあります。",
|
||||
"voice_match_consistent": "一貫",
|
||||
"voice_match_consistent_title": "話者の全セリフを1つの共有参照(話者クローン、なければ最良の1クリップ)からクローンします。吹き替え全体で声の同一性が安定します。",
|
||||
"timing_lip_sync": "リップシンク",
|
||||
"default_track": "デフォルトのトラック:",
|
||||
"original_track": "オリジナル",
|
||||
"selected_dub": "{{code}} (選択されたダブ)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "翻訳エラー: {{error}}",
|
||||
"translate_degraded_title": "翻訳済み(通常)— 仕上げ処理はスキップされました: {{reason}}",
|
||||
"budget_title": "テキストはオリジナルの {{pct}}% です — 高速化または短い表現を検討してください",
|
||||
"text_title": "Ctrl+D でカーソル位置で分割、Ctrl+M で次のカーソルとマージ",
|
||||
"text_title": "Cmd/Ctrl+D でカーソル位置で分割、Cmd/Ctrl+M で次と結合、Cmd/Ctrl+Shift+M で前と結合",
|
||||
"orig_label": "元の",
|
||||
"restore_title": "元のテキストを復元する",
|
||||
"lang_default": "(防御)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "方向を編集…",
|
||||
"split_label": "カーソル位置で分割",
|
||||
"merge_label": "次と結合",
|
||||
"merge_prev_label": "前と結合",
|
||||
"insert_label": "下に行を挿入",
|
||||
"direction_title": "方向: {{dir}}",
|
||||
"more_actions_title": "さらなるアクション",
|
||||
"speaker_pick": "選んでください…",
|
||||
"speaker_title_detected": "スピーカー — 検出されたスピーカーから選択するか、カスタム名を入力します",
|
||||
"speaker_title_custom": "スピーカー — 名前を入力します (ダイアライゼーション クローンは検出されません)。",
|
||||
"time_edit_title": "クリックして開始時刻 (分:ss.s) を編集します。 Enter を押してコミットし、Esc を押してキャンセルします。",
|
||||
"time_edit_end_title": "クリックして終了時刻 (分:ss.s) を編集します。 Enter を押してコミットし、Esc を押してキャンセルします。",
|
||||
"fit_fits": "適合",
|
||||
"fit_fits_title": "ナチュラルレートのオーディオがスロット内に収まります。",
|
||||
"fit_overflows": "オーバーフロー +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "セグメントエディター",
|
||||
"seg_split": "カーソル位置でセグメントを分割",
|
||||
"seg_merge": "次のセグメントと結合",
|
||||
"seg_merge_prev": "前のセグメントと結合",
|
||||
"seg_undo": "元に戻す",
|
||||
"seg_redo": "やり直し",
|
||||
"seg_click": "主なアクション",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "エラー: {{message}}",
|
||||
"ignored_unsupported": "サポートされていない命令は無視されました: {{items}}",
|
||||
"ignored_duplicate": "無視 (カテゴリはすでに設定されています): {{items}}",
|
||||
"ref_audio_unusable": "リファレンス音声から声を検出できませんでした。クリップが無音またはほぼ無音のため、クローンする声がありません。マイクに近づいて録音し直すか(正しい入力デバイスが選択されているか確認してください)、別のクリップを選んでください。"
|
||||
"ref_audio_unusable": "リファレンス音声から声を検出できませんでした。クリップが無音またはほぼ無音のため、クローンする声がありません。マイクに近づいて録音し直すか(正しい入力デバイスが選択されているか確認してください)、別のクリップを選んでください。",
|
||||
"ref_audio_too_long": "参照音声は文字起こし付きなら最長20秒、なしなら最長75秒です。明瞭な発話を含む3〜10秒に切り詰め、テキストを指定する場合は文字起こしも同じ区間に合わせてください。",
|
||||
"ref_audio_no_speech": "自動音声検出で参照音声から発話が見つかりませんでした。明瞭な発話を含む3〜10秒に切り詰めるか、一致する文字起こしを指定してください。"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "共有とリモートアクセス",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "音声モデルの削除",
|
||||
"engine_unavailable": "このインストールではライブディクテーション エンジンは利用できません。ディクテーションは標準の文字起こしパスにフォールバックします。",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "おすすめです。 25 のヨーロッパ言語にわたる高速かつ正確なディクテーション。",
|
||||
"sherpa-parakeet-tdt-v3": "25のヨーロッパ言語に対応した高速で正確な音声入力。",
|
||||
"sherpa-parakeet-tdt-v2": "高速かつ正確な英語のみのディクテーション。",
|
||||
"sherpa-zipformer-bilingual-zh-en": "中国語 + 英語のライブ部分をストリーミングします。",
|
||||
"sherpa-paraformer-bilingual-zh-en": "中国語+英語ストリーミング、コンパクトモデル。",
|
||||
"sherpa-zipformer-en-20m": "小さなストリーミング英語モデル — レイテンシが最も低い。",
|
||||
"sherpa-zipformer-zh-14m": "小さなストリーミング中国語モデル - 遅延が最も低い。",
|
||||
"sherpa-whisper-tiny": "自動言語検出機能を備えた多言語 (90 以上の言語)。"
|
||||
"sherpa-whisper-tiny": "おすすめ。90以上の言語に対応し、言語を自動検出する多言語音声入力。"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "스크립트 작성 중…",
|
||||
"a11y_setup": "받아쓰기가 대신 입력할 수 있도록 손쉬운 사용을 허용하세요",
|
||||
"pasted": "붙여넣음",
|
||||
"inserted": "입력됨",
|
||||
"copied": "클립보드에 복사됨",
|
||||
"no_speech": "음성이 감지되지 않았습니다.",
|
||||
"mic_denied": "마이크 액세스가 거부되었습니다.",
|
||||
"mic_denied_toast": "마이크 액세스가 거부되었습니다. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "각 대사를 자신의 원본 오디오 클립에서 복제합니다. 대사별 억양은 가장 잘 맞지만 목소리 정체성이 대사마다 달라질 수 있습니다.",
|
||||
"voice_match_consistent": "일관",
|
||||
"voice_match_consistent_title": "한 화자의 모든 대사를 하나의 공유 참조(화자 클론, 없으면 최적의 단일 클립)에서 복제합니다. 더빙 전체에서 목소리가 더 안정적입니다.",
|
||||
"timing_lip_sync": "립싱크",
|
||||
"default_track": "기본 트랙:",
|
||||
"original_track": "원본",
|
||||
"selected_dub": "{{code}}(선택된 더빙)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "번역 오류: {{error}}",
|
||||
"translate_degraded_title": "번역됨 (기본) — 다듬기 단계를 건너뜀: {{reason}}",
|
||||
"budget_title": "텍스트가 원본의 {{pct}}%입니다. 더 빠른 속도나 더 짧은 문구를 고려하세요.",
|
||||
"text_title": "커서에서 분할하려면 Ctrl+D · 다음 항목으로 병합하려면 Ctrl+M",
|
||||
"text_title": "커서에서 분할하려면 Cmd/Ctrl+D · 다음 항목으로 병합하려면 Cmd/Ctrl+M · 이전 항목으로 병합하려면 Cmd/Ctrl+Shift+M",
|
||||
"orig_label": "원본",
|
||||
"restore_title": "원본 텍스트 복원",
|
||||
"lang_default": "(데프)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "방향 수정…",
|
||||
"split_label": "커서에서 분할",
|
||||
"merge_label": "다음과 병합",
|
||||
"merge_prev_label": "이전과 병합",
|
||||
"insert_label": "아래에 줄 삽입",
|
||||
"direction_title": "방향: {{dir}}",
|
||||
"more_actions_title": "추가 작업",
|
||||
"speaker_pick": "선택…",
|
||||
"speaker_title_detected": "스피커 — 감지된 스피커 중에서 선택하거나 사용자 정의 이름을 입력하세요.",
|
||||
"speaker_title_custom": "발표자 - 이름을 입력하세요(분할 클론이 감지되지 않음)",
|
||||
"time_edit_title": "시작 시간(m:ss.s)을 편집하려면 클릭하세요. 커밋하려면 Enter를, 취소하려면 Esc를 누르세요.",
|
||||
"time_edit_end_title": "종료 시간(m:ss.s)을 편집하려면 클릭하세요. 커밋하려면 Enter를, 취소하려면 Esc를 누르세요.",
|
||||
"fit_fits": "적합",
|
||||
"fit_fits_title": "자연스러운 속도의 오디오가 슬롯에 맞습니다.",
|
||||
"fit_overflows": "오버플로 +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "세그먼트 편집기",
|
||||
"seg_split": "커서에서 세그먼트 분할",
|
||||
"seg_merge": "다음 세그먼트와 병합",
|
||||
"seg_merge_prev": "이전 세그먼트와 병합",
|
||||
"seg_undo": "실행 취소",
|
||||
"seg_redo": "다시 실행",
|
||||
"seg_click": "기본 작업",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "오류: {{message}}",
|
||||
"ignored_unsupported": "지원되지 않는 지침을 무시했습니다: {{items}}",
|
||||
"ignored_duplicate": "무시됨(카테고리가 이미 설정됨): {{items}}",
|
||||
"ref_audio_unusable": "레퍼런스 오디오에서 음성이 감지되지 않았습니다. 클립이 무음이거나 거의 무음이라 복제할 목소리가 없습니다. 마이크에 더 가까이서 다시 녹음하거나(올바른 입력 장치가 선택되어 있는지 확인) 다른 클립을 선택하세요."
|
||||
"ref_audio_unusable": "레퍼런스 오디오에서 음성이 감지되지 않았습니다. 클립이 무음이거나 거의 무음이라 복제할 목소리가 없습니다. 마이크에 더 가까이서 다시 녹음하거나(올바른 입력 장치가 선택되어 있는지 확인) 다른 클립을 선택하세요.",
|
||||
"ref_audio_too_long": "참조 오디오는 텍스트가 있으면 최대 20초, 없으면 최대 75초까지 사용할 수 있습니다. 명확한 음성이 있는 3~10초 구간으로 자르고, 텍스트를 제공한다면 같은 구간에 맞추세요.",
|
||||
"ref_audio_no_speech": "자동 음성 감지에서 참조 오디오의 발화 단어를 찾지 못했습니다. 명확한 음성이 있는 3~10초 구간으로 자르거나 일치하는 텍스트를 제공하세요."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "공유 및 원격 액세스",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "음성 모델 삭제",
|
||||
"engine_unavailable": "The live-dictation engine isn't available on this install. Dictation falls back to the standard transcription path.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "추천합니다. Fast, accurate dictation across 25 European languages.",
|
||||
"sherpa-parakeet-tdt-v3": "25개 유럽 언어를 지원하는 빠르고 정확한 받아쓰기입니다.",
|
||||
"sherpa-parakeet-tdt-v2": "빠르고 정확한 영어 전용 받아쓰기.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "라이브 부분으로 중국어 + 영어 스트리밍.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "중국어 + 영어 스트리밍, 컴팩트 모델.",
|
||||
"sherpa-zipformer-en-20m": "작은 스트리밍 영어 모델 - 지연 시간이 가장 낮습니다.",
|
||||
"sherpa-zipformer-zh-14m": "작은 스트리밍 중국 모델 - 지연 시간이 가장 낮습니다.",
|
||||
"sherpa-whisper-tiny": "Multilingual (90+ languages) with auto language detection."
|
||||
"sherpa-whisper-tiny": "추천. 90개 이상의 언어를 지원하며 언어를 자동으로 감지하는 다국어 받아쓰기입니다."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"lines_one": "{{count}} regel",
|
||||
"lines_other": "{{count}} regels",
|
||||
"copy": "Kopiëren",
|
||||
"copied": "Gekonieerd!",
|
||||
"copied": "Gekopieerd!",
|
||||
"waiting_output": "Wachten op uitvoer…",
|
||||
"auto_detect": "Automatisch detecteren",
|
||||
"suggest_lang": "Overschakelen naar het Nederlands?",
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transcriberen…",
|
||||
"a11y_setup": "Sta Toegankelijkheid toe zodat dicteren voor je kan typen",
|
||||
"pasted": "Geplakt",
|
||||
"inserted": "Ingevoegd",
|
||||
"copied": "Gekopieerd naar klembord",
|
||||
"no_speech": "Geen spraak gedetecteerd",
|
||||
"mic_denied": "Microfoontoegang geweigerd",
|
||||
"mic_denied_toast": "Microfoontoegang geweigerd. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "Elke regel wordt gekloond uit een clip van zijn eigen bronaudio. Beste prosodie per regel, maar de stemidentiteit kan per regel verschuiven.",
|
||||
"voice_match_consistent": "Consistent",
|
||||
"voice_match_consistent_title": "Alle regels van een spreker worden gekloond uit één gedeelde referentie (de sprekerkloon, of de beste losse clip als die ontbreekt). Stabielere stemidentiteit door de hele dub.",
|
||||
"timing_lip_sync": "Lipsynchronisatie",
|
||||
"default_track": "Standaardnummer:",
|
||||
"original_track": "Origineel",
|
||||
"selected_dub": "{{code}} (geselecteerde kopie)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "Translation error: {{error}}",
|
||||
"translate_degraded_title": "Vertaald (gewoon) — polijststap overgeslagen: {{reason}}",
|
||||
"budget_title": "De tekst is {{pct}}% van het origineel. Overweeg een hogere snelheid of kortere formulering",
|
||||
"text_title": "Ctrl+D om te splitsen bij de cursor · Ctrl+M om samen te voegen met de volgende",
|
||||
"text_title": "Cmd/Ctrl+D om te splitsen bij de cursor · Cmd/Ctrl+M om samen te voegen met de volgende · Cmd/Ctrl+Shift+M om samen te voegen met de vorige",
|
||||
"orig_label": "oorsprong",
|
||||
"restore_title": "Originele tekst herstellen",
|
||||
"lang_default": "(Def)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "Richting bewerken…",
|
||||
"split_label": "Splitsen bij cursor",
|
||||
"merge_label": "Samenvoegen met volgende",
|
||||
"merge_prev_label": "Samenvoegen met vorige",
|
||||
"insert_label": "Regel hieronder invoegen",
|
||||
"direction_title": "Richting: {{dir}}",
|
||||
"more_actions_title": "Meer acties",
|
||||
"speaker_pick": "Kies…",
|
||||
"speaker_title_detected": "Spreker: kies uit de gedetecteerde naam of typ een aangepaste naam",
|
||||
"speaker_title_custom": "Spreker — typ een naam (geen dagboekklonen gedetecteerd)",
|
||||
"time_edit_title": "Klik om de starttijd te bewerken (m:ss.s). Enter om vast te leggen, Esc om te annuleren.",
|
||||
"time_edit_end_title": "Klik om de eindtijd te bewerken (m:ss.s). Enter om vast te leggen, Esc om te annuleren.",
|
||||
"fit_fits": "Past",
|
||||
"fit_fits_title": "Audio met natuurlijke snelheid past in de sleuf.",
|
||||
"fit_overflows": "Overstromen +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "Segmenteditor",
|
||||
"seg_split": "Segment splitsen bij cursor",
|
||||
"seg_merge": "Samenvoegen met het volgende segment",
|
||||
"seg_merge_prev": "Samenvoegen met het vorige segment",
|
||||
"seg_undo": "Ongedaan maken",
|
||||
"seg_redo": "Opnieuw uitvoeren",
|
||||
"seg_click": "Primaire actie",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "Fout: {{message}}",
|
||||
"ignored_unsupported": "Genegeerde niet-ondersteunde instructie: {{items}}",
|
||||
"ignored_duplicate": "Genegeerd (categorie al ingesteld): {{items}}",
|
||||
"ref_audio_unusable": "Er is geen spraak gehoord in de referentie-audio — de clip is stil of bijna stil, dus er is geen stem om te klonen. Neem opnieuw op dichter bij de microfoon (controleer of het juiste invoerapparaat is geselecteerd en het niveau beweegt) of kies een andere clip."
|
||||
"ref_audio_unusable": "Er is geen spraak gehoord in de referentie-audio — de clip is stil of bijna stil, dus er is geen stem om te klonen. Neem opnieuw op dichter bij de microfoon (controleer of het juiste invoerapparaat is geselecteerd en het niveau beweegt) of kies een andere clip.",
|
||||
"ref_audio_too_long": "Een referentie mag maximaal 20 seconden duren met transcript, of 75 seconden zonder transcript. Knip deze tot een duidelijke gesproken passage van 3–10 seconden; knip een meegeleverd transcript tot dezelfde passage.",
|
||||
"ref_audio_no_speech": "De automatische spraakdetectie vond geen gesproken woorden in de referentie. Knip deze tot een duidelijke gesproken passage van 3–10 seconden of geef een passend transcript op."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Delen en externe toegang",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "Spraakmodel verwijderen",
|
||||
"engine_unavailable": "De live-dicteerengine is niet beschikbaar bij deze installatie. Het dicteren valt terug op het standaard transcriptiepad.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Aanbevolen. Snel en nauwkeurig dicteren in 25 Europese talen.",
|
||||
"sherpa-parakeet-tdt-v3": "Snel en nauwkeurig dicteren in 25 Europese talen.",
|
||||
"sherpa-parakeet-tdt-v2": "Snel, nauwkeurig dicteren in het Engels.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming Chinees + Engels met live gedeeltelijke beelden.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming Chinees + Engels met live tussentijdse resultaten.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming Chinees + Engels, compact model.",
|
||||
"sherpa-zipformer-en-20m": "Klein streaming Engels model - laagste latentie.",
|
||||
"sherpa-zipformer-zh-14m": "Klein Chinees streamingmodel – laagste latentie.",
|
||||
"sherpa-whisper-tiny": "Meertalig (90+ talen) met automatische taaldetectie."
|
||||
"sherpa-whisper-tiny": "Aanbevolen. Meertalig dicteren in meer dan 90 talen met automatische taaldetectie."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transkrypcja…",
|
||||
"a11y_setup": "Zezwól na Dostępność, aby dyktowanie mogło pisać za Ciebie",
|
||||
"pasted": "Wklejony",
|
||||
"inserted": "Wstawiono",
|
||||
"copied": "Skopiowano do schowka",
|
||||
"no_speech": "Nie wykryto mowy",
|
||||
"mic_denied": "Odmowa dostępu do mikrofonu",
|
||||
"mic_denied_toast": "Odmowa dostępu do mikrofonu. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "Każda kwestia jest klonowana z klipu własnego dźwięku źródłowego. Najlepsza prozodia w obrębie kwestii, ale tożsamość głosu może dryfować między kwestiami.",
|
||||
"voice_match_consistent": "Spójny",
|
||||
"voice_match_consistent_title": "Wszystkie kwestie mówcy są klonowane z jednego wspólnego odniesienia (klon mówcy lub najlepszy pojedynczy klip, gdy go brak). Stabilniejsza tożsamość głosu w całym dubbingu.",
|
||||
"timing_lip_sync": "Synchronizacja ust",
|
||||
"default_track": "Domyślny utwór:",
|
||||
"original_track": "Oryginał",
|
||||
"selected_dub": "{{code}} (wybrany dubbing)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "Błąd w tłumaczeniu: {{error}}",
|
||||
"translate_degraded_title": "Przetłumaczono (zwykłe) — pominięto szlifowanie: {{reason}}",
|
||||
"budget_title": "Tekst ma {{pct}}% oryginału — rozważ większą prędkość lub krótsze frazowanie",
|
||||
"text_title": "Ctrl+D, aby podzielić przy kursorze · Ctrl+M, aby połączyć z następnym",
|
||||
"text_title": "Cmd/Ctrl+D, aby podzielić przy kursorze · Cmd/Ctrl+M, aby połączyć z następnym · Cmd/Ctrl+Shift+M, aby połączyć z poprzednim",
|
||||
"orig_label": "oryg",
|
||||
"restore_title": "Przywróć oryginalny tekst",
|
||||
"lang_default": "(Obrona)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "Edytuj kierunek…",
|
||||
"split_label": "Podziel przy kursorze",
|
||||
"merge_label": "Połącz z następnym",
|
||||
"merge_prev_label": "Połącz z poprzednim",
|
||||
"insert_label": "Wstaw wiersz poniżej",
|
||||
"direction_title": "Kierunek: {{dir}}",
|
||||
"more_actions_title": "Więcej akcji",
|
||||
"speaker_pick": "Wybierz…",
|
||||
"speaker_title_detected": "Głośnik — wybierz spośród wykrytych lub wpisz własną nazwę",
|
||||
"speaker_title_custom": "Głośnik — wpisz nazwę (nie wykryto klonów diaryzacji)",
|
||||
"time_edit_title": "Kliknij, aby edytować czas rozpoczęcia (m:ss.s). Enter, aby zatwierdzić, Esc, aby anulować.",
|
||||
"time_edit_end_title": "Kliknij, aby edytować czas zakończenia (m:ss.s). Enter, aby zatwierdzić, Esc, aby anulować.",
|
||||
"fit_fits": "Pasuje",
|
||||
"fit_fits_title": "Naturalny dźwięk mieści się w gnieździe.",
|
||||
"fit_overflows": "Przepełnienia +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "Edytor segmentów",
|
||||
"seg_split": "Podziel segment przy kursorze",
|
||||
"seg_merge": "Połącz z następnym segmentem",
|
||||
"seg_merge_prev": "Połącz z poprzednim segmentem",
|
||||
"seg_undo": "Cofnij",
|
||||
"seg_redo": "Powtórz",
|
||||
"seg_click": "Akcja podstawowa",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "Błąd: {{message}}",
|
||||
"ignored_unsupported": "Ignorowana nieobsługiwana instrukcja: {{items}}",
|
||||
"ignored_duplicate": "Ignorowane (kategoria już ustawiona): {{items}}",
|
||||
"ref_audio_unusable": "W nagraniu referencyjnym nie wykryto mowy — klip jest cichy lub prawie cichy, więc nie ma głosu do sklonowania. Nagraj ponownie bliżej mikrofonu (sprawdź, czy wybrano właściwe urządzenie wejściowe i czy poziom się porusza) albo wybierz inny klip."
|
||||
"ref_audio_unusable": "W nagraniu referencyjnym nie wykryto mowy — klip jest cichy lub prawie cichy, więc nie ma głosu do sklonowania. Nagraj ponownie bliżej mikrofonu (sprawdź, czy wybrano właściwe urządzenie wejściowe i czy poziom się porusza) albo wybierz inny klip.",
|
||||
"ref_audio_too_long": "Nagranie referencyjne może mieć najwyżej 20 sekund z transkrypcją lub 75 sekund bez niej. Przytnij je do wyraźnego fragmentu mowy 3–10 sekund; jeśli podajesz tekst, przytnij transkrypcję do tego samego fragmentu.",
|
||||
"ref_audio_no_speech": "Automatyczne wykrywanie mowy nie znalazło wypowiedzianych słów. Przytnij nagranie do wyraźnego fragmentu mowy 3–10 sekund lub podaj pasującą transkrypcję."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Udostępnianie i dostęp zdalny",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "Usuń model mowy",
|
||||
"engine_unavailable": "Mechanizm dyktowania na żywo nie jest dostępny w tej instalacji. Dyktowanie wraca do standardowej ścieżki transkrypcji.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Zalecane. Szybkie i dokładne dyktowanie w 25 językach europejskich.",
|
||||
"sherpa-parakeet-tdt-v3": "Szybkie i dokładne dyktowanie w 25 językach europejskich.",
|
||||
"sherpa-parakeet-tdt-v2": "Szybkie i dokładne dyktowanie wyłącznie w języku angielskim.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Transmisja strumieniowa języka chińskiego i angielskiego z fragmentami na żywo.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Przesyłanie strumieniowe w języku chińskim i angielskim, model kompaktowy.",
|
||||
"sherpa-zipformer-en-20m": "Mały, angielski model przesyłania strumieniowego — najniższe opóźnienie.",
|
||||
"sherpa-zipformer-zh-14m": "Mały chiński model do przesyłania strumieniowego — najniższe opóźnienie.",
|
||||
"sherpa-whisper-tiny": "Wielojęzyczny (ponad 90 języków) z automatycznym wykrywaniem języka."
|
||||
"sherpa-whisper-tiny": "Zalecane. Wielojęzyczne dyktowanie w ponad 90 językach z automatycznym wykrywaniem języka."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transcrevendo…",
|
||||
"a11y_setup": "Permita Acessibilidade para que o ditado possa digitar por você",
|
||||
"pasted": "Colado",
|
||||
"inserted": "Inserido",
|
||||
"copied": "Copiado para a área de transferência",
|
||||
"no_speech": "Nenhuma fala detectada",
|
||||
"mic_denied": "Acesso ao microfone negado",
|
||||
"mic_denied_toast": "Acesso ao microfone negado. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "Cada fala é clonada de um clipe do seu próprio áudio de origem. Melhor prosódia por fala, mas a identidade da voz pode variar de fala em fala.",
|
||||
"voice_match_consistent": "Consistente",
|
||||
"voice_match_consistent_title": "Todas as falas de um falante são clonadas de uma referência compartilhada (o clone do falante ou o melhor clipe único quando não houver). Identidade de voz mais estável em toda a dublagem.",
|
||||
"timing_lip_sync": "Sincronização labial",
|
||||
"default_track": "Faixa padrão:",
|
||||
"original_track": "Originais",
|
||||
"selected_dub": "{{code}} (dublagem selecionada)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "Erro de tradução: {{error}}",
|
||||
"translate_degraded_title": "Traduzido (simples) — o polimento foi ignorado: {{reason}}",
|
||||
"budget_title": "O texto é {{pct}}% do original – considere velocidade mais alta ou fraseado mais curto",
|
||||
"text_title": "Ctrl+D para dividir no cursor · Ctrl+M para mesclar com o próximo",
|
||||
"text_title": "Cmd/Ctrl+D para dividir no cursor · Cmd/Ctrl+M para mesclar com o próximo · Cmd/Ctrl+Shift+M para mesclar com o anterior",
|
||||
"orig_label": "original",
|
||||
"restore_title": "Restaurar texto original",
|
||||
"lang_default": "(Definitivo)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "Editar direção…",
|
||||
"split_label": "Dividir no cursor",
|
||||
"merge_label": "Mesclar com o próximo",
|
||||
"merge_prev_label": "Mesclar com o anterior",
|
||||
"insert_label": "Inserir linha abaixo",
|
||||
"direction_title": "Direção: {{dir}}",
|
||||
"more_actions_title": "Mais ações",
|
||||
"speaker_pick": "Escolha…",
|
||||
"speaker_title_detected": "Alto-falante – escolha entre detectado ou digite um nome personalizado",
|
||||
"speaker_title_custom": "Palestrante — digite um nome (nenhum clone de diarização detectado)",
|
||||
"time_edit_title": "Clique para editar a hora de início (m:ss.s). Enter para confirmar, Esc para cancelar.",
|
||||
"time_edit_end_title": "Clique para editar a hora de término (m:ss.s). Enter para confirmar, Esc para cancelar.",
|
||||
"fit_fits": "Serve",
|
||||
"fit_fits_title": "Áudio de taxa natural cabe dentro do slot.",
|
||||
"fit_overflows": "Estouro +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "Editor de segmento",
|
||||
"seg_split": "Dividir segmento no cursor",
|
||||
"seg_merge": "Mesclar com o próximo segmento",
|
||||
"seg_merge_prev": "Mesclar com o segmento anterior",
|
||||
"seg_undo": "Desfazer",
|
||||
"seg_redo": "Refazer",
|
||||
"seg_click": "Ação primária",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "Erro: {{message}}",
|
||||
"ignored_unsupported": "Instrução não suportada ignorada: {{items}}",
|
||||
"ignored_duplicate": "Ignorado (categoria já definida): {{items}}",
|
||||
"ref_audio_unusable": "Nenhuma fala foi detectada no áudio de referência — o clipe está em silêncio ou quase, então não há voz para clonar. Grave novamente mais perto do microfone (confira se o dispositivo de entrada correto está selecionado e se o nível se move) ou escolha outro clipe."
|
||||
"ref_audio_unusable": "Nenhuma fala foi detectada no áudio de referência — o clipe está em silêncio ou quase, então não há voz para clonar. Grave novamente mais perto do microfone (confira se o dispositivo de entrada correto está selecionado e se o nível se move) ou escolha outro clipe.",
|
||||
"ref_audio_too_long": "Uma referência pode ter no máximo 20 segundos com transcrição ou 75 segundos sem ela. Corte-a para um trecho de fala claro de 3–10 segundos; se fornecer texto, corte a transcrição para o mesmo trecho.",
|
||||
"ref_audio_no_speech": "A detecção automática não encontrou palavras faladas na referência. Corte-a para um trecho de fala claro de 3–10 segundos ou forneça uma transcrição correspondente."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Compartilhamento e acesso remoto",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "Excluir modelo de fala",
|
||||
"engine_unavailable": "O mecanismo de ditado ao vivo não está disponível nesta instalação. O ditado volta ao caminho de transcrição padrão.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Recomendado. Ditado rápido e preciso em 25 idiomas europeus.",
|
||||
"sherpa-parakeet-tdt-v3": "Ditado rápido e preciso em 25 idiomas europeus.",
|
||||
"sherpa-parakeet-tdt-v2": "Ditado rápido e preciso somente em inglês.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming de chinês + inglês com parciais ao vivo.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming Chinês + Inglês, modelo compacto.",
|
||||
"sherpa-zipformer-en-20m": "Modelo inglês de streaming minúsculo – latência mais baixa.",
|
||||
"sherpa-zipformer-zh-14m": "Modelo chinês de streaming minúsculo – latência mais baixa.",
|
||||
"sherpa-whisper-tiny": "Multilíngue (mais de 90 idiomas) com detecção automática de idioma."
|
||||
"sherpa-whisper-tiny": "Recomendado. Ditado multilíngue em mais de 90 idiomas com detecção automática de idioma."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Расшифровка…",
|
||||
"a11y_setup": "Разрешите Универсальный доступ, чтобы диктовка могла печатать за вас",
|
||||
"pasted": "Вставлено",
|
||||
"inserted": "Введено",
|
||||
"copied": "Скопировано в буфер обмена",
|
||||
"no_speech": "Речь не обнаружена",
|
||||
"mic_denied": "Доступ к микрофону запрещен",
|
||||
"mic_denied_toast": "Доступ к микрофону запрещен. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "Каждая реплика клонируется из фрагмента её собственного исходного звука. Лучшая просодия для каждой реплики, но идентичность голоса может плыть от реплики к реплике.",
|
||||
"voice_match_consistent": "Единый",
|
||||
"voice_match_consistent_title": "Все реплики говорящего клонируются из одной общей референс-записи (клон говорящего или лучший одиночный фрагмент, если клона нет). Более стабильный голос на протяжении всего дубляжа.",
|
||||
"timing_lip_sync": "Синхронизация губ",
|
||||
"default_track": "Трек по умолчанию:",
|
||||
"original_track": "Оригинал",
|
||||
"selected_dub": "{{code}} (Избранный дубляж)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "Ошибка перевода: {{error}}",
|
||||
"translate_degraded_title": "Переведено (просто) — этап доводки пропущен: {{reason}}",
|
||||
"budget_title": "Текст составляет {{pct}} % от оригинала. Рассмотрите возможность более быстрой или более короткой формулировки.",
|
||||
"text_title": "Ctrl+D, чтобы разделить курсор · Ctrl+M, чтобы объединить со следующим",
|
||||
"text_title": "Cmd/Ctrl+D, чтобы разделить курсор · Cmd/Ctrl+M, чтобы объединить со следующим · Cmd/Ctrl+Shift+M, чтобы объединить с предыдущим",
|
||||
"orig_label": "оригинал",
|
||||
"restore_title": "Восстановить исходный текст",
|
||||
"lang_default": "(Защита)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "Изменить направление…",
|
||||
"split_label": "Разделить по курсору",
|
||||
"merge_label": "Объединить со следующим",
|
||||
"merge_prev_label": "Объединить с предыдущим",
|
||||
"insert_label": "Вставить строку ниже",
|
||||
"direction_title": "Направление: {{dir}}",
|
||||
"more_actions_title": "Дополнительные действия",
|
||||
"speaker_pick": "Выбирать…",
|
||||
"speaker_title_detected": "Динамик — выберите из обнаруженных или введите собственное имя.",
|
||||
"speaker_title_custom": "Спикер — введите имя (клоны диаризизации не обнаружены)",
|
||||
"time_edit_title": "Нажмите, чтобы изменить время начала (м:сс.с). Enter для фиксации, Esc для отмены.",
|
||||
"time_edit_end_title": "Нажмите, чтобы изменить время окончания (м:сс.с). Enter для фиксации, Esc для отмены.",
|
||||
"fit_fits": "Подходит",
|
||||
"fit_fits_title": "Звук естественной скорости помещается в слот.",
|
||||
"fit_overflows": "Переполнение +{{seconds}}с",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "Редактор сегментов",
|
||||
"seg_split": "Разделить сегмент на курсоре",
|
||||
"seg_merge": "Объединить со следующим сегментом",
|
||||
"seg_merge_prev": "Объединить с предыдущим сегментом",
|
||||
"seg_undo": "Отменить",
|
||||
"seg_redo": "Повторить",
|
||||
"seg_click": "Первичное действие",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "Ошибка: {{message}}",
|
||||
"ignored_unsupported": "Игнорируется неподдерживаемая инструкция: {{items}}",
|
||||
"ignored_duplicate": "Игнорируется (категория уже установлена): {{items}}",
|
||||
"ref_audio_unusable": "В референсном аудио не слышно речи — клип тихий или полностью беззвучный, клонировать нечего. Запишите заново ближе к микрофону (проверьте, что выбрано правильное устройство ввода и уровень записи движется) или выберите другой клип."
|
||||
"ref_audio_unusable": "В референсном аудио не слышно речи — клип тихий или полностью беззвучный, клонировать нечего. Запишите заново ближе к микрофону (проверьте, что выбрано правильное устройство ввода и уровень записи движется) или выберите другой клип.",
|
||||
"ref_audio_too_long": "Референс может длиться не более 20 секунд с расшифровкой или 75 секунд без неё. Обрежьте его до чёткого речевого фрагмента длиной 3–10 секунд; если добавляете текст, обрежьте расшифровку до того же фрагмента.",
|
||||
"ref_audio_no_speech": "Автоматическое распознавание не обнаружило речи в референсе. Обрежьте его до чёткого речевого фрагмента длиной 3–10 секунд или добавьте соответствующую расшифровку."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Совместное использование и удаленный доступ",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "Удалить речевую модель",
|
||||
"engine_unavailable": "Механизм живой диктовки недоступен в этой установке. Диктовка возвращается к стандартному пути транскрипции.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Рекомендуется. Быстрая и точная диктовка на 25 европейских языках.",
|
||||
"sherpa-parakeet-tdt-v3": "Быстрая и точная диктовка на 25 европейских языках.",
|
||||
"sherpa-parakeet-tdt-v2": "Быстрый и точный диктант только на английском языке.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Потоковое вещание на китайском и английском языках с живыми фрагментами.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Потоковое вещание на китайском и английском языках, компактная модель.",
|
||||
"sherpa-zipformer-en-20m": "Миниатюрная потоковая английская модель — минимальная задержка.",
|
||||
"sherpa-zipformer-zh-14m": "Миниатюрная потоковая китайская модель — самая низкая задержка.",
|
||||
"sherpa-whisper-tiny": "Многоязычный (более 90 языков) с автоматическим определением языка."
|
||||
"sherpa-whisper-tiny": "Рекомендуется. Многоязычная диктовка на более чем 90 языках с автоматическим определением языка."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transkriberar...",
|
||||
"a11y_setup": "Tillåt Hjälpmedel så att diktering kan skriva åt dig",
|
||||
"pasted": "Klistras in",
|
||||
"inserted": "Infogat",
|
||||
"copied": "Kopierat till urklipp",
|
||||
"no_speech": "Inget tal upptäckt",
|
||||
"mic_denied": "Mikrofonåtkomst nekad",
|
||||
"mic_denied_toast": "Mikrofonåtkomst nekad. {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "Varje rad klonas från ett klipp av sitt eget källjud. Bäst prosodi per rad, men röstidentiteten kan glida mellan rader.",
|
||||
"voice_match_consistent": "Konsekvent",
|
||||
"voice_match_consistent_title": "Alla rader från en talare klonas från en gemensam referens (talarklonen, eller det bästa enskilda klippet om ingen finns). Stabilare röstidentitet genom hela dubbningen.",
|
||||
"timing_lip_sync": "Läppsynk",
|
||||
"default_track": "Standardspår:",
|
||||
"original_track": "Original",
|
||||
"selected_dub": "{{code}} (vald dub)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "Översättningsfel: {{error}}",
|
||||
"translate_degraded_title": "Översatt (enkel) — putsningssteget hoppades över: {{reason}}",
|
||||
"budget_title": "Texten är {{pct}}% av originalet — överväg högre hastighet eller kortare frasering",
|
||||
"text_title": "Ctrl+D för att dela vid markören · Ctrl+M för att slå samman med nästa",
|
||||
"text_title": "Cmd/Ctrl+D för att dela vid markören · Cmd/Ctrl+M för att slå samman med nästa · Cmd/Ctrl+Shift+M för att slå samman med föregående",
|
||||
"orig_label": "ursprung",
|
||||
"restore_title": "Återställ originaltext",
|
||||
"lang_default": "(Def)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "Redigera riktning...",
|
||||
"split_label": "Dela vid markören",
|
||||
"merge_label": "Slå samman med nästa",
|
||||
"merge_prev_label": "Slå samman med föregående",
|
||||
"insert_label": "Infoga rad nedanför",
|
||||
"direction_title": "Riktning: {{dir}}",
|
||||
"more_actions_title": "Fler åtgärder",
|
||||
"speaker_pick": "Välj...",
|
||||
"speaker_title_detected": "Högtalare – välj från upptäckt eller skriv ett anpassat namn",
|
||||
"speaker_title_custom": "Högtalare – skriv ett namn (inga diariseringskloner upptäcktes)",
|
||||
"time_edit_title": "Klicka för att redigera starttid (m:ss.s). Enter för att begå, Esc för att avbryta.",
|
||||
"time_edit_end_title": "Klicka för att redigera sluttid (m:ss.s). Enter för att begå, Esc för att avbryta.",
|
||||
"fit_fits": "Passar",
|
||||
"fit_fits_title": "Naturligt ljud passar in i kortplatsen.",
|
||||
"fit_overflows": "Bräddar +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "Segmentredigerare",
|
||||
"seg_split": "Dela segment vid markören",
|
||||
"seg_merge": "Slå samman med nästa segment",
|
||||
"seg_merge_prev": "Slå samman med föregående segment",
|
||||
"seg_undo": "Ångra",
|
||||
"seg_redo": "Gör om",
|
||||
"seg_click": "Primär åtgärd",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "Fel: {{message}}",
|
||||
"ignored_unsupported": "Ignorerad instruktion som inte stöds: {{items}}",
|
||||
"ignored_duplicate": "Ignorerad (kategori redan inställd): {{items}}",
|
||||
"ref_audio_unusable": "Inget tal kunde höras i referensljudet — klippet är tyst eller nästan tyst, så det finns ingen röst att klona. Spela in igen närmare mikrofonen (kontrollera att rätt ingångsenhet är vald och att nivån rör sig) eller välj ett annat klipp."
|
||||
"ref_audio_unusable": "Inget tal kunde höras i referensljudet — klippet är tyst eller nästan tyst, så det finns ingen röst att klona. Spela in igen närmare mikrofonen (kontrollera att rätt ingångsenhet är vald och att nivån rör sig) eller välj ett annat klipp.",
|
||||
"ref_audio_too_long": "En referens får vara högst 20 sekunder med transkription eller 75 sekunder utan. Klipp den till ett tydligt talavsnitt på 3–10 sekunder; om du anger text ska transkriptionen klippas till samma avsnitt.",
|
||||
"ref_audio_no_speech": "Den automatiska taldetekteringen hittade inga talade ord. Klipp referensen till ett tydligt talavsnitt på 3–10 sekunder eller ange en matchande transkription."
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Delning och fjärråtkomst",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "Ta bort talmodell",
|
||||
"engine_unavailable": "Live-dikteringsmotorn är inte tillgänglig på den här installationen. Diktering faller tillbaka till standardtranskriptionsvägen.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Rekommenderas. Snabb, exakt diktering på 25 europeiska språk.",
|
||||
"sherpa-parakeet-tdt-v3": "Snabb och exakt diktering på 25 europeiska språk.",
|
||||
"sherpa-parakeet-tdt-v2": "Snabb, exakt diktering endast på engelska.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Strömmande kinesiska + engelska med livepartialer.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming kinesiska + engelska, kompakt modell.",
|
||||
"sherpa-zipformer-en-20m": "Liten strömmande engelsk modell — lägsta latens.",
|
||||
"sherpa-zipformer-zh-14m": "Liten strömmande kinesisk modell — lägsta latens.",
|
||||
"sherpa-whisper-tiny": "Flerspråkig (90+ språk) med automatisk språkdetektering."
|
||||
"sherpa-whisper-tiny": "Rekommenderas. Flerspråkig diktering på över 90 språk med automatisk språkidentifiering."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "กำลังถอดเสียง...",
|
||||
"a11y_setup": "อนุญาตการช่วยการเข้าถึงเพื่อให้การป้อนตามคำบอกพิมพ์แทนคุณได้",
|
||||
"pasted": "วางแล้ว",
|
||||
"inserted": "แทรกแล้ว",
|
||||
"copied": "คัดลอกไปยังคลิปบอร์ดแล้ว",
|
||||
"no_speech": "ไม่พบคำพูด",
|
||||
"mic_denied": "การเข้าถึงไมค์ถูกปฏิเสธ",
|
||||
"mic_denied_toast": "การเข้าถึงไมโครโฟนถูกปฏิเสธ {{hint}}",
|
||||
@@ -1035,6 +1037,7 @@
|
||||
"voice_match_per_line_title": "แต่ละบรรทัดโคลนจากคลิปเสียงต้นฉบับของตัวเอง จังหวะเสียงตรงที่สุดต่อบรรทัด แต่เอกลักษณ์เสียงอาจเพี้ยนไปทีละบรรทัด",
|
||||
"voice_match_consistent": "สม่ำเสมอ",
|
||||
"voice_match_consistent_title": "ทุกบรรทัดของผู้พูดโคลนจากข้อมูลอ้างอิงเดียวร่วมกัน (โคลนผู้พูด หรือคลิปเดี่ยวที่ดีที่สุดหากไม่มี) เอกลักษณ์เสียงคงที่ตลอดทั้งการพากย์",
|
||||
"timing_lip_sync": "ซิงค์ริมฝีปาก",
|
||||
"default_track": "แทร็กเริ่มต้น:",
|
||||
"original_track": "ต้นฉบับ",
|
||||
"selected_dub": "{{code}} (พากย์ที่เลือก)",
|
||||
@@ -1159,7 +1162,7 @@
|
||||
"translate_error_title": "ข้อผิดพลาดในการแปล: {{error}}",
|
||||
"translate_degraded_title": "แปลแล้ว (ตรงตัว) — ข้ามขั้นตอนขัดเกลา: {{reason}}",
|
||||
"budget_title": "ข้อความมีความยาว {{pct}}% ของต้นฉบับ โปรดพิจารณาการใช้ข้อความที่เร็วขึ้นหรือใช้ถ้อยคำที่สั้นลง",
|
||||
"text_title": "Ctrl+D เพื่อแยกที่เคอร์เซอร์ · Ctrl+M เพื่อรวมเข้ากับถัดไป",
|
||||
"text_title": "Cmd/Ctrl+D เพื่อแยกที่เคอร์เซอร์ · Cmd/Ctrl+M เพื่อรวมเข้ากับถัดไป · Cmd/Ctrl+Shift+M เพื่อรวมเข้ากับก่อนหน้า",
|
||||
"orig_label": "ต้นฉบับ",
|
||||
"restore_title": "คืนค่าข้อความต้นฉบับ",
|
||||
"lang_default": "(ป้องกัน)",
|
||||
@@ -1172,12 +1175,15 @@
|
||||
"edit_direction": "แก้ไขทิศทาง...",
|
||||
"split_label": "แยกที่เคอร์เซอร์",
|
||||
"merge_label": "รวมกับต่อไป",
|
||||
"merge_prev_label": "รวมกับก่อนหน้า",
|
||||
"insert_label": "แทรกบรรทัดด้านล่าง",
|
||||
"direction_title": "ทิศทาง: {{dir}}",
|
||||
"more_actions_title": "การดำเนินการเพิ่มเติม",
|
||||
"speaker_pick": "เลือก...",
|
||||
"speaker_title_detected": "ผู้พูด — เลือกจากที่ตรวจพบ หรือพิมพ์ชื่อที่กำหนดเอง",
|
||||
"speaker_title_custom": "ผู้พูด — พิมพ์ชื่อ (ตรวจไม่พบโคลนไดอะไรเซชัน)",
|
||||
"time_edit_title": "คลิกเพื่อแก้ไขเวลาเริ่มต้น (m:ss.s) เข้าสู่เพื่อกระทำ Esc เพื่อยกเลิก",
|
||||
"time_edit_end_title": "คลิกเพื่อแก้ไขเวลาสิ้นสุด (m:ss.s) เข้าสู่เพื่อกระทำ Esc เพื่อยกเลิก",
|
||||
"fit_fits": "พอดี",
|
||||
"fit_fits_title": "เสียงที่มีอัตราธรรมชาติพอดีกับช่อง",
|
||||
"fit_overflows": "โอเวอร์โฟลว์ +{{seconds}}s",
|
||||
@@ -1668,6 +1674,7 @@
|
||||
"segmentEditor": "ตัวแก้ไขส่วน",
|
||||
"seg_split": "แยกส่วนที่เคอร์เซอร์",
|
||||
"seg_merge": "รวมกับส่วนถัดไป",
|
||||
"seg_merge_prev": "รวมกับส่วนก่อนหน้า",
|
||||
"seg_undo": "เลิกทำ",
|
||||
"seg_redo": "ทำซ้ำ",
|
||||
"seg_click": "การกระทำหลัก",
|
||||
@@ -2058,7 +2065,9 @@
|
||||
"error_prefix": "ข้อผิดพลาด: {{message}}",
|
||||
"ignored_unsupported": "ละเว้นคำสั่งที่ไม่สนับสนุน: {{items}}",
|
||||
"ignored_duplicate": "ละเว้น (หมวดหมู่ที่ตั้งไว้แล้ว): {{items}}",
|
||||
"ref_audio_unusable": "ไม่ได้ยินเสียงพูดในไฟล์เสียงอ้างอิง — คลิปเงียบหรือเกือบไม่มีเสียง จึงไม่มีเสียงให้โคลน โปรดอัดใหม่ให้ใกล้ไมโครโฟนมากขึ้น (ตรวจสอบว่าเลือกอุปกรณ์รับเสียงถูกต้อง) หรือเลือกคลิปอื่น"
|
||||
"ref_audio_unusable": "ไม่ได้ยินเสียงพูดในไฟล์เสียงอ้างอิง — คลิปเงียบหรือเกือบไม่มีเสียง จึงไม่มีเสียงให้โคลน โปรดอัดใหม่ให้ใกล้ไมโครโฟนมากขึ้น (ตรวจสอบว่าเลือกอุปกรณ์รับเสียงถูกต้อง) หรือเลือกคลิปอื่น",
|
||||
"ref_audio_too_long": "เสียงอ้างอิงยาวได้ไม่เกิน 20 วินาทีเมื่อมีข้อความถอดเสียง หรือ 75 วินาทีเมื่อไม่มี ตัดให้เหลือช่วงคำพูดชัดเจน 3–10 วินาที และหากมีข้อความให้ตัดตรงกับช่วงเดียวกัน",
|
||||
"ref_audio_no_speech": "การตรวจจับอัตโนมัติไม่พบคำพูดในเสียงอ้างอิง ตัดให้เหลือช่วงคำพูดชัดเจน 3–10 วินาที หรือใส่ข้อความถอดเสียงที่ตรงกัน"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "การแชร์และการเข้าถึงระยะไกล",
|
||||
@@ -2467,13 +2476,13 @@
|
||||
"delete_confirm_title": "ลบโมเดลคำพูด",
|
||||
"engine_unavailable": "กลไกการเขียนตามคำบอกสดไม่พร้อมใช้งานในการติดตั้งนี้ การเขียนตามคำบอกจะกลับไปใช้เส้นทางการถอดเสียงมาตรฐาน",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "แนะนำ. การเขียนตามคำบอกที่รวดเร็วและแม่นยำใน 25 ภาษายุโรป",
|
||||
"sherpa-parakeet-tdt-v3": "การเขียนตามคำบอกที่รวดเร็วและแม่นยำใน 25 ภาษายุโรป",
|
||||
"sherpa-parakeet-tdt-v2": "การเขียนตามคำบอกภาษาอังกฤษเท่านั้นที่รวดเร็วและแม่นยำ",
|
||||
"sherpa-zipformer-bilingual-zh-en": "สตรีมมิ่งภาษาจีน + อังกฤษพร้อมถ่ายทอดสดบางส่วน",
|
||||
"sherpa-paraformer-bilingual-zh-en": "สตรีมมิ่งภาษาจีน+อังกฤษ รุ่นกะทัดรัด",
|
||||
"sherpa-zipformer-en-20m": "โมเดลสตรีมมิ่งภาษาอังกฤษขนาดเล็ก — เวลาแฝงต่ำที่สุด",
|
||||
"sherpa-zipformer-zh-14m": "โมเดลสตรีมมิ่งจีนขนาดเล็ก — เวลาแฝงต่ำที่สุด",
|
||||
"sherpa-whisper-tiny": "หลายภาษา (90+ ภาษา) พร้อมการตรวจจับภาษาอัตโนมัติ"
|
||||
"sherpa-whisper-tiny": "แนะนำ การเขียนตามคำบอกหลายภาษากว่า 90 ภาษา พร้อมการตรวจจับภาษาอัตโนมัติ"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user