Merge remote-tracking branch 'origin/main' into fix/backend-startup-budget-1749

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Palash Debnath
2026-09-02 03:12:28 +05:30
8 changed files with 213 additions and 18 deletions
+3
View File
@@ -24,10 +24,13 @@ the frozen-backend fallback mirror it for their toolchains.
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631).
- A production private-API guide now covers pinned containers, root credentials, network isolation, streaming proxies, health checks, upgrades, and benchmark evidence (#1720)
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
### Fixed
- The setup splash now waits through the backend's full startup budget instead of reporting slow Windows CUDA initialization as stuck after two minutes (#1749)
- Dubbing jobs can now reuse every source-language code produced by automatic ASR detection without a 400 error on the next upload (#1737)
- Incomplete Sherpa-ONNX model snapshots now self-repair before recognizer startup instead of failing on a missing ONNX file (#1733)
- OmniVoice subprocess startup now allows slow packaged Windows Python runtimes to signal readiness before termination (#1711)
- SRT files selected during source analysis now wait for speaker cloning, then replace transcript text without losing voices (#1709)
- Windows MSI deployments can now prohibit WebView2 bootstrap with `DISABLEWEBVIEW2BOOTSTRAP=1`, and `AUTOLAUNCHAPP=0` reliably suppresses first launch (#1714)
+23 -9
View File
@@ -518,9 +518,12 @@ _ingest_gen = dub_pipeline.ingest_pipeline
#: container so a mislabelled video can't slip past the video-skipping branch.
_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma"}
# Source-language choices exposed by the first-party dub UI. Keeping this an
# allow-list rejects language names and private-use BCP-47 tags before they are
# persisted as ASR overrides. Values are normalized to lowercase below.
# Source-language choices exposed by the first-party dub UI, plus every
# language code Whisper can write back after auto-detection. A restored job
# may reuse that detected value as the next upload's override, so rejecting our
# own persisted codes strands otherwise valid dubbing sessions (#1737).
# Keeping this an allow-list still rejects language names and private-use
# BCP-47 tags. Values are normalized to lowercase below.
_DUB_SOURCE_LANG_CODES = frozenset({
"af", "sq", "am", "ar", "hy", "az", "eu", "be", "bn", "bs", "bg",
"my", "ca", "cmn-hans", "cmn-hant", "hr", "cs", "da", "nl", "en",
@@ -531,6 +534,8 @@ _DUB_SOURCE_LANG_CODES = frozenset({
"ru", "sm", "gd", "sr", "sn", "sd", "si", "sk", "sl", "so", "es",
"su", "sw", "sv", "tg", "ta", "te", "th", "tr", "uk", "ur", "uz",
"vi", "cy", "xh", "yi", "yo", "zu",
"as", "ba", "bo", "br", "fo", "lb", "ln", "mg", "nn", "oc", "sa",
"tk", "tl", "tt", "yue", "zh",
})
@@ -544,6 +549,15 @@ def _source_lang_override(value: str | None) -> str | None:
return code
def _detected_source_lang(value: str | None) -> str:
"""Normalize an ASR language without truncating valid three-letter codes."""
code = (value or "en").split("_", 1)[0].strip().lower()
if code in _DUB_SOURCE_LANG_CODES:
return code
short = code[:2]
return short if short in _DUB_SOURCE_LANG_CODES else "en"
@router.post("/dub/upload")
async def dub_upload(
video: UploadFile = File(...),
@@ -1809,9 +1823,9 @@ async def dub_transcribe_stream(
except Exception as e:
logger.warning("speaker_clone extraction skipped: %s", e)
job["source_lang"] = job.get("source_lang_override") or (
(detected_lang or "en").split("_")[0][:2] or "en"
).lower()
job["source_lang"] = job.get("source_lang_override") or _detected_source_lang(
detected_lang
)
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
_save_job(job_id, job)
@@ -2008,9 +2022,9 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
except Exception as e:
logger.warning("Failed to unload ASR backend: %s", e)
job["source_lang"] = job.get("source_lang_override") or (
(detected_lang or "en").split("_")[0][:2] or "en"
).lower()
job["source_lang"] = job.get("source_lang_override") or _detected_source_lang(
detected_lang
)
scene_cuts = job.get("scene_cuts") or []
segments = segment_transcript(result, duration=job.get("duration", 0.0), scene_cuts=scene_cuts)
+59 -4
View File
@@ -273,6 +273,13 @@ def sherpa_available() -> tuple[bool, str]:
return False, f"sherpa-onnx unavailable ({type(e).__name__}): {e}"
def _usable_model_file(path: str) -> bool:
try:
return os.path.isfile(path) and os.path.getsize(path) > 0
except OSError:
return False
def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
"""Return the local directory containing this model's ONNX assets.
@@ -285,6 +292,7 @@ def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
from services.hf_revisions import revision_for
wanted = list(spec.files.values())
cache_dir = _live_hub_cache_dir()
# Probe the revision an existing installation actually resolved. Older
# releases followed ``main`` and may therefore have a different snapshot;
# retaining it preserves offline upgrades. Any network fetch still uses
@@ -294,12 +302,57 @@ def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
return installed
if not download:
raise FileNotFoundError(f"No complete cached snapshot for {spec.repo_id}")
# A Windows cache can retain a snapshot entry whose target blob vanished,
# or a zero-byte ONNX placeholder left by an interrupted download. Hub may
# then treat that entry as already materialized and return the same broken
# snapshot. Repair those entries before asking for another download so the
# recognizer never receives a path to a file that does not resolve (#1733).
from services.hf_cache_repair import (
find_dangling_entries,
repair_repo_cache,
repo_cache_dir,
)
if find_dangling_entries(repo_cache_dir(spec.repo_id, cache_dir)):
repair = repair_repo_cache(spec.repo_id, cache_dir)
installed = _installed_snapshot(spec)
if installed:
return installed
if not repair.get("ok"):
logger.warning(
"sherpa dictation: cache repair for %s failed: %s",
spec.repo_id,
repair.get("error") or repair.get("outcome") or "unknown error",
)
logger.info("sherpa dictation: downloading %s on first use", spec.repo_id)
return snapshot_download(
snapshot = snapshot_download(
repo_id=spec.repo_id,
revision=revision_for(spec.repo_id),
allow_patterns=wanted,
cache_dir=_live_hub_cache_dir(),
cache_dir=cache_dir,
)
missing = [
name for name in wanted
if not _usable_model_file(os.path.join(snapshot, name))
]
if not missing:
return snapshot
# Verify after the Hub reports success. This catches hosts where a broken
# snapshot entry short-circuits snapshot_download. The generic repair
# removes only broken entries, preserves blobs, and retries the immutable
# installed revision.
repair = repair_repo_cache(spec.repo_id, cache_dir)
installed = _installed_snapshot(spec)
if installed:
return installed
detail = repair.get("error") or repair.get("outcome") or "repair did not restore them"
raise FileNotFoundError(
f"Sherpa model cache is incomplete for {spec.repo_id}; missing "
f"{', '.join(missing)}. Cache repair failed: {detail}. Reinstall this "
"model from Model Catalogue."
)
@@ -324,8 +377,10 @@ def _installed_snapshot(spec: SherpaModelSpec) -> str | None:
"snapshots",
revision,
)
if all(os.path.isfile(os.path.join(snapshot, filename))
for filename in spec.files.values()):
if all(
_usable_model_file(os.path.join(snapshot, filename))
for filename in spec.files.values()
):
return snapshot
return None
+27
View File
@@ -123,6 +123,33 @@ on `127.0.0.1` and do not run untrusted workloads in this container. See AMD's
[`librocdxg` WSL container instructions](https://github.com/ROCm/librocdxg#4-container-launch--wsl-specific-flags)
for the driver/runtime compatibility matrix.
#### WSL2 architecture compatibility matrix
VoiceStudio classifies the architecture result separately from device-node
visibility. `/dev/dxg` alone is not proof of acceleration; a supported claim
also needs the runtime probe, application routing, a completed workload, and
GPU-utilization evidence.
| Classification | Evidence required | VoiceStudio behavior |
|---|---|---|
| **Supported** | The native GFX tag is in the shipped PyTorch architecture list, and the named hardware has a published successful workload with GPU-utilization evidence. | Report the measured provider and device from Settings and diagnostics. |
| **Best-effort override** | The native tag is absent, a mapped target is present in the PyTorch build, and `HSA_OVERRIDE_GFX_VERSION` is applied. No hardware validation is implied. | Attempt the mapped kernels; capture execution evidence and treat failures as unsupported for that host. |
| **Unverified** | The bridge or override is configured, but no published end-to-end result exists for the named card and stack. | Do not advertise the card as supported; run the checks below before relying on it. |
| **Unsupported** | Neither the native tag nor a usable mapped target is present, or the runtime/workload rejects the device. | Use an intentional CPU route or a different supported accelerator. |
| Hardware / architecture | Current classification | Detail |
|---|---|---|
| AMD Radeon RX 6700 XT / `gfx1031` through WSL2 ROCDXG | **Unverified** | VoiceStudio can map `gfx1031` to `gfx1030` when that target exists in the PyTorch build, but no RX 6700 XT end-to-end validation has been published. |
For an RX 6700 XT result to move out of **Unverified**, record the Windows AMD
driver, WSL kernel/distribution, image and ROCDXG/ROCm versions,
`torch.version.hip`, device name/count and compiled architecture list, effective
HSA override, `rocminfo`, VoiceStudio self-check and engine-routing output, and
one successful PyTorch TTS and ASR workload with utilization plus cold/warm
latency. Record whether either workload fell back to CPU and, when it did, the
CPU fallback stage and reason reported by VoiceStudio. A CPU-only completion
does not qualify as successful GPU validation.
The same flags work with **Podman** (`podman run --device /dev/kfd
--device /dev/dri …`); in a **Quadlet** unit that's two `AddDevice=` lines:
+15 -3
View File
@@ -20,7 +20,6 @@ from __future__ import annotations
import re
import sys
from time import perf_counter
import pytest
@@ -549,17 +548,30 @@ def test_select_mlx_audio_repo_id_accepts_underscore_prefixes(fresh_app, monkeyp
)
def test_select_mlx_audio_rejects_malformed_repo_ids(fresh_app, monkeypatch, model_id):
_make_mlx_audio_available(monkeypatch)
started = perf_counter()
r = _client(fresh_app).post(
"/engines/select",
json={"family": "tts", "backend_id": "mlx-audio", "model_id": model_id},
)
assert r.status_code == 400
assert perf_counter() - started < 0.5
assert len(r.content) < 256
assert model_id[:100] not in r.text
def test_hf_repo_id_size_bound_precedes_library_validation(fresh_app, monkeypatch):
from api.routers import engines as engines_router
def unexpected_validation(_value):
raise AssertionError("oversized repo id reached the library validator")
monkeypatch.setattr(
engines_router.hf_utils,
"validate_repo_id",
unexpected_validation,
)
assert not engines_router._is_hf_repo_id("-" * 100_000)
def test_select_mlx_audio_without_model_id_does_not_touch_pref(fresh_app, monkeypatch):
"""Selecting mlx-audio without a model_id (e.g. an older frontend) must
leave any existing mlx_audio_model_id pref untouched."""
+24
View File
@@ -72,3 +72,27 @@ def test_wsl_rocm_command_carries_the_complete_dxg_bridge():
"--security-opt seccomp=unconfined",
):
assert required in section
def test_wsl_rocm_matrix_marks_rx_6700_xt_unverified():
text = (ROOT / "docs/install/docker.md").read_text(encoding="utf-8")
section = text.split("#### WSL2 architecture compatibility matrix", 1)[1].split(
"###", 1
)[0]
for classification in (
"Supported",
"Best-effort override",
"Unverified",
"Unsupported",
):
assert f"| **{classification}** |" in section
rx_row = next(line for line in section.splitlines() if "RX 6700 XT" in line)
assert "`gfx1031`" in rx_row
assert "**Unverified**" in rx_row
assert "`gfx1030`" in rx_row
assert "`/dev/dxg` alone is not proof of acceleration" in section
assert "CPU fallback stage and reason" in section
assert "CPU-only completion" in section
assert "successful GPU validation" in section
+17
View File
@@ -339,3 +339,20 @@ class TestAudioOnlyDubbing:
assert response.status_code == 202
assert queued[0][5]["source_lang"] == "fr"
def test_asr_detected_source_languages_can_be_reused_as_overrides(self, app_client):
_client, dc, _dx, _tmp = app_client
detected_codes = {
"as", "ba", "bo", "br", "fo", "lb", "ln", "mg", "nn", "oc",
"sa", "tk", "tl", "tt", "yue", "zh",
}
for code in detected_codes:
assert dc._source_lang_override(code) == code
def test_asr_detected_cantonese_code_is_not_truncated(self, app_client):
_client, dc, _dx, _tmp = app_client
assert dc._detected_source_lang("yue") == "yue"
assert dc._detected_source_lang("es_ES") == "es"
assert dc._detected_source_lang("unknown-language") == "en"
+45 -2
View File
@@ -208,12 +208,17 @@ def test_model_resolution_pins_offline_probe_and_download(monkeypatch, tmp_path)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
calls = []
downloaded = tmp_path / "downloaded"
def fake_snapshot(**kwargs):
calls.append(kwargs)
return "/cache/pinned"
downloaded.mkdir()
for filename in spec.files.values():
(downloaded / filename).write_bytes(b"model")
return str(downloaded)
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot)
assert sd._resolve_model_dir(spec) == "/cache/pinned"
assert sd._resolve_model_dir(spec) == str(downloaded)
assert calls == [{
"repo_id": spec.repo_id,
"revision": hf_revisions.revision_for(spec.repo_id),
@@ -222,6 +227,44 @@ def test_model_resolution_pins_offline_probe_and_download(monkeypatch, tmp_path)
}]
def test_model_resolution_repairs_broken_snapshot_before_loading(monkeypatch, tmp_path):
"""A zero-byte ONNX entry must be repaired before sherpa receives it (#1733)."""
from services import hf_cache_repair, sherpa_dictation as sd
import huggingface_hub
spec = sd.get_spec("sherpa-whisper-tiny")
revision = "6" * 40
repo = tmp_path / "models--csukuangfj--sherpa-onnx-whisper-tiny"
ref = repo / "refs" / "main"
ref.parent.mkdir(parents=True)
ref.write_text(revision + "\n", encoding="ascii")
snapshot = repo / "snapshots" / revision
snapshot.mkdir(parents=True)
broken = snapshot / spec.files["encoder"]
broken.write_bytes(b"")
for role in ("decoder", "tokens"):
(snapshot / spec.files[role]).write_bytes(b"model")
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
repairs = []
def fake_repair(repo_id, cache_dir):
repairs.append((repo_id, cache_dir))
broken.write_bytes(b"restored model")
return {"ok": True, "outcome": "healed_with_copies", "error": ""}
monkeypatch.setattr(hf_cache_repair, "repair_repo_cache", fake_repair)
monkeypatch.setattr(
huggingface_hub,
"snapshot_download",
lambda **_kwargs: pytest.fail("a repaired snapshot must be reused"),
)
assert sd._resolve_model_dir(spec) == str(snapshot)
assert repairs == [(spec.repo_id, str(tmp_path))]
assert broken.read_bytes() == b"restored model"
def test_model_resolution_probes_preserved_legacy_snapshot(monkeypatch, tmp_path):
from services import hf_revisions, sherpa_dictation as sd
import huggingface_hub