Merge remote-tracking branch 'origin/main' into fix/backend-startup-budget-1749
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user