fix(dub): keep language and media tools ready (#1679)

Fixes #1677 and #1678.

Publishes first-run media tools to the live backend, provides precise cross-platform missing-process guidance, and keeps localized source-language selection available before transcription. Includes regression coverage and deterministic model-store test isolation.
This commit is contained in:
Palash Debnath
2026-08-28 06:32:49 +05:30
committed by GitHub
parent 6d42d2255e
commit 0268f43e7f
12 changed files with 199 additions and 3 deletions
+2
View File
@@ -56,6 +56,8 @@ 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
- Dubbing keeps the source-language selector visible after a local file is chosen, so ASR can be pinned before transcription starts (#1678) — thanks @Lonki-lomki-cloud!
- First-run media-engine downloads become available to TTS immediately without a restart, and missing media-process failures now point to repair controls (#1677) — thanks @farhataligpt-dev!
- Source installs on AMD GPUs honour `OMNIVOICE_TORCH_VARIANT=rocm`: `bun run desktop` now swaps in the ROCm torch wheel after `uv sync` and launches the backend without re-syncing, instead of silently reverting to the CPU-only CUDA build on every start (#1665) — thanks @uberclokr!
- `bun run desktop` on a fresh clone no longer fails with "resource path `../../frontend/dist` doesn't exist" — the dev launcher creates the placeholder Tauri resource directory before compiling (#1664) — thanks @uberclokr!
- macOS no longer loses TTS after the first request when Python lacks `os.waitid`; subprocess ownership now uses a safe `waitpid` fallback without risking reused process groups (#1656) — thanks @paoloantinori!
+40
View File
@@ -459,6 +459,31 @@ def _is_timeout_failure(e) -> bool:
return False
def _is_media_process_launch_failure(exc: BaseException) -> bool:
"""Identify an ffmpeg/ffprobe launch ENOENT without guessing from a file name."""
if not isinstance(exc, FileNotFoundError):
return False
# A regular missing reference/model file may itself be named "ffmpeg".
# Require the innermost raise site to be Python's process launcher so that
# basename collisions keep the normal missing-file diagnosis (#1677).
traceback_cursor = exc.__traceback__
if traceback_cursor is None:
return False
while traceback_cursor.tb_next is not None:
traceback_cursor = traceback_cursor.tb_next
origin_module = traceback_cursor.tb_frame.f_globals.get("__name__", "")
if origin_module != "subprocess" and not origin_module.startswith("asyncio."):
return False
filename = getattr(exc, "filename", None)
if not filename:
return "[winerror 2]" in str(exc).lower()
return os.path.basename(str(filename)).lower() in {
"ffmpeg", "ffmpeg.exe", "ffprobe", "ffprobe.exe",
}
def _oom_friendly_reraise(e):
"""Best-effort cache flush + the user-facing OOM hint shared by both
inference paths."""
@@ -483,6 +508,21 @@ def _oom_friendly_reraise(e):
# that lost its +x bit) is NOT an OOM — don't send the user to the Flush
# button; tell them what's actually wrong.
es = str(e)
# #1677: Windows CreateProcess reports a missing executable as a bare
# ``FileNotFoundError: [WinError 2] ...`` with no filename, while POSIX
# includes the missing ffmpeg/ffprobe name. The bundled-media downloader
# now republishes PATH as soon as it finishes, but a failed/blocked
# download still needs an actionable recovery rather than the unknown-
# error dead end. Keep missing reference/model files on their own path.
for _exc in _exception_chain(e):
if _is_media_process_launch_failure(_exc):
raise RuntimeError(
"A required media program couldn't be launched. Open "
"Settings → Audio tools and use "
"Download/Repair for the media engine, then retry. If Audio "
"tools is already ready, repair the selected TTS engine and "
f"restart VoiceStudio. Underlying error: {_safe_exc_text(_exc)}"
) from e
if isinstance(e, PermissionError) or "Permission denied" in es or "Errno 13" in es:
raise RuntimeError(
f"A required engine binary couldn't be executed (permission denied). "
+20
View File
@@ -154,6 +154,17 @@ def bundled_dir() -> str:
return os.path.join(media_tools_dir(), f"ffbin-{_FFBIN_COMMIT[:12]}", _platform_key())
def _publish_bundled_on_path() -> None:
"""Make a newly validated bundle visible to bare-name subprocess calls."""
directory = os.path.abspath(bundled_dir())
current = os.environ.get("PATH", "")
entries = current.split(os.pathsep) if current else []
if os.path.normcase(directory) in {os.path.normcase(entry) for entry in entries if entry}:
return
os.environ["PATH"] = os.pathsep.join([directory, *entries])
logger.info("Published the acquired media-tool directory on PATH")
def _exe(name: str) -> str:
return f"{name}.exe" if sys.platform == "win32" else name
@@ -231,12 +242,21 @@ def acquire_bundled(wait: bool = False) -> dict:
_ops["acquire"].update(state="running", progress=0.0, error=None)
if all(bundled_tool_path(t) and _binary_runs(bundled_tool_path(t)) for t in TOOLS):
# The bundle may have arrived after startup's one-time PATH publish
# (first-run acquisition is asynchronous). Make it visible to pydub
# and other dependencies that launch ffmpeg/ffprobe by bare name now,
# without requiring a backend restart (#1677).
_publish_bundled_on_path()
_set_op("acquire", state="done", progress=1.0)
return _op_snapshot()["acquire"]
def _worker():
try:
_do_acquire()
# Startup cannot publish binaries which do not exist yet. The
# background worker must complete that second half atomically with
# installation so the very next synthesis can use the tools.
_publish_bundled_on_path()
_set_op("acquire", state="done", progress=1.0, error=None)
logger.info("media-tools: bundled ffmpeg/ffprobe installed at %s", bundled_dir())
except Exception as e:
+24 -3
View File
@@ -25,7 +25,7 @@ import DubbingDemo from '../DubbingDemo';
import DubFailureNotice from './DubFailureNotice';
import PrepOverlay from './PrepOverlay';
import TranscribeOverlay from './TranscribeOverlay';
import { LANG_CODES } from '../../utils/languages';
import { LANG_CODES, languageLabel } from '../../utils/languages';
const SPEAKERS_INPUT =
'w-[52px] ml-[4px] px-[6px] py-[4px] rounded-[6px] border border-[var(--border,#3c3836)] bg-[var(--input-bg,#282828)] text-inherit text-[12px]';
@@ -59,6 +59,7 @@ function AsrInstallStatus({ t, install, onAbort }) {
export default function IdleSkeleton({
t,
uiLocale,
dubVideoFile,
activeProjectName,
dubFilename,
@@ -272,6 +273,26 @@ export default function IdleSkeleton({
/>
</label>
)}
<label className="inline-flex items-center gap-[5px] text-[12px] text-[var(--muted,#a89984)] whitespace-nowrap">
<Globe size={13} /> {t('dub.source_language')}
<select
className="input-base text-[0.65rem]"
value={dubSourceLangCode}
disabled={
dubStep === 'uploading' ||
dubStep === 'transcribing' ||
dubStep === 'installing-asr'
}
onChange={(event) => setDubSourceLangCode(event.target.value)}
>
<option value="auto">{t('bootstrap.auto_detect')}</option>
{LANG_CODES.map((language) => (
<option key={language.code} value={language.code}>
{languageLabel(language.code, uiLocale, language.label)} {language.code}
</option>
))}
</select>
</label>
<label
className="inline-flex items-center gap-[5px] text-[12px] text-[var(--muted,#a89984)] whitespace-nowrap"
title={t('dub.num_speakers_help')}
@@ -485,7 +506,7 @@ export default function IdleSkeleton({
<option value="auto">{t('bootstrap.auto_detect')}</option>
{LANG_CODES.map((language) => (
<option key={language.code} value={language.code}>
{language.label} {language.code}
{languageLabel(language.code, uiLocale, language.label)} {language.code}
</option>
))}
</select>
@@ -506,7 +527,7 @@ export default function IdleSkeleton({
>
{LANG_CODES.map((lc) => (
<option key={lc.code} value={lc.code}>
{lc.label} {lc.code}
{languageLabel(lc.code, uiLocale, lc.label)} {lc.code}
</option>
))}
</select>
+1
View File
@@ -651,6 +651,7 @@ export default function DubTab(props) {
{showIdleSkeleton && (
<IdleSkeleton
t={t}
uiLocale={i18n.language}
dubVideoFile={dubVideoFile}
activeProjectName={activeProjectName}
dubFilename={dubFilename}
@@ -29,6 +29,7 @@ function baseProps(overrides = {}) {
const noop = vi.fn();
return {
t: i18n.t,
uiLocale: 'en',
dubVideoFile: null, // URL-ingest / restored job: no local File
activeProjectName: '',
dubFilename: '',
@@ -64,6 +65,8 @@ function baseProps(overrides = {}) {
youtubeCookieFile: null,
setYoutubeCookieFile: noop,
dubLangCode: 'en',
dubSourceLangCode: 'auto',
setDubSourceLangCode: noop,
setDubLangCode: noop,
setDubLang: noop,
landingAdvOpen: false,
@@ -108,6 +111,29 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
expect(screen.getByLabelText('Choose a cookies.txt export')).toBeInTheDocument();
});
it('keeps source-language selection available after choosing a local file', () => {
const setDubSourceLangCode = vi.fn();
renderIdle({
dubVideoFile: new File(['video'], 'thai.mp4', { type: 'video/mp4' }),
setDubSourceLangCode,
});
fireEvent.change(screen.getByRole('combobox', { name: i18n.t('dub.source_language') }), {
target: { value: 'th' },
});
expect(setDubSourceLangCode).toHaveBeenCalledWith('th');
});
it('localizes source-language option names for the active UI locale', () => {
renderIdle({
dubVideoFile: new File(['video'], 'thai.mp4', { type: 'video/mp4' }),
uiLocale: 'fr',
});
const thai = new Intl.DisplayNames(['fr', 'en'], { type: 'language' }).of('th');
expect(screen.getByRole('option', { name: `${thai} — th` })).toBeInTheDocument();
});
it('clears the native cookie picker when the selection is removed', () => {
const selected = new File(['# Netscape HTTP Cookie File\n'], 'cookies.txt', {
type: 'text/plain',
@@ -65,6 +65,7 @@ vi.mock('../api/system', () => ({
}));
vi.mock('../api/external', () => ({ openExternal: vi.fn() }));
vi.mock('../components/settings/models/RecoBanner', () => ({ default: () => null }));
vi.mock('../components/settings/VoicePreviewsPanel', () => ({ default: () => null }));
// Surface the FILTERED row set (and the empty-state action) without the
// virtualizer, which yields no rows in jsdom. The filter itself TanStack
// global/column filter state driven by the real toolbar stays real.
@@ -212,6 +212,7 @@ vi.mock('../api/system', () => ({
}));
vi.mock('../api/external', () => ({ openExternal: vi.fn() }));
vi.mock('../components/settings/models/RecoBanner', () => ({ default: () => null }));
vi.mock('../components/settings/VoicePreviewsPanel', () => ({ default: () => null }));
// Surface the per-section row set without the virtualizer (yields no rows in
// jsdom). Section chrome (headers, incompatible toggle) stays real.
vi.mock('../components/settings/models/ModelsTable', () => ({
@@ -150,6 +150,7 @@ vi.mock('../api/setup', () => ({
}));
vi.mock('../api/external', () => ({ openExternal: vi.fn() }));
vi.mock('../components/settings/models/RecoBanner', () => ({ default: () => null }));
vi.mock('../components/settings/VoicePreviewsPanel', () => ({ default: () => null }));
// Render every row's cells for real (the virtualizer yields nothing in jsdom).
vi.mock('../components/settings/models/ModelsTable', () => ({
default: ({ tableRows }) => (
+10
View File
@@ -92,3 +92,13 @@ export const LANG_CODES = [
{ code: 'yo', label: 'Yoruba' },
{ code: 'zu', label: 'Zulu' },
];
/** Localized language name, with the static English label as a WebView fallback. */
export function languageLabel(code, uiLocale = 'en', fallback = code) {
try {
const names = new Intl.DisplayNames([uiLocale, 'en'], { type: 'language' });
return names.of(code) || fallback;
} catch {
return fallback;
}
}
+42
View File
@@ -7,6 +7,7 @@ misleading "ran out of memory". Two guards: sanitize non-finite samples before
any encode, and classify a decode/ffmpeg failure as unreadable-audio (not OOM).
"""
import os
import subprocess
import sys
import pytest
@@ -21,6 +22,16 @@ from api.routers.generation import ( # noqa: E402
)
def _missing_media_process_error() -> FileNotFoundError:
"""Capture a real process-launch ENOENT so traceback origin is preserved."""
env = {**os.environ, "PATH": os.path.join(os.path.dirname(__file__), "missing-bin")}
try:
subprocess.run(["ffmpeg"], check=False, env=env)
except FileNotFoundError as exc:
return exc
raise AssertionError("missing ffmpeg unexpectedly launched")
def test_sanitize_replaces_non_finite_with_silence():
t = torch.tensor([0.1, float("nan"), float("inf"), -float("inf"), 0.2])
out = _sanitize_audio(t)
@@ -109,6 +120,37 @@ def test_unknown_error_is_not_labelled_oom():
assert "Try the Flush button" not in msg
def test_bare_windows_missing_process_error_is_actionable():
"""CreateProcess omits the executable name from WinError 2 on Windows."""
err = _missing_media_process_error()
err.filename = None
err.strerror = "[WinError 2] The system cannot find the file specified"
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "doesn't recognize" not in msg
assert "Audio tools" in msg
assert "required media program" in msg
def test_posix_missing_media_process_error_is_actionable():
"""Equivalent missing-tool failures receive the same guidance on POSIX."""
err = _missing_media_process_error()
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "Audio tools" in msg
assert "required media program" in msg
def test_missing_file_named_like_media_tool_keeps_missing_file_diagnosis():
"""A reference/model basename collision is not a process-launch failure."""
err = FileNotFoundError(2, "No such file or directory", "ffmpeg")
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
assert "Audio tools" not in str(ei.value)
@pytest.mark.parametrize("reason", [
"CUDA out of memory. Tried to allocate 20.00 MiB",
"MPS backend out of memory (MPS allocated: 8.00 GB)",
+31
View File
@@ -26,6 +26,7 @@ def mt(monkeypatch, tmp_path):
import core.prefs as prefs
import services.media_tools as mt_mod
original_path = os.environ.get("PATH")
monkeypatch.setattr(prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
monkeypatch.setattr(mt_mod, "media_tools_dir", lambda: str(tmp_path / "media_tools"))
auth_dir = tmp_path / "path-authorizations"
@@ -47,6 +48,10 @@ def mt(monkeypatch, tmp_path):
# (test_pitch_stretch_async was the victim). Explicitly drop them.
for key in _OVERRIDE_KEYS:
os.environ.pop(key, None)
if original_path is None:
os.environ.pop("PATH", None)
else:
os.environ["PATH"] = original_path
def _client():
@@ -165,6 +170,32 @@ def test_acquire_installs_when_checksum_and_probe_pass(mt, monkeypatch):
assert os.access(p, os.X_OK)
def test_acquire_publishes_new_binaries_to_the_live_process(mt, monkeypatch):
"""A first-run background download must become usable without restart."""
payload = _make_zip([f"plat/{mt._exe('ffmpeg')}", f"plat/{mt._exe('ffprobe')}"])
_patched_bundle(mt, monkeypatch, payload)
monkeypatch.setattr(mt, "_binary_runs", lambda p: True)
published = []
monkeypatch.setattr(mt, "_publish_bundled_on_path", lambda: published.append(True))
with patch("urllib.request.urlopen", return_value=_FakeResponse(payload)):
assert mt.acquire_bundled(wait=True)["state"] == "done"
assert published == [True]
def test_publish_bundled_on_path_prepends_once(mt, monkeypatch):
monkeypatch.setenv("PATH", os.pathsep.join(["system-a", "system-b"]))
mt._publish_bundled_on_path()
mt._publish_bundled_on_path()
entries = os.environ["PATH"].split(os.pathsep)
assert entries[0] == os.path.abspath(mt.bundled_dir())
assert entries.count(entries[0]) == 1
assert entries[1:] == ["system-a", "system-b"]
def test_acquire_rejects_binary_that_fails_version_probe(mt, monkeypatch):
"""A checksum-valid download whose binary won't run (wrong arch, corrupt)
must NOT be installed the WinError-193 class, caught at install time."""