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:
@@ -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!
|
||||
|
||||
@@ -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). "
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user