Merge remote-tracking branch 'origin/main' into land/queue2
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -19,7 +19,10 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Transcriptions dictation wakes the desktop recorder, presents one contextual start action, and centers its microphone icon with the label (#1902)
|
||||
- Colab transcription and dubbing now include an explicit ASR model setup step (#1922) — thanks @nidhi-singh02!
|
||||
|
||||
- Apple Silicon now shows one canonical OmniVoice choice in the engine picker while retaining its automatic crash-isolated sidecar runtime (#1913)
|
||||
- Validate current-user Windows installers under a standard account on hosted runners (#1883)
|
||||
- Model downloads survive a flaky connection instead of restarting from zero (#1940)
|
||||
- `bun run dev` recovers on Windows instead of demanding Task Manager (#1941)
|
||||
|
||||
- The desktop app builds and opens from a fresh clone again (#1818) — thanks @flutterkage2k!
|
||||
- GPUs with less VRAM than the engine needs no longer get half the compute-time budget a CPU gets (#1806) — thanks @VishvakR!
|
||||
@@ -69,6 +72,21 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Clearing the logs now empties the rotated log files too, so it frees the space it appears to (#1920)
|
||||
- An error thrown by a browser extension no longer offers to file itself as a VoiceStudio bug (#1901)
|
||||
- Clearing the desktop logs no longer wipes the backend's stderr, which is the only record a native crash leaves behind and is meant to survive a respawn (#1510)
|
||||
- Long audiobook chapters now use the same device- and text-length-aware synthesis timeout as other TTS routes (#1910) — thanks @psiberfunk!
|
||||
|
||||
- Interrupted audiobook renders can resume cached chapters after tab navigation, and their chapter cache is available from the recovery card (#1911) — thanks @psiberfunk!
|
||||
- System-check details and storage paths beginning with a number or a slash no longer render with their leading text moved to the end of the line (#1848) — thanks @psiberfunk!
|
||||
- An unavailable engine's row now links to that engine's guide, so the generic "check installation and configuration" message has somewhere to send you (#1866) — thanks @psiberfunk!
|
||||
- The backend log now records which engine failed a health check and whether its probe raised, instead of a line that identified neither (#1866) — thanks @psiberfunk!
|
||||
- The first-run Activity log counts every line instead of freezing at 200 while the install is still running, and Copy now hands back the whole run rather than the last 200 lines (#1847) — thanks @psiberfunk!
|
||||
- A first-run failure that happened early in a long install keeps its specific advice, instead of falling back to the generic retry hint once the log scrolled past 200 lines (#1847) — thanks @psiberfunk!
|
||||
- Opening the log panel no longer clips the Launchpad's heading and slides the feature cards up over it — the page scrolls instead of squashing itself (#1859) — thanks @psiberfunk!
|
||||
- Segmented model downloads split files into 16 MB ranges instead of one range per connection, so a dropped connection refetches one range rather than restarting the file (#1940)
|
||||
- The download accelerator is kept across retries after a transient network failure and resumes from its manifest, instead of falling back to a from-zero `snapshot_download` (#1940)
|
||||
- `dev-backend.mjs` stops the backend by process tree on Windows, so an orphaned uvicorn no longer holds port 3900 and turns a source reload into three phantom crashes (#1941)
|
||||
- `clear-dev-ports.mjs` can free a stuck development port on Windows again, bound to the inspected process instance so a recycled pid is never terminated (#1941)
|
||||
- Checkout-ownership matching no longer resolves POSIX paths with the host's separator, which made the guard's own test fail on Windows (#1941)
|
||||
|
||||
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
|
||||
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
|
||||
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
|
||||
|
||||
@@ -229,7 +229,7 @@ Engine support is capability-specific. Check cloning, language, platform, memory
|
||||
| [**Sherpa-ONNX**](docs/engines/sherpa-onnx.md) | 20+ | No | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| [**IndexTTS 2.5** ⚡](docs/engines/indextts.md) | ZH · EN · JA · ES · AR | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ |
|
||||
| [**OmniVoice GGUF** ⚡](docs/engines/omnivoice-gguf.md) | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [review the derivative model terms](https://huggingface.co/Serveurperso/OmniVoice-GGUF#license)³ |
|
||||
| [**OmniVoice (subprocess)** ⚡](docs/engines/omnivoice-subprocess.md) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0 code, CC-BY-NC weights](https://huggingface.co/k2-fsa/OmniVoice#license)³ |
|
||||
| [**OmniVoice (subprocess; opt-in off MPS)** ⚡](docs/engines/omnivoice-subprocess.md) | 600+ | Yes | Yes | CUDA/CPU | MPS via default OmniVoice | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0 code, CC-BY-NC weights](https://huggingface.co/k2-fsa/OmniVoice#license)³ |
|
||||
| [**PocketTTS** ⚡](docs/engines/pockettts.md) | EN · FR · DE · PT · IT · ES | Yes | No | CPU | CPU | CPU | CC-BY-4.0, gated² |
|
||||
| [**Supertonic 3** ⚡](docs/engines/supertonic3.md) | 31 | No | No | CPU | CPU | CPU | OpenRAIL-M |
|
||||
| [**MOSS-TTS-v1.5** ⚡](docs/engines/moss-tts-v15.md) | 31 | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
|
||||
@@ -36,7 +36,11 @@ def public_backends(entries: list[dict]) -> list[dict]:
|
||||
"""Copy registry entries while replacing service diagnostics.
|
||||
|
||||
Availability probes may contain exception text, local paths, tracebacks, or
|
||||
credentials. Installation hints are registry-authored and remain intact.
|
||||
credentials. Registry-authored fields are not probe output and remain
|
||||
intact: ``install_hint``, ``setup_snippet`` and ``docs_url`` are all
|
||||
VoiceStudio-owned constants keyed on the engine id, so an unavailable row
|
||||
still has something actionable to show and somewhere to send the user
|
||||
(#1866) even though ``reason``/``last_error`` are replaced here.
|
||||
"""
|
||||
safe: list[dict] = []
|
||||
for entry in entries:
|
||||
|
||||
@@ -718,6 +718,10 @@ def _remote_chapter_call(chapter, *, engine_id, default_voice, voice_map,
|
||||
"expressive": opts.to_manifest(), "watermark": bool(watermark_enabled()),
|
||||
}
|
||||
signature = hashlib.sha256(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
|
||||
# The worker synthesizes from ``spans``, but the gateway and scheduler read
|
||||
# top-level ``text`` to scale the remote execution deadline. Add this after
|
||||
# the signature so existing content-addressed remote cache keys still hit.
|
||||
params["text"] = "\n".join(row["text"] for row in rows)
|
||||
wav_path = os.path.join(cache_dir, f"remote-{signature}.wav")
|
||||
|
||||
def decode(result):
|
||||
@@ -739,7 +743,7 @@ async def _run_chapter(chapter, *, operation="audiobook", decision, job, default
|
||||
voice_map, lexicon, cache_dir):
|
||||
"""Run one chapter through the gateway; local preparation stays lazy."""
|
||||
from services import gpu_gateway
|
||||
from services.tts_backend import active_backend_id
|
||||
from services.tts_backend import active_backend_id, get_backend_class
|
||||
|
||||
engine_id = active_backend_id()
|
||||
remote, remote_cache = _remote_chapter_call(
|
||||
@@ -753,15 +757,27 @@ async def _run_chapter(chapter, *, operation="audiobook", decision, job, default
|
||||
return remote_cache, float(info.duration), True, None
|
||||
|
||||
async def prepare_local():
|
||||
from services.model_manager import generate_timeout_s
|
||||
|
||||
synth, sr, resolve, local_engine = await _prepare_synth(
|
||||
default_voice, language=language, opts=opts, voice_map=voice_map
|
||||
)
|
||||
try:
|
||||
timeout_engine = get_backend_class(local_engine)
|
||||
except ValueError:
|
||||
# Tests and third-party integrations may inject a synth under a
|
||||
# non-catalogue id. Keep the canonical host/text policy available;
|
||||
# registered production engines still add their routing metadata.
|
||||
timeout_engine = None
|
||||
return gpu_gateway.LocalCall(
|
||||
fn=lambda: _render_chapter_cached(
|
||||
chapter, synth, sr, local_engine, resolve, cache_dir, lexicon,
|
||||
language, opts, voice_map,
|
||||
),
|
||||
what="Audiobook chapter",
|
||||
timeout=generate_timeout_s(
|
||||
remote.params["text"], engine=timeout_engine
|
||||
),
|
||||
)
|
||||
|
||||
return await gpu_gateway.run(
|
||||
|
||||
@@ -42,10 +42,26 @@ _FAMILIES = {
|
||||
}
|
||||
|
||||
|
||||
def _catalogue_active_id(family: str, module) -> str:
|
||||
"""Return the active id represented by the public engine catalogue."""
|
||||
active = module.active_backend_id()
|
||||
if family != "tts" or active != "omnivoice-subprocess":
|
||||
return active
|
||||
|
||||
from core.device_caps import detect_host_caps
|
||||
|
||||
try:
|
||||
return "omnivoice" if detect_host_caps().family == "mps" else active
|
||||
except Exception:
|
||||
return active
|
||||
|
||||
|
||||
def _family_payload(family: str, module):
|
||||
"""Public inventory plus whether an environment pin owns this family."""
|
||||
return {
|
||||
"active": module.active_backend_id(),
|
||||
# MPS hides the explicit compatibility row, so legacy configs report
|
||||
# the visible canonical equivalent as active to picker consumers.
|
||||
"active": _catalogue_active_id(family, module),
|
||||
"env_override": bool(os.environ.get(f"OMNIVOICE_{family.upper()}_BACKEND")),
|
||||
"backends": public_backends(module.list_backends()),
|
||||
}
|
||||
@@ -354,6 +370,9 @@ def engine_health(engine_id: str):
|
||||
)
|
||||
|
||||
t0 = perf_counter()
|
||||
# Stable exception class when the probe itself raised, None when it merely
|
||||
# returned not-available. Never the exception text — see the log line below.
|
||||
raised_class: str | None = None
|
||||
if hasattr(cls, "health_check"):
|
||||
# SubprocessBackend path — spawn sidecar (if not running) and ping.
|
||||
# ``health_check`` already swallows its own exceptions per Plan
|
||||
@@ -364,6 +383,7 @@ def engine_health(engine_id: str):
|
||||
ok, msg = instance.health_check()
|
||||
except Exception as exc:
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
raised_class = type(exc).__name__
|
||||
else:
|
||||
# In-process backend — `is_available()` is the classmethod-level
|
||||
# liveness check. Cheap and side-effect-free for every shipping
|
||||
@@ -372,6 +392,7 @@ def engine_health(engine_id: str):
|
||||
ok, msg = cls.is_available()
|
||||
except Exception as exc:
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
raised_class = type(exc).__name__
|
||||
|
||||
# Engine-owned output can contain much more than shaped HF tokens: local
|
||||
# paths, arbitrary credentials, source lines, or a nested traceback.
|
||||
@@ -379,7 +400,38 @@ def engine_health(engine_id: str):
|
||||
|
||||
latency_ms = (perf_counter() - t0) * 1000.0
|
||||
if not ok:
|
||||
logger.warning("Engine health check failed; details withheld")
|
||||
# The response tells the user to "check the backend log for details",
|
||||
# and docs/engines/*.md asks a user diagnosing an unavailable engine to
|
||||
# copy that engine's log lines. The old line named neither the engine
|
||||
# nor anything about the probe, so neither instruction could be
|
||||
# followed (#1866).
|
||||
#
|
||||
# `probe=` reports what the PROBE DID, not what went wrong. It cannot
|
||||
# classify the cause: SubprocessBackend.health_check() swallows its own
|
||||
# exceptions per Plan 02-01's contract, so a dead sidecar and a package
|
||||
# that was never installed both arrive here as `returned-unavailable`.
|
||||
# Separating those needs structured failure metadata from the probes
|
||||
# themselves, which is a wider change than this one.
|
||||
#
|
||||
# Still no diagnostic text and still not the caller-supplied id: the
|
||||
# engine id comes off the resolved registry class and a raised probe
|
||||
# contributes only its exception class, the same shape
|
||||
# core.public_errors.public_failure() logs as `class=`.
|
||||
# tests/test_response_safety.py pins that boundary and passes
|
||||
# unchanged.
|
||||
#
|
||||
# The id is a class attribute off the registry rather than caller
|
||||
# input, but this line is a log-injection surface either way, so it is
|
||||
# flattened to a single token before it goes in.
|
||||
engine_label = str(getattr(cls, "id", None) or cls.__name__)
|
||||
engine_label = "".join(
|
||||
c if (c.isalnum() or c in "-_.") else "-" for c in engine_label
|
||||
)[:64]
|
||||
logger.warning(
|
||||
"Engine health check failed; engine=%s probe=%s, details withheld",
|
||||
engine_label or "unknown",
|
||||
f"raised:{raised_class}" if raised_class else "returned-unavailable",
|
||||
)
|
||||
return {
|
||||
"id": engine_id,
|
||||
"ok": bool(ok),
|
||||
@@ -589,7 +641,15 @@ def select_engine(req: SelectEngineRequest):
|
||||
if not family:
|
||||
raise HTTPException(400, f"Unknown family: {req.family}. Expected one of tts/asr/llm.")
|
||||
module, pref_key = family
|
||||
available = {b["id"]: b for b in module.list_backends()}
|
||||
# MPS intentionally hides the redundant explicit OmniVoice sidecar from
|
||||
# the picker, but existing scripts and saved preferences may still submit
|
||||
# that supported compatibility id directly.
|
||||
rows = (
|
||||
module.list_backends(include_hidden=True)
|
||||
if req.family == "tts"
|
||||
else module.list_backends()
|
||||
)
|
||||
available = {b["id"]: b for b in rows}
|
||||
if req.backend_id not in available:
|
||||
raise HTTPException(400, f"Unknown {req.family} backend: {req.backend_id!r}")
|
||||
entry = available[req.backend_id]
|
||||
|
||||
@@ -381,6 +381,51 @@ def _is_retryable_download_error(exc: BaseException) -> bool:
|
||||
return is_hf_connectivity_error(str(exc))
|
||||
|
||||
|
||||
def _segmented_retry_plan(
|
||||
exc: BaseException, attempt: int, max_attempts: int
|
||||
) -> tuple[bool, bool]:
|
||||
"""What to do after the segmented accelerator failed on ``attempt``.
|
||||
|
||||
Returns ``(disable_accelerator, reraise)``.
|
||||
|
||||
A dropped connection is not the accelerator's fault, so the error is
|
||||
re-raised for the outer retry: the next attempt re-enters
|
||||
:func:`_segmented_snapshot`, which resumes from the ``.part`` manifest.
|
||||
Falling straight through to ``snapshot_download`` instead would finish the
|
||||
install from a separate ``.incomplete`` file and strand that manifest — the
|
||||
restart-from-zero this exists to prevent.
|
||||
|
||||
The final attempt is always reserved for the plain path, so the accelerator
|
||||
can never be the reason an install fails outright. The two flags are
|
||||
decoupled for that handover: the attempt that exhausts the accelerator still
|
||||
re-raises, so the plain path starts on the LAST attempt rather than the
|
||||
second-to-last. Disabling and falling through in the same attempt would
|
||||
abandon the resumable manifest one attempt early and restart through a
|
||||
separate file — which is the failure this whole helper exists to avoid.
|
||||
"""
|
||||
if not _is_retryable_download_error(exc):
|
||||
return True, False # the accelerator cannot work here at all
|
||||
if attempt >= max_attempts:
|
||||
# Nothing left to hand over to: take the plain path now rather than
|
||||
# re-raising out of the loop with no fallback ever tried.
|
||||
return True, False
|
||||
return attempt >= max_attempts - 1, True
|
||||
|
||||
|
||||
def _segmented_retry_note(disable: bool, reraise: bool) -> str:
|
||||
"""How to describe the outcome of :func:`_segmented_retry_plan` in the log.
|
||||
|
||||
Three distinct states, and reading only ``disable`` conflates two of them:
|
||||
the attempt that exhausts the accelerator is disabled AND re-raises, so the
|
||||
fallback starts on the NEXT attempt, not this one.
|
||||
"""
|
||||
if not disable:
|
||||
return "kept for the next attempt (resumes from its manifest)"
|
||||
if reraise:
|
||||
return "exhausted — retrying once more, then snapshot_download takes over"
|
||||
return "disabled for this install — falling back to snapshot_download now"
|
||||
|
||||
|
||||
@router.post("/models/install")
|
||||
async def install_model(req: InstallModelRequest):
|
||||
"""Download one HF repo snapshot; progress goes through the shared
|
||||
@@ -557,6 +602,11 @@ async def install_model(req: InstallModelRequest):
|
||||
|
||||
_max_attempts = 5
|
||||
_attempt = 0
|
||||
# The accelerator is retried across attempts so its manifest-based
|
||||
# resume actually gets used; it is disabled for the rest of the
|
||||
# install only when it fails for a reason that is NOT transient
|
||||
# network trouble (i.e. the accelerator itself is unusable here).
|
||||
_segmented_off = False
|
||||
while True:
|
||||
if req.repo_id in _cancelled:
|
||||
raise _InstallCancelled()
|
||||
@@ -564,12 +614,13 @@ async def install_model(req: InstallModelRequest):
|
||||
try:
|
||||
# Segmented accelerator (FDL-09, default ON): parallel
|
||||
# byte-range fetch with real live progress, for the
|
||||
# legacy-LFS path. Any failure falls through to
|
||||
# snapshot_download — the accelerator can never compromise a
|
||||
# correct install.
|
||||
# legacy-LFS path. A failure that is not transient network
|
||||
# trouble falls through to snapshot_download, and so does the
|
||||
# install's last attempt — the accelerator can never
|
||||
# compromise a correct install (see _segmented_retry_plan).
|
||||
_snapshot_path = None
|
||||
if (
|
||||
_attempt == 1
|
||||
not _segmented_off
|
||||
and not allow_patterns
|
||||
and _segmented_enabled()
|
||||
and not _xet_active()
|
||||
@@ -583,10 +634,16 @@ async def install_model(req: InstallModelRequest):
|
||||
except _InstallCancelled:
|
||||
raise
|
||||
except Exception as _seg_err:
|
||||
logger.info(
|
||||
"segmented download for %s failed (%s); falling back to snapshot_download",
|
||||
req.repo_id, _seg_err,
|
||||
_segmented_off, _seg_reraise = _segmented_retry_plan(
|
||||
_seg_err, _attempt, _max_attempts
|
||||
)
|
||||
logger.info(
|
||||
"segmented download for %s failed (%s); accelerator %s",
|
||||
req.repo_id, _seg_err,
|
||||
_segmented_retry_note(_segmented_off, _seg_reraise),
|
||||
)
|
||||
if _seg_reraise:
|
||||
raise
|
||||
_snapshot_path = None
|
||||
if _snapshot_path is None:
|
||||
_snapshot_path = snapshot_download(**dl_kwargs) # nosec B615 -- immutable revision_for pin
|
||||
|
||||
@@ -190,7 +190,7 @@ def _check_ram() -> dict:
|
||||
def _check_engines() -> dict:
|
||||
try:
|
||||
from services.tts_backend import list_backends, active_backend_id
|
||||
backends = list_backends()
|
||||
backends = list_backends(include_hidden=True)
|
||||
active = active_backend_id()
|
||||
except Exception as e:
|
||||
return _check("engines", "TTS engines", WARN, f"could not enumerate: {e}")
|
||||
@@ -380,7 +380,12 @@ def run_diagnostics(include_network: bool = True, deep: bool = False) -> dict:
|
||||
try:
|
||||
module = importlib.import_module(f"services.{family}_backend")
|
||||
active = module.active_backend_id()
|
||||
row = next((item for item in module.list_backends() if item.get("id") == active), None)
|
||||
rows = (
|
||||
module.list_backends(include_hidden=True)
|
||||
if family == "tts"
|
||||
else module.list_backends()
|
||||
)
|
||||
row = next((item for item in rows if item.get("id") == active), None)
|
||||
if row is not None:
|
||||
engine_execution.append({
|
||||
"family": family,
|
||||
|
||||
@@ -29,6 +29,14 @@ import httpx
|
||||
_HF_AUTH_HOSTS = ("huggingface.co", "hf.co")
|
||||
_DEFAULT_CONNECTIONS = 8
|
||||
_MIN_SEGMENT_BYTES = 4 * 1024 * 1024 # don't split below this — overhead > gain
|
||||
# Cap on a single segment. Progress is committed to the manifest only when a
|
||||
# whole segment lands, so the segment size is also the MOST bytes a dropped
|
||||
# connection can throw away. Sizing segments as size/num_connections made that
|
||||
# ~100 MB on an 800 MB blob: on a link that drops every ~50 MB no segment ever
|
||||
# completed, the manifest was never written, and every retry restarted from
|
||||
# zero (#1224 follow-up). Bounded segments turn the same flaky link into steady
|
||||
# forward progress.
|
||||
_MAX_SEGMENT_BYTES = 16 * 1024 * 1024
|
||||
_READ_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
@@ -67,8 +75,16 @@ async def _resolve(client: httpx.AsyncClient, url: str, token: Optional[str], ma
|
||||
|
||||
|
||||
def _plan_segments(size: int, num_connections: int) -> list[tuple[int, int]]:
|
||||
"""Byte ranges to fetch, each at most ``_MAX_SEGMENT_BYTES``.
|
||||
|
||||
``num_connections`` controls how many run at once (see the semaphore in
|
||||
:func:`segmented_download`), NOT how many segments exist — a large file is
|
||||
split into many bounded segments so each one commits to the manifest
|
||||
quickly and a dropped connection costs at most one segment.
|
||||
"""
|
||||
n = max(1, min(num_connections, max(1, size // _MIN_SEGMENT_BYTES)))
|
||||
step = -(-size // n) # ceil
|
||||
step = max(_MIN_SEGMENT_BYTES, min(step, _MAX_SEGMENT_BYTES))
|
||||
segs = []
|
||||
start = 0
|
||||
while start < size:
|
||||
@@ -143,6 +159,10 @@ async def segmented_download(
|
||||
_preallocate(part, size)
|
||||
segments = [s for s in _plan_segments(size, num_connections) if s not in done]
|
||||
lock = asyncio.Lock()
|
||||
# Segments are bounded, so a big file yields many more of them than
|
||||
# there are connections. The semaphore — not the segment count — is
|
||||
# what keeps concurrency at num_connections.
|
||||
sem = asyncio.Semaphore(max(1, num_connections))
|
||||
|
||||
async def _fetch(seg: tuple[int, int]):
|
||||
start, end = seg
|
||||
@@ -169,8 +189,12 @@ async def segmented_download(
|
||||
done.add(seg)
|
||||
_save_done(part, size, done)
|
||||
|
||||
async def _fetch_limited(seg: tuple[int, int]):
|
||||
async with sem:
|
||||
await _fetch(seg)
|
||||
|
||||
if segments:
|
||||
await asyncio.gather(*(_fetch(s) for s in segments))
|
||||
await asyncio.gather(*(_fetch_limited(s) for s in segments))
|
||||
|
||||
# ── verify ──────────────────────────────────────────────────────
|
||||
actual = os.path.getsize(part)
|
||||
|
||||
@@ -2392,6 +2392,51 @@ _SETUP_SNIPPETS: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
# Per-engine documentation page, as a repo-relative path (#1866). Every one of
|
||||
# these docs already exists and several are CI-guarded against the code they
|
||||
# describe (e.g. tests/test_cosyvoice_install_docs.py), but nothing in the app
|
||||
# linked to them, so the point of failure — an unavailable engine row — was a
|
||||
# dead end. Paths rather than URLs so tests/test_engine_docs.py can assert the
|
||||
# file is really there; the URL is built once, at read time, from core.links.
|
||||
#
|
||||
# Keyed on the engine id, so it stays correct when the doc filename does not
|
||||
# match the id (indextts2 → indextts.md).
|
||||
_ENGINE_DOCS: dict[str, str] = {
|
||||
"omnivoice": "docs/engines/omnivoice.md",
|
||||
"omnivoice-subprocess": "docs/engines/omnivoice-subprocess.md",
|
||||
"omnivoice-gguf": "docs/engines/omnivoice-gguf.md",
|
||||
"cosyvoice": "docs/engines/cosyvoice.md",
|
||||
"kittentts": "docs/engines/kittentts.md",
|
||||
"mlx-audio": "docs/engines/mlx-audio.md",
|
||||
"voxcpm2": "docs/engines/voxcpm2.md",
|
||||
"moss-tts-nano": "docs/engines/moss-tts-nano.md",
|
||||
"moss-tts-v15": "docs/engines/moss-tts-v15.md",
|
||||
"dots-tts": "docs/engines/dots-tts.md",
|
||||
"confucius4-tts": "docs/engines/confucius4-tts.md",
|
||||
"indextts2": "docs/engines/indextts.md",
|
||||
"gpt-sovits": "docs/engines/gpt-sovits.md",
|
||||
"sherpa-onnx": "docs/engines/sherpa-onnx.md",
|
||||
"supertonic3": "docs/engines/supertonic3.md",
|
||||
"pockettts": "docs/engines/pockettts.md",
|
||||
"audiocpp": "docs/engines/audio-cpp.md",
|
||||
}
|
||||
|
||||
|
||||
def _engine_docs_url(bid: str) -> str | None:
|
||||
"""Public URL of this engine's doc page, or None when it has none.
|
||||
|
||||
VoiceStudio-owned constant either way: the path comes from the registry
|
||||
above and the base from :mod:`core.links`, so no part of it is derived
|
||||
from an engine probe. That is what lets it cross the public boundary
|
||||
intact (see api.public_engine_metadata).
|
||||
"""
|
||||
path = _ENGINE_DOCS.get(bid)
|
||||
if not path:
|
||||
return None
|
||||
from core import links
|
||||
return f"{links.PROJECT_REPO_BLOB_MAIN}/{path}"
|
||||
|
||||
|
||||
# Short, readable labels for mlx-audio's curated models (#981) — surfaced in
|
||||
# the Model Catalogue → Engines model picker so users see more than a bare key.
|
||||
# Single-sourced here rather than on MLXAudioBackend.CURATED_MODELS itself so
|
||||
@@ -2424,8 +2469,15 @@ def _sidecar_installable_ids() -> frozenset[str]:
|
||||
return frozenset()
|
||||
|
||||
|
||||
def list_backends() -> list[dict]:
|
||||
"""Enumerate every registered backend with its availability state.
|
||||
def list_backends(*, include_hidden: bool = False) -> list[dict]:
|
||||
"""Enumerate the engine catalogue with each backend's availability state.
|
||||
|
||||
On MPS, the canonical ``omnivoice`` id already resolves to the killable
|
||||
OmniVoice sidecar. The explicit ``omnivoice-subprocess`` compatibility id
|
||||
is therefore omitted from the normal catalogue so the picker does not
|
||||
advertise two choices with the same runtime behavior. Internal callers
|
||||
that must validate or preserve a stored compatibility id can pass
|
||||
``include_hidden=True``.
|
||||
|
||||
Per-entry shape (ENGINE-05 + ENGINE-06):
|
||||
|
||||
@@ -2439,6 +2491,7 @@ def list_backends() -> list[dict]:
|
||||
# e.g. VoxCPM2's >=2.0.3 upgrade hint)
|
||||
"install_hint": Optional[str],
|
||||
"setup_snippet": Optional[str], # exact `export VAR=...` for path-gated opt-in engines
|
||||
"docs_url": Optional[str], # this engine's doc page (registry-authored constant)
|
||||
"one_click_install": bool, # services.sidecar_install can provision it in-app
|
||||
"last_error": Optional[str], # cached most-recent failure
|
||||
"isolation_mode": "in-process" | "subprocess",
|
||||
@@ -2477,6 +2530,12 @@ def list_backends() -> list[dict]:
|
||||
|
||||
out: list[dict] = []
|
||||
for bid, cls in _REGISTRY.items():
|
||||
if (
|
||||
not include_hidden
|
||||
and caps.family == "mps"
|
||||
and bid == "omnivoice-subprocess"
|
||||
):
|
||||
continue
|
||||
cls = _effective_backend_class(bid, cls, caps.family)
|
||||
try:
|
||||
ok, msg = cls.is_available()
|
||||
@@ -2551,6 +2610,10 @@ def list_backends() -> list[dict]:
|
||||
"install_hint": _INSTALL_HINTS.get(bid),
|
||||
# Exact `export VAR=...` line for path-gated opt-in engines, or None.
|
||||
"setup_snippet": _SETUP_SNIPPETS.get(bid),
|
||||
# This engine's doc page (#1866). Registry-authored constant, so it
|
||||
# survives api.public_engine_metadata and gives an unavailable row
|
||||
# somewhere to send the user.
|
||||
"docs_url": _engine_docs_url(bid),
|
||||
# True when services.sidecar_install can provision this engine
|
||||
# in-app (Settings renders an Install button instead of leading
|
||||
# with the manual setup snippet).
|
||||
@@ -2650,7 +2713,9 @@ def active_routing() -> dict | None:
|
||||
"""
|
||||
try:
|
||||
active = active_backend_id()
|
||||
for b in list_backends():
|
||||
# The MPS picker intentionally hides the redundant compatibility id,
|
||||
# but routing must still describe a saved or environment-pinned id.
|
||||
for b in list_backends(include_hidden=True):
|
||||
if b.get("id") == active:
|
||||
return {
|
||||
"engine": active,
|
||||
|
||||
@@ -160,6 +160,96 @@ def test_engine_catalogue_reports_effective_mps_isolation(monkeypatch):
|
||||
assert row["isolation_mode"] == "subprocess"
|
||||
|
||||
|
||||
def test_mps_catalogue_hides_redundant_explicit_omnivoice_sidecar(monkeypatch):
|
||||
"""The picker advertises the canonical id, while legacy callers retain both."""
|
||||
from core.device_caps import HostCaps
|
||||
from services import tts_backend
|
||||
|
||||
monkeypatch.setattr(
|
||||
tts_backend,
|
||||
"_REGISTRY",
|
||||
{
|
||||
"omnivoice": OmniVoiceBackend,
|
||||
"omnivoice-subprocess": OmniVoiceSubprocessBackend,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.device_caps.detect_host_caps",
|
||||
lambda: HostCaps(family="mps", available_families=("mps", "cpu")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
OmniVoiceSubprocessBackend,
|
||||
"is_available",
|
||||
classmethod(lambda cls: (True, "ready")),
|
||||
)
|
||||
|
||||
picker_ids = {item["id"] for item in list_backends()}
|
||||
assert picker_ids == {"omnivoice"}
|
||||
assert get_backend_class("omnivoice") is OmniVoiceMPSSubprocessBackend
|
||||
|
||||
all_ids = {item["id"] for item in list_backends(include_hidden=True)}
|
||||
assert all_ids == {"omnivoice", "omnivoice-subprocess"}
|
||||
assert get_backend_class("omnivoice-subprocess") is OmniVoiceSubprocessBackend
|
||||
|
||||
|
||||
def test_mps_active_routing_preserves_hidden_compatibility_id(monkeypatch):
|
||||
from core.device_caps import HostCaps
|
||||
from services import tts_backend
|
||||
|
||||
monkeypatch.setattr(
|
||||
tts_backend,
|
||||
"_REGISTRY",
|
||||
{"omnivoice-subprocess": OmniVoiceSubprocessBackend},
|
||||
)
|
||||
monkeypatch.setattr(tts_backend, "active_backend_id", lambda: "omnivoice-subprocess")
|
||||
monkeypatch.setattr(
|
||||
"core.device_caps.detect_host_caps",
|
||||
lambda: HostCaps(family="mps", available_families=("mps", "cpu")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
OmniVoiceSubprocessBackend,
|
||||
"is_available",
|
||||
classmethod(lambda cls: (True, "ready")),
|
||||
)
|
||||
|
||||
assert tts_backend.active_routing() == {
|
||||
"engine": "omnivoice-subprocess",
|
||||
"available": True,
|
||||
"effective_device": "mps",
|
||||
"routing_status": "accelerated",
|
||||
"routing_reason": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("family", ("cuda", "cpu"))
|
||||
def test_non_mps_catalogue_keeps_explicit_omnivoice_sidecar(monkeypatch, family):
|
||||
from core.device_caps import HostCaps
|
||||
from services import tts_backend
|
||||
|
||||
monkeypatch.setattr(
|
||||
tts_backend,
|
||||
"_REGISTRY",
|
||||
{
|
||||
"omnivoice": OmniVoiceBackend,
|
||||
"omnivoice-subprocess": OmniVoiceSubprocessBackend,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.device_caps.detect_host_caps",
|
||||
lambda: HostCaps(family=family, available_families=(family, "cpu")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
OmniVoiceSubprocessBackend,
|
||||
"is_available",
|
||||
classmethod(lambda cls: (True, "ready")),
|
||||
)
|
||||
|
||||
assert {item["id"] for item in list_backends()} == {
|
||||
"omnivoice",
|
||||
"omnivoice-subprocess",
|
||||
}
|
||||
|
||||
|
||||
def test_mps_startup_does_not_preload_native_model(monkeypatch):
|
||||
from core.device_caps import HostCaps
|
||||
from services import model_manager
|
||||
|
||||
@@ -38,7 +38,19 @@ To keep that path **fast** despite Xet being off, the app runs a built-in
|
||||
**multi-connection (segmented) downloader on by default** — it fetches each file
|
||||
over parallel byte-ranges (IDM/uGet style), so the legacy-LFS path is no longer
|
||||
single-stream. It reports real live speed/ETA and **falls back to the normal
|
||||
download on any error**, so it can never compromise a correct install. Adding a
|
||||
download**, so it can never compromise a correct install.
|
||||
|
||||
Ranges are capped at 16 MB each and eight of them are in flight at a time, so a
|
||||
completed range is committed to a resume manifest every few seconds. On a
|
||||
connection that drops mid-transfer, only the ranges in flight are refetched: the
|
||||
attempt is retried and the accelerator resumes from its manifest rather than
|
||||
starting the file over. An origin that does not serve ranges at all is handled
|
||||
inside the accelerator as a single stream, not as a failure.
|
||||
|
||||
The accelerator is disabled for the rest of the install — handing over to the
|
||||
plain `snapshot_download` path — when it fails for a reason that is not
|
||||
transient network trouble, and on the install's final attempt, so it can never
|
||||
be the reason an install fails outright. Adding a
|
||||
free Hugging Face token (first-run setup, or Settings → Credentials) makes this
|
||||
faster still — authenticated downloads get higher rate limits and fewer stalls.
|
||||
To force the old single-stream path, set `OMNIVOICE_SEGMENTED_DOWNLOAD=0`.
|
||||
|
||||
@@ -5,6 +5,12 @@ quirks. Select engines in **Model Catalogue → Engines** (or quick-switch with
|
||||
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>), or pin one with
|
||||
`OMNIVOICE_TTS_BACKEND` / `OMNIVOICE_ASR_BACKEND`.
|
||||
|
||||
When an engine reports itself unavailable, expand its row's **Why?** panel and
|
||||
use **Learn more** to jump straight to that engine's page here. The row's own
|
||||
message stays deliberately generic — an availability probe can carry local
|
||||
paths or credentials, so it is never shown verbatim — and the page below is
|
||||
where the actual requirements and setup steps live.
|
||||
|
||||
The compute device (CUDA/ROCm/MPS/CPU) is auto-detected; pin it under
|
||||
**Settings → Performance & Device** (or `OMNIVOICE_DEVICE`) if auto-detect
|
||||
picks wrong — see [performance](../performance.md).
|
||||
@@ -36,7 +42,7 @@ approval), [Windows](../install/windows.md), [Linux](../install/linux.md),
|
||||
| Supertonic-3 | [supertonic3](supertonic3.md) | CPU | — (7 preset voices) | `uv sync --extra supertonic` + license |
|
||||
| MOSS-TTS-v1.5 (8B) | [moss-tts-v15](moss-tts-v15.md) | CUDA · CPU | ✅ | clone + env var |
|
||||
| dots.tts (2B) | [dots-tts](dots-tts.md) | CUDA · CPU (not Windows) | ✅ | clone + env var |
|
||||
| OmniVoice (subprocess) | [omnivoice-subprocess](omnivoice-subprocess.md) | CUDA · MPS · CPU | ✅ | opt-in pick, no install |
|
||||
| OmniVoice (subprocess) | [omnivoice-subprocess](omnivoice-subprocess.md) | CUDA · MPS · CPU | ✅ | opt-in pick off MPS; automatic via default OmniVoice on MPS |
|
||||
| PocketTTS (Kyutai) | [pockettts](pockettts.md) | CPU (not Intel Mac) | ✅ | `uv sync --extra pockettts` + license |
|
||||
| Confucius4-TTS | [confucius4-tts](confucius4-tts.md) | CUDA · CPU | ✅ | clone + env var |
|
||||
| audio.cpp (Breeze-TTS-2) | [audio-cpp](audio-cpp.md) | CPU + Vulkan/Metal/CUDA/HIP/ROCm where compiled | ✅ + voice design | prebuilt binary + env var (weights research/non-commercial) |
|
||||
|
||||
@@ -32,12 +32,14 @@ lower call overhead.
|
||||
|
||||
## Selecting it
|
||||
|
||||
- **Model Catalogue → Engines**, or
|
||||
- **Model Catalogue → Engines** on CUDA, ROCm, or CPU, or
|
||||
- `OMNIVOICE_TTS_BACKEND=omnivoice-subprocess`
|
||||
|
||||
The explicit engine is opt-in on CUDA, ROCm, and CPU. Apple Silicon gets the
|
||||
same isolation automatically while keeping the default `omnivoice` id in APIs,
|
||||
Settings, and saved projects.
|
||||
The explicit engine is opt-in on CUDA, ROCm, and CPU. On Apple Silicon it is
|
||||
not listed separately: the canonical `omnivoice` choice automatically uses the
|
||||
same isolation while keeping that default id in APIs, Settings, and saved
|
||||
projects. Existing explicit `omnivoice-subprocess` configuration remains
|
||||
accepted for compatibility.
|
||||
|
||||
## Platform support
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ ignores.
|
||||
| Emotion ("excited", "sad", graded intensity) | IndexTTS2's emotion controls — Audiobook tab's Production Overrides, or the `/ws/tts` API — or CosyVoice 3 instruct | Opt-in engines only |
|
||||
| The same take again | Pin the seed / lock the profile | Default engine |
|
||||
|
||||
## Recovering an interrupted audiobook
|
||||
|
||||
Switching away from the Audiobook tab explicitly interrupts synthesis at a chapter boundary. The Audiobook recovery card lets you resume with its cached chapters, and **Open chapter cache** reveals the chapter audio cache.
|
||||
|
||||
## Why bracket tags work at all (and when they don't)
|
||||
|
||||
Everything you type in the text box reaches the active engine **verbatim** —
|
||||
|
||||
@@ -125,6 +125,33 @@ export async function audiobookGenerate(
|
||||
});
|
||||
}
|
||||
|
||||
export interface ResumableAudiobookJob {
|
||||
job_id: string;
|
||||
type: 'audiobook' | 'longform';
|
||||
status: string;
|
||||
title: string;
|
||||
total_chapters: number;
|
||||
chapters_done: number;
|
||||
created_at: number | null;
|
||||
}
|
||||
|
||||
/** List server-owned longform manifests left by interrupted renders. */
|
||||
export async function audiobookListJobs(): Promise<{ jobs: ResumableAudiobookJob[] }> {
|
||||
const res = await apiFetch('/audiobook/jobs');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Resume one trusted server-side manifest and stream the normal audiobook events. */
|
||||
export async function audiobookResume(
|
||||
jobId: string,
|
||||
opts: { signal?: AbortSignal } = {},
|
||||
): Promise<Response> {
|
||||
return apiFetch(`/audiobook/resume/${encodeURIComponent(jobId)}`, {
|
||||
method: 'POST',
|
||||
signal: opts.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** Upload a cover image; returns the server-side path to pass as `cover_path`. */
|
||||
export async function audiobookUploadCover(file: File): Promise<{ path: string }> {
|
||||
const form = new FormData();
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface EngineBackend {
|
||||
// Copy-paste-ready `export VAR=...` line for a path-gated opt-in engine
|
||||
// (IndexTTS / MOSS-v1.5 / dots.tts / Confucius4), else null/absent.
|
||||
setup_snippet?: string | null;
|
||||
// This engine's documentation page (#1866). A registry-authored constant, so
|
||||
// it survives the public-metadata scrub that replaces `reason`/`last_error`.
|
||||
// Absent on legacy payloads.
|
||||
docs_url?: string | null;
|
||||
// True when the backend's sidecar provisioner can install this engine
|
||||
// in-app (Settings renders an Install button; the manual snippet is
|
||||
// demoted to a collapsible fallback). Absent on legacy payloads.
|
||||
|
||||
@@ -131,7 +131,18 @@ const INSTALL_STAGES = ['downloading_uv', 'creating_venv', 'installing_deps', 'a
|
||||
// anywhere else means a new attempt began (Retry, or a Rust-side restart).
|
||||
const RESTART_STAGES = new Set(['checking', 'awaiting_setup']);
|
||||
|
||||
const MAX_LOG_LINES = 200;
|
||||
// How many log lines the <pre> shows. `logs` state holds exactly this tail and
|
||||
// nothing more, so the per-event array copy stays bounded no matter how long a
|
||||
// bootstrap runs.
|
||||
//
|
||||
// The FULL run lives in `allLogsRef` instead (#1847). Four consumers need every
|
||||
// line — the Activity counter, the Copy button, the actionable failure hints and
|
||||
// the unrecoverable-retry gate — and capping the state they read meant a cold
|
||||
// install silently destroyed its own early output. A ref rather than state
|
||||
// because appending to it is O(1); `totalLines` is what makes a new line
|
||||
// re-render. It lives for exactly one bootstrap: both retry paths clear it, and
|
||||
// App.jsx unmounts the splash the moment the stage flips to 'ready'.
|
||||
const VISIBLE_LOG_LINES = 200;
|
||||
|
||||
/** Scan logs + error message for known failure patterns and return i18n keys
|
||||
* for actionable hints (resolved with `t(...)` at render — English defaults
|
||||
@@ -463,6 +474,12 @@ export function BootstrapSplash({ stage, message }) {
|
||||
// Wall-clock start of the current attempt. A retry restarts the bootstrap;
|
||||
// log lines from the previous attempt must not count toward this one.
|
||||
const [attemptStart, setAttemptStart] = useState(0);
|
||||
// Every line of this bootstrap. `logs` above is only the rendered tail.
|
||||
const [totalLines, setTotalLines] = useState(0);
|
||||
const allLogsRef = useRef([]);
|
||||
// Backfill and the live listener can overlap by a few lines at handover.
|
||||
// Dedup guards that seam and nothing else — see the listener.
|
||||
const handoverDoneRef = useRef(false);
|
||||
const logRef = useRef(null);
|
||||
const prevStageRef = useRef(stage); // previous stage, for restart detection
|
||||
// True between a retry WE initiated and the poll catching up to it, so the
|
||||
@@ -484,17 +501,34 @@ export function BootstrapSplash({ stage, message }) {
|
||||
// context, cf. #1847).
|
||||
const observedStages = useMemo(() => {
|
||||
const seen = new Set(polledStages);
|
||||
for (const entry of logs) {
|
||||
for (const entry of allLogsRef.current) {
|
||||
if (entry?.stage && (entry.t ?? 0) >= attemptStart) seen.add(entry.stage);
|
||||
}
|
||||
return seen;
|
||||
}, [polledStages, logs, attemptStart]);
|
||||
// totalLines is the render signal for allLogsRef, which is a ref and so
|
||||
// cannot itself be a dependency. Reading the full run rather than the
|
||||
// rendered tail is the point: a stage whose only evidence scrolled out of
|
||||
// the capped tail would otherwise read as never having run (#1847).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [polledStages, totalLines, attemptStart]);
|
||||
|
||||
const label = t(`bootstrap.${stage}`, STAGE_LABEL[stage]);
|
||||
const stepIndex = Math.max(0, STEPS.indexOf(stage));
|
||||
const isFailed = stage === 'failed';
|
||||
// Retrying an Intel-Mac install can never succeed — don't offer the dead end.
|
||||
const isUnrecoverable = isFailed && isUnrecoverableFailure(message, logs);
|
||||
// Reads the full run, not the rendered tail: an Intel-Mac marker printed
|
||||
// early in a long install used to scroll out of the capped array, and the
|
||||
// dead-end detection went with it. Only evaluated once `isFailed`, by which
|
||||
// point no more lines are arriving.
|
||||
const isUnrecoverable = isFailed && isUnrecoverableFailure(message, allLogsRef.current);
|
||||
|
||||
const resetLogs = () => {
|
||||
allLogsRef.current = [];
|
||||
handoverDoneRef.current = false;
|
||||
setTotalLines(0);
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
// True once any genuine install stage has been observed this session. On a
|
||||
// warm start the Rust stage jumps straight from `checking` to
|
||||
// `starting_backend` — nothing here ever fires — so the first-run install
|
||||
@@ -507,10 +541,11 @@ export function BootstrapSplash({ stage, message }) {
|
||||
// starts emitting logs for the new attempt immediately, and a boundary
|
||||
// stamped at detection time would sit *after* those lines and discard them
|
||||
// as belonging to the old attempt — losing exactly the fast-stage evidence
|
||||
// the log union exists to capture.
|
||||
// the log union exists to capture. resetLogs() clears the full-run ref too,
|
||||
// so a retry cannot inherit the previous attempt's lines (#1847 + #1894).
|
||||
const beginAttempt = () => {
|
||||
selfInitiatedRef.current = true;
|
||||
setLogs([]);
|
||||
resetLogs();
|
||||
setPolledStages(new Set());
|
||||
setAttemptStart(Date.now());
|
||||
};
|
||||
@@ -639,13 +674,14 @@ export function BootstrapSplash({ stage, message }) {
|
||||
try {
|
||||
const buffered = await invoke('get_bootstrap_logs');
|
||||
if (!cancelled && Array.isArray(buffered) && buffered.length > 0) {
|
||||
setLogs(
|
||||
buffered.map(({ stage: s, line }) => ({
|
||||
stage: s,
|
||||
line,
|
||||
t: Date.now(),
|
||||
})),
|
||||
);
|
||||
const entries = buffered.map(({ stage: s, line }) => ({
|
||||
stage: s,
|
||||
line,
|
||||
t: Date.now(),
|
||||
}));
|
||||
allLogsRef.current = entries;
|
||||
setTotalLines(entries.length);
|
||||
setLogs(entries.slice(-VISIBLE_LOG_LINES));
|
||||
}
|
||||
} catch {
|
||||
/* command may not exist in older builds */
|
||||
@@ -656,12 +692,26 @@ export function BootstrapSplash({ stage, message }) {
|
||||
const { stage: s, line } = e.payload || {};
|
||||
if (!line) return;
|
||||
noteBootstrapLogActivity();
|
||||
// Dedup ONLY across the backfill→live seam. The overlap it exists
|
||||
// for can only happen on the first live event; running it for the
|
||||
// whole bootstrap silently dropped legitimate repeats, and installer
|
||||
// output repeats itself constantly. Telling a true repeat from a
|
||||
// replayed one needs a sequence number from the Rust side, so the
|
||||
// window is narrowed to where the ambiguity actually is instead of
|
||||
// guessing for the rest of the run.
|
||||
if (!handoverDoneRef.current) {
|
||||
const seam = allLogsRef.current.slice(-5);
|
||||
if (seam.some((l) => l.stage === s && l.line === line)) return;
|
||||
handoverDoneRef.current = true;
|
||||
}
|
||||
const entry = { stage: s, line, t: Date.now() };
|
||||
allLogsRef.current.push(entry);
|
||||
setTotalLines(allLogsRef.current.length);
|
||||
setLogs((prev) => {
|
||||
// Deduplicate against backfill by checking the last few lines.
|
||||
const lastFew = prev.slice(-5);
|
||||
if (lastFew.some((l) => l.stage === s && l.line === line)) return prev;
|
||||
const next = prev.concat([{ stage: s, line, t: Date.now() }]);
|
||||
return next.length > MAX_LOG_LINES ? next.slice(next.length - MAX_LOG_LINES) : next;
|
||||
const next = prev.concat([entry]);
|
||||
return next.length > VISIBLE_LOG_LINES
|
||||
? next.slice(next.length - VISIBLE_LOG_LINES)
|
||||
: next;
|
||||
});
|
||||
});
|
||||
unlistenProgress = await listen('bootstrap-progress', (e) => {
|
||||
@@ -702,11 +752,15 @@ export function BootstrapSplash({ stage, message }) {
|
||||
if (isFailed) setLogsOpen(true);
|
||||
}, [isFailed]);
|
||||
|
||||
// Serializes the WHOLE run, not the visible tail (#1847). A user filing a
|
||||
// bootstrap bug is asked for this output, and the early lines — which stage
|
||||
// failed first, which mirror was reached — are the ones that scrolled out.
|
||||
const handleCopyLogs = () => {
|
||||
const all = allLogsRef.current;
|
||||
const logText =
|
||||
logs.length === 0
|
||||
all.length === 0
|
||||
? 'No log output captured.'
|
||||
: logs.map((l) => `[${l.stage}] ${l.line}`).join('\n');
|
||||
: all.map((l) => `[${l.stage}] ${l.line}`).join('\n');
|
||||
const full =
|
||||
isFailed && message ? `ERROR: ${message}\n\n--- Bootstrap Logs ---\n${logText}` : logText;
|
||||
copyText(full)
|
||||
@@ -835,7 +889,7 @@ export function BootstrapSplash({ stage, message }) {
|
||||
<Lightbulb size={12} /> {t('bootstrap.what_to_try', 'What to try:')}
|
||||
</span>
|
||||
<ul className="mt-1.5 flex list-disc flex-col gap-1.5 pl-5 text-fg-muted">
|
||||
{detectHints(message, logs).map((key) => (
|
||||
{detectHints(message, allLogsRef.current).map((key) => (
|
||||
<li key={key}>{t(key)}</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -962,7 +1016,7 @@ export function BootstrapSplash({ stage, message }) {
|
||||
<h2 className="m-0 flex items-center font-mono text-[0.62rem] font-semibold uppercase tracking-[0.18em] text-fg-muted">
|
||||
{t('firstrun.activity_title', 'Activity')}
|
||||
<span className="ml-auto tracking-[0.08em] text-fg-subtle">
|
||||
{logs.length > 0 && t('bootstrap.lines', { count: logs.length })}
|
||||
{totalLines > 0 && t('bootstrap.lines', { count: totalLines })}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { listLoadedModels, unloadLoadedModel } from '../api/system';
|
||||
import { useAppStore } from '../store';
|
||||
import { copyText } from '../utils/copyText';
|
||||
import { openExternal } from '../api/external';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Badge, Button, Select, Table, Tabs } from '../ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -243,6 +244,9 @@ function normalizeEntry(entry) {
|
||||
Array.isArray(entry.gpu_compat) && entry.gpu_compat.length > 0 ? entry.gpu_compat : ['cpu'],
|
||||
// Copy-paste `export VAR=...` line for a path-gated opt-in engine, or null.
|
||||
setup_snippet: entry.setup_snippet || null,
|
||||
// Where to send a user whose engine is unavailable (#1866). Registry
|
||||
// constant, so it is present even when reason/last_error are placeholders.
|
||||
docs_url: entry.docs_url || null,
|
||||
// The backend's sidecar provisioner can install this engine in-app —
|
||||
// renders an Install button; the manual snippet demotes to a fallback.
|
||||
one_click_install: entry.one_click_install === true,
|
||||
@@ -1496,6 +1500,25 @@ export default function EngineCompatibilityMatrix({
|
||||
{t('engines.lastError', { error: b.last_error })}
|
||||
</span>
|
||||
)}
|
||||
{/* #1866: the reason the backend computed is replaced by
|
||||
a fixed string before it reaches here, so the row's own
|
||||
text can only ever be generic. The engine's doc page is
|
||||
the one place that does explain it, and nothing linked
|
||||
to it. Reuses the `common.learn_more` key InfoHint
|
||||
already renders for the MCP and Remote GPU panels, so
|
||||
no new string is needed. No trailing arrow glyph:
|
||||
InfoHint hardcodes one, and a bare "→" points the
|
||||
wrong way once the app is in an RTL locale. */}
|
||||
{b.docs_url && (
|
||||
<button
|
||||
type="button"
|
||||
className="engine-matrix__docs cursor-pointer self-start border-0 bg-transparent p-0 text-[11px] font-semibold text-[color:var(--chrome-accent)] hover:underline"
|
||||
data-testid={`engine-docs-${b.id}`}
|
||||
onClick={() => openExternal(b.docs_url)}
|
||||
>
|
||||
{t('common.learn_more', 'Learn more')}
|
||||
</button>
|
||||
)}
|
||||
{hasDiskDetails &&
|
||||
(() => {
|
||||
const usage = diskByEngine[b.id] || b.disk_usage;
|
||||
|
||||
@@ -146,8 +146,10 @@ function StorageRow({ label, desc, path, need, check, onPick }) {
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5" title={desc}>
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
{/* rtl for a start-side ellipsis; <bdi> keeps the path's own order
|
||||
(see the preflight detail in SetupWizard.jsx for why). */}
|
||||
<code className="truncate font-mono text-[0.66rem] text-fg-muted" title={path} dir="rtl">
|
||||
{path}
|
||||
<bdi>{path}</bdi>
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex min-w-[170px] shrink-0 flex-col items-end gap-1">
|
||||
|
||||
@@ -108,6 +108,7 @@ function WaveBars({ color = '#f3a5b6', active }) {
|
||||
const heights = [4, 9, 5, 11, 6, 10, 5, 8];
|
||||
return (
|
||||
<div
|
||||
data-testid="header-wave-bars"
|
||||
className={`inline-flex items-center gap-[2px] h-[16px] px-[6px] transition-opacity duration-200 max-[1351px]:hidden ${active ? 'opacity-100' : 'opacity-[0.35]'}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
@@ -373,10 +374,7 @@ export default function Header({
|
||||
LogsFooter bar so all app-wide chrome sits together. */}
|
||||
<div className="flex items-center justify-end gap-3 justify-self-end min-w-0 overflow-visible">
|
||||
<NotificationPanel onNavigate={setMode} />
|
||||
<WaveBars
|
||||
color={view.accent}
|
||||
active={modelStatus === 'ready' || modelStatus === 'loading'}
|
||||
/>
|
||||
<WaveBars color={view.accent} active={modelStatus === 'loading'} />
|
||||
{sysStats && (
|
||||
<div className="flex items-center gap-2 [font-family:var(--chrome-font-mono)] text-[10.5px] text-[var(--chrome-fg-dim)] bg-transparent h-[var(--chrome-pill-h)] whitespace-nowrap shrink-0 tabular-nums slashed-zero">
|
||||
{showLiveStats && (
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FolderOpen, RotateCcw } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { audiobookListJobs } from '../../api/audiobook';
|
||||
import { exportReveal } from '../../api/exports';
|
||||
import { useSystemInfo } from '../../api/hooks';
|
||||
import { Button } from '../../ui';
|
||||
|
||||
/** Local recovery affordance for interrupted audiobook renders (not a global job manager). */
|
||||
export default function AudiobookRecovery({ t, generating, onResume }) {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [hiddenJobId, setHiddenJobId] = useState(null);
|
||||
const refreshIdRef = useRef(0);
|
||||
const { data: systemInfo } = useSystemInfo();
|
||||
|
||||
useEffect(() => {
|
||||
const refreshId = ++refreshIdRef.current;
|
||||
audiobookListJobs()
|
||||
.then((result) => {
|
||||
if (refreshId === refreshIdRef.current) {
|
||||
setJobs(Array.isArray(result?.jobs) ? result.jobs : []);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
// Invalidate any in-flight response so an unmounted recovery card is
|
||||
// never repopulated by a late inventory request.
|
||||
refreshIdRef.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const job = useMemo(
|
||||
() =>
|
||||
jobs
|
||||
.filter((candidate) => candidate.type === 'audiobook' && candidate.job_id !== hiddenJobId)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0],
|
||||
[hiddenJobId, jobs],
|
||||
);
|
||||
const cachePath = systemInfo?.outputs_dir
|
||||
? `${systemInfo.outputs_dir.replace(/[\\/]+$/, '')}/longform_cache`
|
||||
: '';
|
||||
const manifestPath = systemInfo?.outputs_dir
|
||||
? `${systemInfo.outputs_dir.replace(/[\\/]+$/, '')}/audiobook_${job?.job_id}/resume.json`
|
||||
: '';
|
||||
|
||||
if (!job) return null;
|
||||
|
||||
const resume = async () => {
|
||||
const jobId = job.job_id;
|
||||
setHiddenJobId(jobId);
|
||||
await onResume(jobId);
|
||||
// A resumed render runs under a fresh backend id. If it is stopped or
|
||||
// fails, that fresh manifest becomes the next recovery card; if it
|
||||
// completes, the inventory is empty. Refresh in every terminal case.
|
||||
const refreshId = ++refreshIdRef.current;
|
||||
try {
|
||||
const result = await audiobookListJobs();
|
||||
if (refreshId === refreshIdRef.current) {
|
||||
setJobs(Array.isArray(result?.jobs) ? result.jobs : []);
|
||||
setHiddenJobId(null);
|
||||
}
|
||||
} catch {
|
||||
setHiddenJobId(null);
|
||||
}
|
||||
};
|
||||
const openCache = async () => {
|
||||
if (!cachePath) return;
|
||||
try {
|
||||
await exportReveal({ path: cachePath });
|
||||
} catch (error) {
|
||||
toast.error(error?.message || t('audiobook.open_cache_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex flex-wrap items-center justify-between gap-[10px] rounded-[12px] border border-[color-mix(in_srgb,var(--chrome-accent)_25%,transparent)] bg-[var(--color-bg-elev-2)] px-[12px] py-[9px]">
|
||||
<div className="min-w-0">
|
||||
<h3 className="m-0 text-[var(--text-sm)] font-semibold text-fg">
|
||||
{t('audiobook.recovery_title')}
|
||||
</h3>
|
||||
<p className="m-[3px_0_0] text-[var(--text-sm)] text-fg-muted">
|
||||
<strong className="text-fg">{job.title || t('audiobook.untitled')}</strong>
|
||||
{' · '}
|
||||
{t('audiobook.recovery_progress', {
|
||||
done: job.chapters_done || 0,
|
||||
total: job.total_chapters || 0,
|
||||
})}
|
||||
</p>
|
||||
<p className="m-[3px_0_0] text-[var(--text-xs)] text-fg-dim">
|
||||
{t('audiobook.recovery_hint')}
|
||||
</p>
|
||||
<p className="m-[3px_0_0] text-[var(--text-xs)] text-fg-dim">
|
||||
{t('audiobook.cache_location_hint', { cachePath, manifestPath })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-[4px]">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={openCache}
|
||||
disabled={!cachePath}
|
||||
leading={<FolderOpen />}
|
||||
>
|
||||
{t('audiobook.open_chapter_cache')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void resume()}
|
||||
disabled={generating}
|
||||
leading={<RotateCcw />}
|
||||
>
|
||||
{t('audiobook.resume')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "المساعدة في تحسين VoiceStudio؟",
|
||||
"desc": "إحصاءات استخدام مجهولة الهوية وخالية من المحتوى تساعدنا على إصلاح الأخطاء بشكل أسرع — اختيارية تمامًا ومعطّلة افتراضيًا.",
|
||||
"step_label": "تحسين VoiceStudio",
|
||||
"body": "إحصاءات استخدام مجهولة الهوية وخالية من المحتوى: أي المحركات تُستخدم، وكم تستغرق عمليات التوليد، ونوع الأخطاء. لن يُرسل أبدًا نصك أو صوتك أو ملفاتك أو أي شيء يحدد هويتك. لا يُرسل أي شيء ما لم توافق، ويمكنك تغيير رأيك في أي وقت من الإعدادات ← الخصوصية.",
|
||||
"learn_more": "ما الذي يُرسل بالضبط؟",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "مفتوح",
|
||||
"install_required_models": "قم بتثبيت النماذج المطلوبة أعلاه للمتابعة.",
|
||||
"try_dictation": "حاول الإملاء",
|
||||
"try_dictation_desc": "اضغط على اختصارك مرة واحدة للتأكد من أن الإملاء يعمل قبل إنهاء الإعداد.",
|
||||
"dictation_step_label": "الإملاء",
|
||||
"next_try_dictation": "التالي: حاول الإملاء",
|
||||
"step_aria": "الخطوة {{num}}: {{label}}",
|
||||
"step_completed": "مكتملة",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "صوت غير معروف ‘{{name}}’ — سيتم استخدام الافتراضي",
|
||||
"warn_empty_chapter": "الفصل “{{title}}” لا يحتوي على نص منطوق",
|
||||
"warn_unknown_tag": "وسم غير معروف {{tag}} — سيُقرأ بصوت عالٍ",
|
||||
"untitled": "بدون عنوان",
|
||||
"untitled": "كتاب صوتي بلا عنوان",
|
||||
"dismiss": "تجاهل",
|
||||
"output": "الإخراج",
|
||||
"title": "كتاب مسموع",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "خطة المعاينة",
|
||||
"stop": "إيقاف",
|
||||
"stopped_note": "تم الإيقاف — الفصول المكتملة مخزَّنة؛ أنشئ من جديد للمتابعة.",
|
||||
"recovery_title": "كتاب صوتي متوقف",
|
||||
"recovery_progress": "تم تخزين {{done}} من أصل {{total}} فصلاً مؤقتًا",
|
||||
"recovery_hint": "تُعيد الاستئناف استخدام الفصول المكتملة ولا تُنشئ إلا ما تبقى.",
|
||||
"resume": "استئناف",
|
||||
"open_chapter_cache": "فتح ذاكرة التخزين المؤقت للفصول",
|
||||
"cache_location_hint": "ذاكرة التخزين المؤقت لصوت الفصول: {{cachePath}} · بيان الاستئناف: {{manifestPath}}",
|
||||
"resume_failed": "تعذّر استئناف الكتاب الصوتي: {{message}}",
|
||||
"open_cache_failed": "تعذّر فتح ذاكرة التخزين المؤقت للفصول",
|
||||
"progress_summary": "جارٍ تركيب {{current}} من {{total}} فصلاً",
|
||||
"eta": "~{{time}} متبقٍ",
|
||||
"chapter_n": "الفصل {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "VoiceStudio verbessern helfen?",
|
||||
"desc": "Anonyme, inhaltsfreie Nutzungsstatistiken, die uns helfen, Fehler schneller zu beheben — völlig optional und standardmäßig deaktiviert.",
|
||||
"step_label": "VoiceStudio verbessern",
|
||||
"body": "Anonyme, inhaltsfreie Nutzungsstatistiken: welche Engines verwendet werden, wie lange Generierungen dauern und die Art von Fehlern. Niemals Ihr Text, Ihr Audio, Ihre Dateien oder etwas, das Sie identifiziert. Es wird nichts gesendet, solange Sie nicht zustimmen — und Sie können Ihre Entscheidung jederzeit unter Einstellungen → Datenschutz ändern.",
|
||||
"learn_more": "Was genau wird gesendet?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Offen",
|
||||
"install_required_models": "Installieren Sie die oben genannten erforderlichen Modelle, um fortzufahren.",
|
||||
"try_dictation": "Versuchen Sie es mit Diktat",
|
||||
"try_dictation_desc": "Drücken Sie einmal Ihre Tastenkombination, um sicherzugehen, dass die Diktierfunktion funktioniert, bevor Sie die Einrichtung abschließen.",
|
||||
"dictation_step_label": "Diktat",
|
||||
"next_try_dictation": "Als nächstes: Versuchen Sie es mit dem Diktat",
|
||||
"step_aria": "Schritt {{num}}: {{label}}",
|
||||
"step_completed": "abgeschlossen",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Unbekannte Stimme „{{name}}“ – die Standardstimme wird verwendet",
|
||||
"warn_empty_chapter": "Kapitel „{{title}}“ enthält keinen gesprochenen Text",
|
||||
"warn_unknown_tag": "Unbekanntes Tag {{tag}} – es wird vorgelesen",
|
||||
"untitled": "Ohne Titel",
|
||||
"untitled": "Unbenanntes Hörbuch",
|
||||
"dismiss": "Verwerfen",
|
||||
"output": "Ausgabe",
|
||||
"title": "Hörbuch",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Vorschauplan",
|
||||
"stop": "Stopp",
|
||||
"stopped_note": "Gestoppt — fertige Kapitel sind zwischengespeichert; erneut „Erstellen“ zum Fortsetzen.",
|
||||
"recovery_title": "Unterbrochenes Hörbuch",
|
||||
"recovery_progress": "{{done}} von {{total}} Kapiteln sind zwischengespeichert",
|
||||
"recovery_hint": "Beim Fortsetzen werden fertige Kapitel wiederverwendet und nur der Rest erzeugt.",
|
||||
"resume": "Fortsetzen",
|
||||
"open_chapter_cache": "Kapitel-Zwischenspeicher öffnen",
|
||||
"cache_location_hint": "Kapitel-Audio-Zwischenspeicher: {{cachePath}} · Fortsetzungsmanifest: {{manifestPath}}",
|
||||
"resume_failed": "Das Hörbuch konnte nicht fortgesetzt werden: {{message}}",
|
||||
"open_cache_failed": "Der Kapitel-Zwischenspeicher konnte nicht geöffnet werden",
|
||||
"progress_summary": "Synthetisiere {{current}} von {{total}} Kapiteln",
|
||||
"eta": "~{{time}} übrig",
|
||||
"chapter_n": "Kapitel {{n}}",
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
"warn_unknown_voice": "Unknown voice ‘{{name}}’ — will use the default",
|
||||
"warn_empty_chapter": "Chapter “{{title}}” has no spoken text",
|
||||
"warn_unknown_tag": "Unrecognized tag {{tag}} — it will be read aloud",
|
||||
"untitled": "Untitled",
|
||||
"untitled": "Untitled audiobook",
|
||||
"dismiss": "Dismiss",
|
||||
"output": "Output",
|
||||
"title": "Audiobook",
|
||||
@@ -212,6 +212,14 @@
|
||||
"assembling": "Assembling the audiobook…",
|
||||
"stop": "Stop",
|
||||
"stopped_note": "Stopped — finished chapters are cached; Create again to resume.",
|
||||
"recovery_title": "Interrupted audiobook",
|
||||
"recovery_progress": "{{done}} of {{total}} chapters are cached",
|
||||
"recovery_hint": "Resume reuses finished chapters and synthesizes only what remains.",
|
||||
"resume": "Resume",
|
||||
"open_chapter_cache": "Open chapter cache",
|
||||
"cache_location_hint": "Chapter audio cache: {{cachePath}} · Resume manifest: {{manifestPath}}",
|
||||
"resume_failed": "Could not resume the audiobook: {{message}}",
|
||||
"open_cache_failed": "Could not open the chapter cache",
|
||||
"progress_summary": "Synthesizing {{current}} of {{total}} chapters",
|
||||
"eta": "~{{time}} left",
|
||||
"chapter_n": "Chapter {{n}}",
|
||||
@@ -1020,6 +1028,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Help improve VoiceStudio?",
|
||||
"desc": "Anonymous, content-free usage stats that help us fix bugs faster — entirely optional, off by default.",
|
||||
"step_label": "Improve VoiceStudio",
|
||||
"body": "Anonymous, content-free usage stats: which engines are used, how long generations take, and the type of errors. Never your text, your audio, your files, or anything identifying you. Nothing is sent unless you say yes, and you can change your mind anytime in Settings → Privacy.",
|
||||
"learn_more": "What exactly is sent?",
|
||||
@@ -1831,6 +1840,8 @@
|
||||
"open": "Open",
|
||||
"install_required_models": "Install the required models above to continue.",
|
||||
"try_dictation": "Try dictation",
|
||||
"try_dictation_desc": "Press your shortcut once so you know dictation works before you finish setup.",
|
||||
"dictation_step_label": "Dictation",
|
||||
"next_try_dictation": "Next: Try dictation",
|
||||
"step_aria": "Step {{num}}: {{label}}",
|
||||
"step_completed": "completed",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "¿Ayudar a mejorar VoiceStudio?",
|
||||
"desc": "Estadísticas de uso anónimas y sin contenido que nos ayudan a corregir errores más rápido — totalmente opcionales y desactivadas de forma predeterminada.",
|
||||
"step_label": "Mejorar VoiceStudio",
|
||||
"body": "Estadísticas de uso anónimas y sin contenido: qué motores se usan, cuánto tardan las generaciones y el tipo de errores. Nunca tu texto, tu audio, tus archivos ni nada que te identifique. No se envía nada a menos que digas que sí, y puedes cambiar de opinión en cualquier momento en Ajustes → Privacidad.",
|
||||
"learn_more": "¿Qué se envía exactamente?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Abierto",
|
||||
"install_required_models": "Instale los modelos requeridos arriba para continuar.",
|
||||
"try_dictation": "Prueba el dictado",
|
||||
"try_dictation_desc": "Pulsa tu atajo una vez para comprobar que el dictado funciona antes de terminar la configuración.",
|
||||
"dictation_step_label": "Dictado",
|
||||
"next_try_dictation": "Siguiente: Prueba el dictado",
|
||||
"step_aria": "Paso {{num}}: {{label}}",
|
||||
"step_completed": "completado",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Voz desconocida «{{name}}»: se usará la predeterminada",
|
||||
"warn_empty_chapter": "El capítulo «{{title}}» no tiene texto hablado",
|
||||
"warn_unknown_tag": "Etiqueta no reconocida {{tag}}: se leerá en voz alta",
|
||||
"untitled": "Sin título",
|
||||
"untitled": "Audiolibro sin título",
|
||||
"dismiss": "Descartar",
|
||||
"output": "Salida",
|
||||
"title": "Audiolibro",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Vista previa del plan",
|
||||
"stop": "Detener",
|
||||
"stopped_note": "Detenido — los capítulos terminados están en caché; pulsa Crear de nuevo para continuar.",
|
||||
"recovery_title": "Audiolibro interrumpido",
|
||||
"recovery_progress": "{{done}} de {{total}} capítulos están en caché",
|
||||
"recovery_hint": "Al reanudar se reutilizan los capítulos terminados y solo se sintetiza lo que falta.",
|
||||
"resume": "Reanudar",
|
||||
"open_chapter_cache": "Abrir caché de capítulos",
|
||||
"cache_location_hint": "Caché de audio de capítulos: {{cachePath}} · Manifiesto de reanudación: {{manifestPath}}",
|
||||
"resume_failed": "No se pudo reanudar el audiolibro: {{message}}",
|
||||
"open_cache_failed": "No se pudo abrir la caché de capítulos",
|
||||
"progress_summary": "Sintetizando {{current}} de {{total}} capítulos",
|
||||
"eta": "~{{time}} restante",
|
||||
"chapter_n": "Capítulo {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Aider à améliorer VoiceStudio ?",
|
||||
"desc": "Statistiques d'utilisation anonymes et sans contenu qui nous aident à corriger les bugs plus vite — entièrement facultatives, désactivées par défaut.",
|
||||
"step_label": "Améliorer VoiceStudio",
|
||||
"body": "Statistiques d'utilisation anonymes, sans contenu : quels moteurs sont utilisés, la durée des générations et le type d'erreurs. Jamais votre texte, votre audio, vos fichiers ni rien qui vous identifie. Rien n'est envoyé sans votre accord, et vous pouvez changer d'avis à tout moment dans Réglages → Confidentialité.",
|
||||
"learn_more": "Qu'est-ce qui est envoyé exactement ?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Ouvert",
|
||||
"install_required_models": "Installez les modèles requis ci-dessus pour continuer.",
|
||||
"try_dictation": "Essayez la dictée",
|
||||
"try_dictation_desc": "Appuyez une fois sur votre raccourci pour vérifier que la dictée fonctionne avant de terminer la configuration.",
|
||||
"dictation_step_label": "Dictée",
|
||||
"next_try_dictation": "Suivant : Essayez la dictée",
|
||||
"step_aria": "Étape {{num}} : {{label}}",
|
||||
"step_completed": "terminée",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Voix inconnue « {{name}} » — la voix par défaut sera utilisée",
|
||||
"warn_empty_chapter": "Le chapitre « {{title}} » ne contient aucun texte parlé",
|
||||
"warn_unknown_tag": "Balise non reconnue {{tag}} — elle sera lue à voix haute",
|
||||
"untitled": "Sans titre",
|
||||
"untitled": "Livre audio sans titre",
|
||||
"dismiss": "Ignorer",
|
||||
"output": "Sortie",
|
||||
"title": "Livre audio",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Aperçu du plan",
|
||||
"stop": "Arrêter",
|
||||
"stopped_note": "Arrêté — les chapitres terminés sont en cache ; relancez Créer pour reprendre.",
|
||||
"recovery_title": "Livre audio interrompu",
|
||||
"recovery_progress": "{{done}} chapitres sur {{total}} sont mis en cache",
|
||||
"recovery_hint": "Reprendre réutilise les chapitres terminés et ne synthétise que ce qui reste.",
|
||||
"resume": "Reprendre",
|
||||
"open_chapter_cache": "Ouvrir le cache des chapitres",
|
||||
"cache_location_hint": "Cache audio des chapitres : {{cachePath}} · Manifeste de reprise : {{manifestPath}}",
|
||||
"resume_failed": "Impossible de reprendre le livre audio : {{message}}",
|
||||
"open_cache_failed": "Impossible d'ouvrir le cache des chapitres",
|
||||
"progress_summary": "Synthèse de {{current}} sur {{total}} chapitres",
|
||||
"eta": "~{{time}} restant",
|
||||
"chapter_n": "Chapitre {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "VoiceStudio को बेहतर बनाने में मदद करें?",
|
||||
"desc": "गुमनाम, सामग्री-रहित उपयोग आँकड़े जो हमें बग जल्दी ठीक करने में मदद करते हैं — पूरी तरह वैकल्पिक, डिफ़ॉल्ट रूप से बंद।",
|
||||
"step_label": "VoiceStudio को बेहतर बनाएँ",
|
||||
"body": "गुमनाम, सामग्री-रहित उपयोग आँकड़े: कौन से इंजन उपयोग होते हैं, जनरेशन में कितना समय लगता है, और त्रुटियों का प्रकार। कभी भी आपका टेक्स्ट, ऑडियो, फ़ाइलें या आपकी पहचान बताने वाली कोई चीज़ नहीं। जब तक आप हाँ नहीं कहते, कुछ भी नहीं भेजा जाता, और आप कभी भी सेटिंग्स → गोपनीयता में अपना निर्णय बदल सकते हैं।",
|
||||
"learn_more": "वास्तव में क्या भेजा जाता है?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "खुला",
|
||||
"install_required_models": "जारी रखने के लिए उपरोक्त आवश्यक मॉडल स्थापित करें।",
|
||||
"try_dictation": "श्रुतलेख का प्रयास करें",
|
||||
"try_dictation_desc": "सेटअप पूरा करने से पहले यह जांचने के लिए अपना शॉर्टकट एक बार दबाएँ कि डिक्टेशन काम कर रहा है।",
|
||||
"dictation_step_label": "डिक्टेशन",
|
||||
"next_try_dictation": "अगला: श्रुतलेख का प्रयास करें",
|
||||
"step_aria": "चरण {{num}}: {{label}}",
|
||||
"step_completed": "पूर्ण",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "अज्ञात आवाज़ ‘{{name}}’ — डिफ़ॉल्ट का उपयोग होगा",
|
||||
"warn_empty_chapter": "अध्याय “{{title}}” में बोलने योग्य कोई पाठ नहीं है",
|
||||
"warn_unknown_tag": "अपरिचित टैग {{tag}} — इसे ज़ोर से पढ़ा जाएगा",
|
||||
"untitled": "शीर्षकहीन",
|
||||
"untitled": "शीर्षकहीन ऑडियोबुक",
|
||||
"dismiss": "खारिज करें",
|
||||
"output": "आउटपुट",
|
||||
"title": "ऑडियोबुक",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "पूर्वावलोकन योजना",
|
||||
"stop": "रोकें",
|
||||
"stopped_note": "रोक दिया गया — पूर्ण अध्याय कैश में हैं; जारी रखने के लिए फिर से बनाएँ।",
|
||||
"recovery_title": "बाधित ऑडियोबुक",
|
||||
"recovery_progress": "{{total}} में से {{done}} अध्याय कैश हैं",
|
||||
"recovery_hint": "जारी रखने पर पूरे हो चुके अध्याय फिर से उपयोग होंगे और केवल शेष को संश्लेषित किया जाएगा।",
|
||||
"resume": "जारी रखें",
|
||||
"open_chapter_cache": "अध्याय कैश खोलें",
|
||||
"cache_location_hint": "अध्याय ऑडियो कैश: {{cachePath}} · फिर से शुरू करने का मेनिफ़ेस्ट: {{manifestPath}}",
|
||||
"resume_failed": "ऑडियोबुक फिर से शुरू नहीं की जा सकी: {{message}}",
|
||||
"open_cache_failed": "अध्याय कैश नहीं खोला जा सका",
|
||||
"progress_summary": "{{total}} में से {{current}} अध्याय संश्लेषित हो रहे हैं",
|
||||
"eta": "~{{time}} शेष",
|
||||
"chapter_n": "अध्याय {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Bantu meningkatkan VoiceStudio?",
|
||||
"desc": "Statistik penggunaan anonim dan bebas konten yang membantu kami memperbaiki bug lebih cepat — sepenuhnya opsional, nonaktif secara default.",
|
||||
"step_label": "Tingkatkan VoiceStudio",
|
||||
"body": "Statistik penggunaan anonim tanpa konten: mesin mana yang digunakan, berapa lama proses pembuatan, dan jenis kesalahan. Tidak pernah teks Anda, audio Anda, file Anda, atau apa pun yang mengidentifikasi Anda. Tidak ada yang dikirim kecuali Anda setuju, dan Anda dapat berubah pikiran kapan saja di Pengaturan → Privasi.",
|
||||
"learn_more": "Apa saja yang sebenarnya dikirim?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Buka",
|
||||
"install_required_models": "Instal model yang diperlukan di atas untuk melanjutkan.",
|
||||
"try_dictation": "Coba dikte",
|
||||
"try_dictation_desc": "Tekan pintasan Anda sekali untuk memastikan dikte berfungsi sebelum menyelesaikan penyiapan.",
|
||||
"dictation_step_label": "Dikte",
|
||||
"next_try_dictation": "Berikutnya: Coba dikte",
|
||||
"step_aria": "Langkah {{num}}: {{label}}",
|
||||
"step_completed": "selesai",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Suara tidak dikenal ‘{{name}}’ — akan memakai default",
|
||||
"warn_empty_chapter": "Bab “{{title}}” tidak memiliki teks untuk dibacakan",
|
||||
"warn_unknown_tag": "Tag tidak dikenal {{tag}} — akan dibacakan",
|
||||
"untitled": "Tanpa judul",
|
||||
"untitled": "Buku audio tanpa judul",
|
||||
"dismiss": "Tutup",
|
||||
"output": "Keluaran",
|
||||
"title": "Buku Audio",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Rencana pratinjau",
|
||||
"stop": "Hentikan",
|
||||
"stopped_note": "Dihentikan — bab yang selesai tersimpan di cache; tekan Buat lagi untuk melanjutkan.",
|
||||
"recovery_title": "Buku audio terputus",
|
||||
"recovery_progress": "{{done}} dari {{total}} bab telah di-cache",
|
||||
"recovery_hint": "Lanjutkan menggunakan kembali bab yang selesai dan hanya menyintesis sisanya.",
|
||||
"resume": "Lanjutkan",
|
||||
"open_chapter_cache": "Buka cache bab",
|
||||
"cache_location_hint": "Cache audio bab: {{cachePath}} · Manifes lanjutan: {{manifestPath}}",
|
||||
"resume_failed": "Tidak dapat melanjutkan buku audio: {{message}}",
|
||||
"open_cache_failed": "Tidak dapat membuka cache bab",
|
||||
"progress_summary": "Mensintesis {{current}} dari {{total}} bab",
|
||||
"eta": "~{{time}} tersisa",
|
||||
"chapter_n": "Bab {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Aiutare a migliorare VoiceStudio?",
|
||||
"desc": "Statistiche di utilizzo anonime e prive di contenuti che ci aiutano a correggere i bug più velocemente — del tutto facoltative, disattivate per impostazione predefinita.",
|
||||
"step_label": "Migliora VoiceStudio",
|
||||
"body": "Statistiche d'uso anonime e prive di contenuti: quali motori vengono usati, quanto durano le generazioni e il tipo di errori. Mai il tuo testo, il tuo audio, i tuoi file o qualcosa che ti identifichi. Non viene inviato nulla senza il tuo consenso, e puoi cambiare idea in qualsiasi momento in Impostazioni → Privacy.",
|
||||
"learn_more": "Cosa viene inviato esattamente?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Aperto",
|
||||
"install_required_models": "Installa i modelli richiesti sopra per continuare.",
|
||||
"try_dictation": "Prova la dettatura",
|
||||
"try_dictation_desc": "Premi la tua scorciatoia una volta per verificare che la dettatura funzioni prima di completare la configurazione.",
|
||||
"dictation_step_label": "Dettatura",
|
||||
"next_try_dictation": "Successiva: Prova la dettatura",
|
||||
"step_aria": "Passaggio {{num}}: {{label}}",
|
||||
"step_completed": "completato",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Voce sconosciuta «{{name}}» — verrà usata quella predefinita",
|
||||
"warn_empty_chapter": "Il capitolo «{{title}}» non contiene testo parlato",
|
||||
"warn_unknown_tag": "Tag non riconosciuto {{tag}} — verrà letto ad alta voce",
|
||||
"untitled": "Senza titolo",
|
||||
"untitled": "Audiolibro senza titolo",
|
||||
"dismiss": "Ignora",
|
||||
"output": "Uscita",
|
||||
"title": "Audiolibro",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Piano in anteprima",
|
||||
"stop": "Ferma",
|
||||
"stopped_note": "Interrotto — i capitoli completati sono in cache; premi di nuovo Crea per riprendere.",
|
||||
"recovery_title": "Audiolibro interrotto",
|
||||
"recovery_progress": "{{done}} capitoli su {{total}} sono nella cache",
|
||||
"recovery_hint": "La ripresa riutilizza i capitoli completati e sintetizza solo ciò che resta.",
|
||||
"resume": "Riprendi",
|
||||
"open_chapter_cache": "Apri cache dei capitoli",
|
||||
"cache_location_hint": "Cache audio dei capitoli: {{cachePath}} · Manifest di ripresa: {{manifestPath}}",
|
||||
"resume_failed": "Impossibile riprendere l'audiolibro: {{message}}",
|
||||
"open_cache_failed": "Impossibile aprire la cache dei capitoli",
|
||||
"progress_summary": "Sintesi di {{current}} di {{total}} capitoli",
|
||||
"eta": "~{{time}} rimanenti",
|
||||
"chapter_n": "Capitolo {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "VoiceStudio の改善に協力しますか?",
|
||||
"desc": "バグをより早く修正するのに役立つ、匿名でコンテンツを含まない利用統計 — 完全に任意で、デフォルトではオフです。",
|
||||
"step_label": "VoiceStudio の改善",
|
||||
"body": "匿名でコンテンツを含まない使用統計です。使用したエンジン、生成にかかった時間、エラーの種類のみを送信します。あなたのテキスト、音声、ファイル、あなたを特定できる情報は決して送信されません。同意しない限り何も送信されず、設定 → プライバシーでいつでも変更できます。",
|
||||
"learn_more": "実際に送信される内容は?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "開く",
|
||||
"install_required_models": "続行するには、上記の必要なモデルをインストールしてください。",
|
||||
"try_dictation": "ディクテーションを試してみる",
|
||||
"try_dictation_desc": "セットアップを完了する前に、ショートカットを一度押してディクテーションが機能することを確認してください。",
|
||||
"dictation_step_label": "ディクテーション",
|
||||
"next_try_dictation": "次へ: ディクテーションを試してみる",
|
||||
"step_aria": "ステップ {{num}}: {{label}}",
|
||||
"step_completed": "完了",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "不明な声 ‘{{name}}’ — デフォルトを使用します",
|
||||
"warn_empty_chapter": "章「{{title}}」に読み上げるテキストがありません",
|
||||
"warn_unknown_tag": "不明なタグ {{tag}} — そのまま読み上げられます",
|
||||
"untitled": "無題",
|
||||
"untitled": "無題のオーディオブック",
|
||||
"dismiss": "閉じる",
|
||||
"output": "出力",
|
||||
"title": "オーディオブック",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "プレビュープラン",
|
||||
"stop": "停止",
|
||||
"stopped_note": "停止しました — 完了した章はキャッシュ済みです。再度「作成」で再開できます。",
|
||||
"recovery_title": "中断されたオーディオブック",
|
||||
"recovery_progress": "{{total}}章中{{done}}章がキャッシュされています",
|
||||
"recovery_hint": "再開すると完了済みの章を再利用し、残りだけを合成します。",
|
||||
"resume": "再開",
|
||||
"open_chapter_cache": "章のキャッシュを開く",
|
||||
"cache_location_hint": "章の音声キャッシュ: {{cachePath}} · 再開マニフェスト: {{manifestPath}}",
|
||||
"resume_failed": "オーディオブックを再開できませんでした: {{message}}",
|
||||
"open_cache_failed": "章のキャッシュを開けませんでした",
|
||||
"progress_summary": "{{total}} 章中 {{current}} 章を合成中",
|
||||
"eta": "残り約 {{time}}",
|
||||
"chapter_n": "第 {{n}} 章",
|
||||
|
||||
@@ -960,6 +960,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "VoiceStudio 개선에 참여하시겠어요?",
|
||||
"desc": "버그를 더 빨리 해결하는 데 도움이 되는 익명의 콘텐츠 없는 사용 통계 — 완전히 선택 사항이며 기본적으로 꺼져 있습니다.",
|
||||
"step_label": "VoiceStudio 개선",
|
||||
"body": "익명의 콘텐츠 없는 사용 통계입니다: 어떤 엔진이 사용되는지, 생성에 걸리는 시간, 오류 유형만 전송됩니다. 당신의 텍스트, 오디오, 파일, 신원을 알 수 있는 정보는 절대 전송되지 않습니다. 동의하지 않는 한 아무것도 전송되지 않으며, 설정 → 개인정보에서 언제든지 변경할 수 있습니다.",
|
||||
"learn_more": "정확히 무엇이 전송되나요?",
|
||||
@@ -1857,6 +1858,8 @@
|
||||
"open": "열기",
|
||||
"install_required_models": "계속하려면 위의 필수 모델을 설치하세요.",
|
||||
"try_dictation": "받아쓰기를 시도해보세요",
|
||||
"try_dictation_desc": "설정을 마치기 전에 단축키를 한 번 눌러 받아쓰기가 작동하는지 확인하세요.",
|
||||
"dictation_step_label": "받아쓰기",
|
||||
"next_try_dictation": "다음: 받아쓰기 사용해 보기",
|
||||
"step_aria": "{{num}}단계: {{label}}",
|
||||
"step_completed": "완료됨",
|
||||
@@ -2920,7 +2923,7 @@
|
||||
"warn_unknown_voice": "알 수 없는 음성 ‘{{name}}’ — 기본값을 사용합니다",
|
||||
"warn_empty_chapter": "챕터 “{{title}}”에 읽을 텍스트가 없습니다",
|
||||
"warn_unknown_tag": "인식할 수 없는 태그 {{tag}} — 그대로 읽힙니다",
|
||||
"untitled": "제목 없음",
|
||||
"untitled": "제목 없는 오디오북",
|
||||
"dismiss": "닫기",
|
||||
"output": "출력",
|
||||
"title": "오디오북",
|
||||
@@ -2932,6 +2935,14 @@
|
||||
"preview_plan": "계획 미리보기",
|
||||
"stop": "중지",
|
||||
"stopped_note": "중지됨 — 완료된 챕터는 캐시되어 있습니다. 다시 만들기로 이어서 진행하세요.",
|
||||
"recovery_title": "중단된 오디오북",
|
||||
"recovery_progress": "총 {{total}}개 챕터 중 {{done}}개가 캐시되었습니다",
|
||||
"recovery_hint": "재개하면 완료된 챕터를 재사용하고 남은 부분만 합성합니다.",
|
||||
"resume": "재개",
|
||||
"open_chapter_cache": "챕터 캐시 열기",
|
||||
"cache_location_hint": "챕터 오디오 캐시: {{cachePath}} · 재개 매니페스트: {{manifestPath}}",
|
||||
"resume_failed": "오디오북을 재개할 수 없습니다: {{message}}",
|
||||
"open_cache_failed": "챕터 캐시를 열 수 없습니다",
|
||||
"progress_summary": "{{total}}개 챕터 중 {{current}}개 합성 중",
|
||||
"eta": "~{{time}} 남음",
|
||||
"chapter_n": "{{n}}장",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Helpen VoiceStudio te verbeteren?",
|
||||
"desc": "Anonieme, inhoudsvrije gebruiksstatistieken die ons helpen bugs sneller op te lossen — volledig optioneel en standaard uitgeschakeld.",
|
||||
"step_label": "VoiceStudio verbeteren",
|
||||
"body": "Anonieme gebruiksstatistieken zonder inhoud: welke engines worden gebruikt, hoelang generaties duren en het type fouten. Nooit je tekst, je audio, je bestanden of iets dat jou identificeert. Er wordt niets verzonden tenzij je ja zegt, en je kunt je keuze altijd wijzigen in Instellingen → Privacy.",
|
||||
"learn_more": "Wat wordt er precies verzonden?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Openen",
|
||||
"install_required_models": "Installeer de vereiste modellen hierboven om door te gaan.",
|
||||
"try_dictation": "Probeer dicteren",
|
||||
"try_dictation_desc": "Druk één keer op je sneltoets om te controleren of dictee werkt voordat je de installatie voltooit.",
|
||||
"dictation_step_label": "Dictee",
|
||||
"next_try_dictation": "Volgende: Probeer dicteren",
|
||||
"step_aria": "Stap {{num}}: {{label}}",
|
||||
"step_completed": "voltooid",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Onbekende stem ‘{{name}}’ — de standaardstem wordt gebruikt",
|
||||
"warn_empty_chapter": "Hoofdstuk “{{title}}” bevat geen gesproken tekst",
|
||||
"warn_unknown_tag": "Onbekende tag {{tag}} — deze wordt voorgelezen",
|
||||
"untitled": "Naamloos",
|
||||
"untitled": "Luisterboek zonder titel",
|
||||
"dismiss": "Sluiten",
|
||||
"output": "Uitvoer",
|
||||
"title": "Audioboek",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Voorbeeldplan",
|
||||
"stop": "Stoppen",
|
||||
"stopped_note": "Gestopt — voltooide hoofdstukken staan in de cache; klik opnieuw op Maken om te hervatten.",
|
||||
"recovery_title": "Onderbroken luisterboek",
|
||||
"recovery_progress": "{{done}} van {{total}} hoofdstukken zijn gecachet",
|
||||
"recovery_hint": "Hervatten gebruikt voltooide hoofdstukken opnieuw en synthetiseert alleen wat resteert.",
|
||||
"resume": "Hervatten",
|
||||
"open_chapter_cache": "Hoofdstukcache openen",
|
||||
"cache_location_hint": "Hoofdstukaudiocache: {{cachePath}} · Hervattingsmanifest: {{manifestPath}}",
|
||||
"resume_failed": "Het luisterboek kon niet worden hervat: {{message}}",
|
||||
"open_cache_failed": "De hoofdstukcache kon niet worden geopend",
|
||||
"progress_summary": "{{current}} van {{total}} hoofdstukken aan het synthetiseren",
|
||||
"eta": "~{{time}} resterend",
|
||||
"chapter_n": "Hoofdstuk {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Pomóc ulepszyć VoiceStudio?",
|
||||
"desc": "Anonimowe statystyki użytkowania bez treści, które pomagają nam szybciej naprawiać błędy — całkowicie opcjonalne, domyślnie wyłączone.",
|
||||
"step_label": "Ulepsz VoiceStudio",
|
||||
"body": "Anonimowe statystyki użycia bez treści: które silniki są używane, jak długo trwa generowanie i jaki jest typ błędów. Nigdy Twój tekst, dźwięk, pliki ani nic, co Cię identyfikuje. Nic nie jest wysyłane, dopóki nie wyrazisz zgody, a decyzję możesz zmienić w każdej chwili w Ustawienia → Prywatność.",
|
||||
"learn_more": "Co dokładnie jest wysyłane?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Otwórz",
|
||||
"install_required_models": "Aby kontynuować, zainstaluj wymagane modele powyżej.",
|
||||
"try_dictation": "Spróbuj dyktowania",
|
||||
"try_dictation_desc": "Naciśnij swój skrót raz, aby upewnić się, że dyktowanie działa, zanim zakończysz konfigurację.",
|
||||
"dictation_step_label": "Dyktowanie",
|
||||
"next_try_dictation": "Dalej: Wypróbuj dyktowanie",
|
||||
"step_aria": "Krok {{num}}: {{label}}",
|
||||
"step_completed": "ukończono",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Nieznany głos „{{name}}” — zostanie użyty domyślny",
|
||||
"warn_empty_chapter": "Rozdział „{{title}}” nie zawiera tekstu do przeczytania",
|
||||
"warn_unknown_tag": "Nierozpoznany znacznik {{tag}} — zostanie odczytany na głos",
|
||||
"untitled": "Bez tytułu",
|
||||
"untitled": "Książka audio bez tytułu",
|
||||
"dismiss": "Odrzuć",
|
||||
"output": "Wyjście",
|
||||
"title": "Książka audio",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Podgląd planu",
|
||||
"stop": "Zatrzymaj",
|
||||
"stopped_note": "Zatrzymano — ukończone rozdziały są w pamięci podręcznej; kliknij Utwórz ponownie, aby wznowić.",
|
||||
"recovery_title": "Przerwana książka audio",
|
||||
"recovery_progress": "{{done}} z {{total}} rozdziałów jest w pamięci podręcznej",
|
||||
"recovery_hint": "Wznowienie wykorzystuje ukończone rozdziały i syntezuje tylko to, co pozostało.",
|
||||
"resume": "Wznów",
|
||||
"open_chapter_cache": "Otwórz pamięć podręczną rozdziałów",
|
||||
"cache_location_hint": "Pamięć podręczna dźwięku rozdziałów: {{cachePath}} · Manifest wznowienia: {{manifestPath}}",
|
||||
"resume_failed": "Nie można wznowić książki audio: {{message}}",
|
||||
"open_cache_failed": "Nie można otworzyć pamięci podręcznej rozdziałów",
|
||||
"progress_summary": "Synteza {{current}} z {{total}} rozdziałów",
|
||||
"eta": "~{{time}} pozostało",
|
||||
"chapter_n": "Rozdział {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Ajudar a melhorar o VoiceStudio?",
|
||||
"desc": "Estatísticas de uso anônimas e sem conteúdo que nos ajudam a corrigir bugs mais rápido — totalmente opcionais, desativadas por padrão.",
|
||||
"step_label": "Melhorar o VoiceStudio",
|
||||
"body": "Estatísticas de uso anônimas e sem conteúdo: quais mecanismos são usados, quanto tempo as gerações levam e o tipo de erros. Nunca o seu texto, o seu áudio, os seus arquivos ou qualquer coisa que o identifique. Nada é enviado a menos que você aceite, e você pode mudar de ideia a qualquer momento em Configurações → Privacidade.",
|
||||
"learn_more": "O que exatamente é enviado?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Abrir",
|
||||
"install_required_models": "Instale os modelos necessários acima para continuar.",
|
||||
"try_dictation": "Experimente o ditado",
|
||||
"try_dictation_desc": "Pressione seu atalho uma vez para confirmar que o ditado funciona antes de concluir a configuração.",
|
||||
"dictation_step_label": "Ditado",
|
||||
"next_try_dictation": "Próximo: Experimente o ditado",
|
||||
"step_aria": "Etapa {{num}}: {{label}}",
|
||||
"step_completed": "concluída",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Voz desconhecida «{{name}}» — a padrão será usada",
|
||||
"warn_empty_chapter": "O capítulo «{{title}}» não tem texto falado",
|
||||
"warn_unknown_tag": "Tag não reconhecida {{tag}} — será lida em voz alta",
|
||||
"untitled": "Sem título",
|
||||
"untitled": "Audiolivro sem título",
|
||||
"dismiss": "Dispensar",
|
||||
"output": "Saída",
|
||||
"title": "Audiolivro",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Plano de visualização",
|
||||
"stop": "Parar",
|
||||
"stopped_note": "Parado — os capítulos concluídos estão em cache; clique em Criar novamente para retomar.",
|
||||
"recovery_title": "Audiolivro interrompido",
|
||||
"recovery_progress": "{{done}} de {{total}} capítulos estão em cache",
|
||||
"recovery_hint": "Retomar reutiliza os capítulos concluídos e sintetiza apenas o que falta.",
|
||||
"resume": "Retomar",
|
||||
"open_chapter_cache": "Abrir cache dos capítulos",
|
||||
"cache_location_hint": "Cache de áudio dos capítulos: {{cachePath}} · Manifesto de retomada: {{manifestPath}}",
|
||||
"resume_failed": "Não foi possível retomar o audiolivro: {{message}}",
|
||||
"open_cache_failed": "Não foi possível abrir o cache dos capítulos",
|
||||
"progress_summary": "Sintetizando {{current}} de {{total}} capítulos",
|
||||
"eta": "~{{time}} restante",
|
||||
"chapter_n": "Capítulo {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Помочь улучшить VoiceStudio?",
|
||||
"desc": "Анонимная, не содержащая контента статистика использования, которая помогает нам быстрее исправлять ошибки — полностью необязательна, по умолчанию отключена.",
|
||||
"step_label": "Улучшить VoiceStudio",
|
||||
"body": "Анонимная статистика использования без содержимого: какие движки используются, сколько длится генерация и тип ошибок. Никогда — ваш текст, аудио, файлы или что-либо, идентифицирующее вас. Ничего не отправляется без вашего согласия, и вы можете изменить решение в любой момент в Настройки → Конфиденциальность.",
|
||||
"learn_more": "Что именно отправляется?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Открыть",
|
||||
"install_required_models": "Установите необходимые модели, указанные выше, чтобы продолжить.",
|
||||
"try_dictation": "Попробуйте диктовку",
|
||||
"try_dictation_desc": "Нажмите сочетание клавиш один раз, чтобы убедиться, что надиктовка работает, прежде чем завершить настройку.",
|
||||
"dictation_step_label": "Надиктовка",
|
||||
"next_try_dictation": "Далее: Попробуйте диктовку",
|
||||
"step_aria": "Шаг {{num}}: {{label}}",
|
||||
"step_completed": "завершён",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Неизвестный голос «{{name}}» — будет использован голос по умолчанию",
|
||||
"warn_empty_chapter": "В главе «{{title}}» нет произносимого текста",
|
||||
"warn_unknown_tag": "Нераспознанный тег {{tag}} — он будет прочитан вслух",
|
||||
"untitled": "Без названия",
|
||||
"untitled": "Аудиокнига без названия",
|
||||
"dismiss": "Закрыть",
|
||||
"output": "Вывод",
|
||||
"title": "Аудиокнига",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Предварительный план",
|
||||
"stop": "Остановить",
|
||||
"stopped_note": "Остановлено — готовые главы кэшированы; нажмите «Создать» снова, чтобы продолжить.",
|
||||
"recovery_title": "Прерванная аудиокнига",
|
||||
"recovery_progress": "В кэше сохранено {{done}} из {{total}} глав",
|
||||
"recovery_hint": "Продолжение использует готовые главы и синтезирует только оставшиеся.",
|
||||
"resume": "Продолжить",
|
||||
"open_chapter_cache": "Открыть кэш глав",
|
||||
"cache_location_hint": "Кэш аудио глав: {{cachePath}} · Манифест возобновления: {{manifestPath}}",
|
||||
"resume_failed": "Не удалось продолжить аудиокнигу: {{message}}",
|
||||
"open_cache_failed": "Не удалось открыть кэш глав",
|
||||
"progress_summary": "Синтез {{current}} из {{total}} глав",
|
||||
"eta": "~{{time}} осталось",
|
||||
"chapter_n": "Глава {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Hjälpa till att förbättra VoiceStudio?",
|
||||
"desc": "Anonym användningsstatistik utan innehåll som hjälper oss att fixa buggar snabbare — helt valfri, avstängd som standard.",
|
||||
"step_label": "Förbättra VoiceStudio",
|
||||
"body": "Anonym användningsstatistik utan innehåll: vilka motorer som används, hur lång tid genereringar tar och typen av fel. Aldrig din text, ditt ljud, dina filer eller något som identifierar dig. Inget skickas om du inte säger ja, och du kan ändra dig när som helst i Inställningar → Integritet.",
|
||||
"learn_more": "Vad skickas egentligen?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Öppna",
|
||||
"install_required_models": "Installera de modeller som krävs ovan för att fortsätta.",
|
||||
"try_dictation": "Prova diktering",
|
||||
"try_dictation_desc": "Tryck på din genväg en gång för att se att diktering fungerar innan du slutför installationen.",
|
||||
"dictation_step_label": "Diktering",
|
||||
"next_try_dictation": "Nästa: Prova diktering",
|
||||
"step_aria": "Steg {{num}}: {{label}}",
|
||||
"step_completed": "klart",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Okänd röst ‘{{name}}’ — standardrösten används",
|
||||
"warn_empty_chapter": "Kapitlet ”{{title}}” saknar uppläst text",
|
||||
"warn_unknown_tag": "Okänd tagg {{tag}} — den läses upp",
|
||||
"untitled": "Namnlös",
|
||||
"untitled": "Namnlös ljudbok",
|
||||
"dismiss": "Avfärda",
|
||||
"output": "Utdata",
|
||||
"title": "Ljudbok",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Förhandsgranska plan",
|
||||
"stop": "Stoppa",
|
||||
"stopped_note": "Stoppad — färdiga kapitel är cachade; klicka på Skapa igen för att fortsätta.",
|
||||
"recovery_title": "Avbruten ljudbok",
|
||||
"recovery_progress": "{{done}} av {{total}} kapitel är cachade",
|
||||
"recovery_hint": "Fortsätt återanvänder färdiga kapitel och syntetiserar bara det som återstår.",
|
||||
"resume": "Fortsätt",
|
||||
"open_chapter_cache": "Öppna kapitelcache",
|
||||
"cache_location_hint": "Cache för kapitelljud: {{cachePath}} · Återupptagningsmanifest: {{manifestPath}}",
|
||||
"resume_failed": "Det gick inte att fortsätta ljudboken: {{message}}",
|
||||
"open_cache_failed": "Det gick inte att öppna kapitelcachen",
|
||||
"progress_summary": "Syntetiserar {{current}} av {{total}} kapitel",
|
||||
"eta": "~{{time}} kvar",
|
||||
"chapter_n": "Kapitel {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "ช่วยปรับปรุง VoiceStudio ไหม?",
|
||||
"desc": "สถิติการใช้งานแบบไม่ระบุตัวตนและไม่มีเนื้อหา ที่ช่วยให้เราแก้ไขบั๊กได้เร็วขึ้น — เป็นทางเลือกทั้งหมด ปิดอยู่โดยค่าเริ่มต้น",
|
||||
"step_label": "ปรับปรุง VoiceStudio",
|
||||
"body": "สถิติการใช้งานแบบไม่ระบุตัวตนและไม่มีเนื้อหา: ใช้เอนจินใด การสร้างใช้เวลานานเท่าใด และประเภทของข้อผิดพลาด ไม่มีวันส่งข้อความ เสียง ไฟล์ของคุณ หรือสิ่งใดที่ระบุตัวตนของคุณ จะไม่มีการส่งข้อมูลใด ๆ เว้นแต่คุณจะตอบตกลง และคุณเปลี่ยนใจได้ทุกเมื่อใน การตั้งค่า → ความเป็นส่วนตัว",
|
||||
"learn_more": "ส่งอะไรบ้างกันแน่?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "เปิด",
|
||||
"install_required_models": "ติดตั้งรุ่นที่จำเป็นด้านบนเพื่อดำเนินการต่อ",
|
||||
"try_dictation": "ลองเขียนตามคำบอก",
|
||||
"try_dictation_desc": "กดปุ่มลัดของคุณหนึ่งครั้งเพื่อให้แน่ใจว่าการสั่งงานด้วยเสียงทำงานก่อนที่คุณจะตั้งค่าเสร็จ",
|
||||
"dictation_step_label": "การสั่งงานด้วยเสียง",
|
||||
"next_try_dictation": "ถัดไป: ลองใช้การเขียนตามคำบอก",
|
||||
"step_aria": "ขั้นตอนที่ {{num}}: {{label}}",
|
||||
"step_completed": "เสร็จสิ้น",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "เสียงที่ไม่รู้จัก ‘{{name}}’ — จะใช้เสียงเริ่มต้น",
|
||||
"warn_empty_chapter": "บท “{{title}}” ไม่มีข้อความสำหรับอ่าน",
|
||||
"warn_unknown_tag": "แท็กที่ไม่รู้จัก {{tag}} — จะถูกอ่านออกเสียง",
|
||||
"untitled": "ไม่มีชื่อ",
|
||||
"untitled": "หนังสือเสียงไม่มีชื่อ",
|
||||
"dismiss": "ปิด",
|
||||
"output": "เอาต์พุต",
|
||||
"title": "หนังสือเสียง",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "ดูตัวอย่างแผน",
|
||||
"stop": "หยุด",
|
||||
"stopped_note": "หยุดแล้ว — บทที่เสร็จถูกแคชไว้ กด สร้าง อีกครั้งเพื่อทำต่อ",
|
||||
"recovery_title": "หนังสือเสียงที่ถูกขัดจังหวะ",
|
||||
"recovery_progress": "แคชแล้ว {{done}} จาก {{total}} บท",
|
||||
"recovery_hint": "ทำต่อจะใช้บทที่เสร็จแล้วซ้ำ และสังเคราะห์เฉพาะส่วนที่เหลือ",
|
||||
"resume": "ทำต่อ",
|
||||
"open_chapter_cache": "เปิดแคชบท",
|
||||
"cache_location_hint": "แคชเสียงของบท: {{cachePath}} · ไฟล์กำกับการทำต่อ: {{manifestPath}}",
|
||||
"resume_failed": "ไม่สามารถทำหนังสือเสียงต่อได้: {{message}}",
|
||||
"open_cache_failed": "ไม่สามารถเปิดแคชบทได้",
|
||||
"progress_summary": "กำลังสังเคราะห์บทที่ {{current}} จาก {{total}} บท",
|
||||
"eta": "เหลือ ~{{time}}",
|
||||
"chapter_n": "บทที่ {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "VoiceStudio'un gelişmesine yardım eder misiniz?",
|
||||
"desc": "Hataları daha hızlı düzeltmemize yardımcı olan, anonim ve içerik barındırmayan kullanım istatistikleri — tamamen isteğe bağlı, varsayılan olarak kapalı.",
|
||||
"step_label": "VoiceStudio'u geliştir",
|
||||
"body": "Anonim, içerik barındırmayan kullanım istatistikleri: hangi motorların kullanıldığı, üretimlerin ne kadar sürdüğü ve hata türleri. Asla metniniz, sesiniz, dosyalarınız veya sizi tanımlayan hiçbir şey gönderilmez. Evet demedikçe hiçbir şey gönderilmez ve kararınızı istediğiniz zaman Ayarlar → Gizlilik'ten değiştirebilirsiniz.",
|
||||
"learn_more": "Tam olarak ne gönderiliyor?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Açık",
|
||||
"install_required_models": "Devam etmek için yukarıdaki gerekli modelleri yükleyin.",
|
||||
"try_dictation": "Dikte etmeyi deneyin",
|
||||
"try_dictation_desc": "Kurulumu bitirmeden önce dikteyi çalıştığından emin olmak için kısayolunuza bir kez basın.",
|
||||
"dictation_step_label": "Dikte",
|
||||
"next_try_dictation": "Sonraki: Dikteyi deneyin",
|
||||
"step_aria": "Adım {{num}}: {{label}}",
|
||||
"step_completed": "tamamlandı",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Bilinmeyen ses ‘{{name}}’ — varsayılan kullanılacak",
|
||||
"warn_empty_chapter": "“{{title}}” bölümünde seslendirilecek metin yok",
|
||||
"warn_unknown_tag": "Tanınmayan etiket {{tag}} — sesli okunacak",
|
||||
"untitled": "Başlıksız",
|
||||
"untitled": "Adsız sesli kitap",
|
||||
"dismiss": "Kapat",
|
||||
"output": "Çıktı",
|
||||
"title": "Sesli kitap",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Önizleme planı",
|
||||
"stop": "Durdur",
|
||||
"stopped_note": "Durduruldu — tamamlanan bölümler önbellekte; devam etmek için tekrar Oluştur'a basın.",
|
||||
"recovery_title": "Kesintiye uğrayan sesli kitap",
|
||||
"recovery_progress": "{{total}} bölümün {{done}} tanesi önbelleğe alındı",
|
||||
"recovery_hint": "Devam et, tamamlanan bölümleri yeniden kullanır ve yalnızca kalanı sentezler.",
|
||||
"resume": "Devam et",
|
||||
"open_chapter_cache": "Bölüm önbelleğini aç",
|
||||
"cache_location_hint": "Bölüm sesi önbelleği: {{cachePath}} · Devam bildirimi: {{manifestPath}}",
|
||||
"resume_failed": "Sesli kitap sürdürülemedi: {{message}}",
|
||||
"open_cache_failed": "Bölüm önbelleği açılamadı",
|
||||
"progress_summary": "{{total}} bölümden {{current}} tanesi sentezleniyor",
|
||||
"eta": "~{{time}} kaldı",
|
||||
"chapter_n": "Bölüm {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Допомогти покращити VoiceStudio?",
|
||||
"desc": "Анонімна статистика використання без вмісту, яка допомагає нам швидше виправляти помилки — повністю необов'язково, вимкнено за замовчуванням.",
|
||||
"step_label": "Покращити VoiceStudio",
|
||||
"body": "Анонімна статистика використання без вмісту: які рушії використовуються, скільки триває генерація та тип помилок. Ніколи — ваш текст, аудіо, файли чи будь-що, що вас ідентифікує. Нічого не надсилається без вашої згоди, і ви можете змінити рішення будь-коли в Налаштування → Приватність.",
|
||||
"learn_more": "Що саме надсилається?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "відкритий",
|
||||
"install_required_models": "Щоб продовжити, установіть необхідні моделі вище.",
|
||||
"try_dictation": "Спробуйте диктант",
|
||||
"try_dictation_desc": "Натисніть своє поєднання клавіш один раз, щоб переконатися, що диктування працює, перш ніж завершити налаштування.",
|
||||
"dictation_step_label": "Диктування",
|
||||
"next_try_dictation": "Далі: спробуйте надиктувати",
|
||||
"step_aria": "Крок {{num}}: {{label}}",
|
||||
"step_completed": "завершено",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Невідомий голос «{{name}}» — буде використано голос за замовчуванням",
|
||||
"warn_empty_chapter": "У розділі «{{title}}» немає тексту для озвучення",
|
||||
"warn_unknown_tag": "Нерозпізнаний тег {{tag}} — його буде прочитано вголос",
|
||||
"untitled": "Без назви",
|
||||
"untitled": "Аудіокнига без назви",
|
||||
"dismiss": "Закрити",
|
||||
"output": "Вивід",
|
||||
"title": "Аудіокнига",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Попередній перегляд плану",
|
||||
"stop": "Зупинити",
|
||||
"stopped_note": "Зупинено — готові розділи кешовані; натисніть «Створити» знову, щоб продовжити.",
|
||||
"recovery_title": "Перервана аудіокнига",
|
||||
"recovery_progress": "У кеші {{done}} з {{total}} розділів",
|
||||
"recovery_hint": "Продовження повторно використовує готові розділи й синтезує лише те, що залишилося.",
|
||||
"resume": "Продовжити",
|
||||
"open_chapter_cache": "Відкрити кеш розділів",
|
||||
"cache_location_hint": "Кеш аудіо розділів: {{cachePath}} · Маніфест відновлення: {{manifestPath}}",
|
||||
"resume_failed": "Не вдалося продовжити аудіокнигу: {{message}}",
|
||||
"open_cache_failed": "Не вдалося відкрити кеш розділів",
|
||||
"progress_summary": "Синтез {{current}} з {{total}} розділів",
|
||||
"eta": "~{{time}} залишилось",
|
||||
"chapter_n": "Розділ {{n}}",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "Giúp cải thiện VoiceStudio?",
|
||||
"desc": "Số liệu thống kê sử dụng ẩn danh, không chứa nội dung, giúp chúng tôi sửa lỗi nhanh hơn — hoàn toàn tùy chọn, mặc định tắt.",
|
||||
"step_label": "Cải thiện VoiceStudio",
|
||||
"body": "Thống kê sử dụng ẩn danh, không chứa nội dung: engine nào được dùng, thời gian tạo mất bao lâu và loại lỗi. Không bao giờ gửi văn bản, âm thanh, tệp của bạn hay bất cứ thứ gì nhận dạng bạn. Không có gì được gửi trừ khi bạn đồng ý, và bạn có thể đổi ý bất cứ lúc nào trong Cài đặt → Quyền riêng tư.",
|
||||
"learn_more": "Chính xác thì những gì được gửi?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "Mở",
|
||||
"install_required_models": "Cài đặt các mô hình cần thiết ở trên để tiếp tục.",
|
||||
"try_dictation": "Thử đọc chính tả",
|
||||
"try_dictation_desc": "Nhấn phím tắt của bạn một lần để chắc chắn tính năng đọc chính tả hoạt động trước khi hoàn tất thiết lập.",
|
||||
"dictation_step_label": "Đọc chính tả",
|
||||
"next_try_dictation": "Tiếp theo: Thử đọc chính tả",
|
||||
"step_aria": "Bước {{num}}: {{label}}",
|
||||
"step_completed": "đã hoàn thành",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "Giọng không xác định ‘{{name}}’ — sẽ dùng giọng mặc định",
|
||||
"warn_empty_chapter": "Chương “{{title}}” không có nội dung để đọc",
|
||||
"warn_unknown_tag": "Thẻ không nhận dạng {{tag}} — sẽ được đọc to",
|
||||
"untitled": "Không có tiêu đề",
|
||||
"untitled": "Sách nói không tiêu đề",
|
||||
"dismiss": "Bỏ qua",
|
||||
"output": "Đầu ra",
|
||||
"title": "Sách nói",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "Xem trước kế hoạch",
|
||||
"stop": "Dừng",
|
||||
"stopped_note": "Đã dừng — các chương đã xong được lưu trong bộ nhớ đệm; nhấn Tạo lại để tiếp tục.",
|
||||
"recovery_title": "Sách nói bị gián đoạn",
|
||||
"recovery_progress": "Đã lưu đệm {{done}} trong số {{total}} chương",
|
||||
"recovery_hint": "Tiếp tục sẽ dùng lại các chương đã hoàn thành và chỉ tổng hợp phần còn lại.",
|
||||
"resume": "Tiếp tục",
|
||||
"open_chapter_cache": "Mở bộ nhớ đệm chương",
|
||||
"cache_location_hint": "Bộ nhớ đệm âm thanh chương: {{cachePath}} · Tệp kê khai tiếp tục: {{manifestPath}}",
|
||||
"resume_failed": "Không thể tiếp tục sách nói: {{message}}",
|
||||
"open_cache_failed": "Không thể mở bộ nhớ đệm chương",
|
||||
"progress_summary": "Đang tổng hợp {{current}} trên {{total}} chương",
|
||||
"eta": "còn ~{{time}}",
|
||||
"chapter_n": "Chương {{n}}",
|
||||
|
||||
@@ -674,6 +674,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "帮助改进 VoiceStudio?",
|
||||
"desc": "匿名且不含内容的使用统计,可帮助我们更快修复问题——完全可选,默认关闭。",
|
||||
"step_label": "改进 VoiceStudio",
|
||||
"body": "匿名且不含内容的使用统计:使用了哪些引擎、生成耗时多久,以及错误的类型。绝不会发送你的文本、音频、文件或任何可识别你身份的信息。除非你同意,否则不会发送任何数据;你可以随时在 设置 → 隐私 中更改选择。",
|
||||
"learn_more": "究竟会发送什么?",
|
||||
@@ -1513,6 +1514,8 @@
|
||||
"open": "打开",
|
||||
"install_required_models": "请先安装以上必需模型。",
|
||||
"try_dictation": "试用听写",
|
||||
"try_dictation_desc": "完成设置前,按一次快捷键确认听写功能可以正常使用。",
|
||||
"dictation_step_label": "听写",
|
||||
"next_try_dictation": "下一步:试用听写",
|
||||
"step_aria": "第 {{num}} 步:{{label}}",
|
||||
"step_completed": "已完成",
|
||||
@@ -2523,7 +2526,7 @@
|
||||
"warn_unknown_voice": "未知声音 ‘{{name}}’ — 将使用默认声音",
|
||||
"warn_empty_chapter": "章节“{{title}}”没有可朗读的文本",
|
||||
"warn_unknown_tag": "无法识别的标记 {{tag}} — 将被朗读出来",
|
||||
"untitled": "无标题",
|
||||
"untitled": "未命名有声读物",
|
||||
"dismiss": "关闭",
|
||||
"output": "输出",
|
||||
"title": "有声读物",
|
||||
@@ -2535,6 +2538,14 @@
|
||||
"preview_plan": "预览计划",
|
||||
"stop": "停止",
|
||||
"stopped_note": "已停止 — 已完成的章节已缓存;再次点击创建以继续。",
|
||||
"recovery_title": "已中断的有声读物",
|
||||
"recovery_progress": "{{total}} 章中有 {{done}} 章已缓存",
|
||||
"recovery_hint": "继续时会复用已完成的章节,只合成剩余内容。",
|
||||
"resume": "继续",
|
||||
"open_chapter_cache": "打开章节缓存",
|
||||
"cache_location_hint": "章节音频缓存:{{cachePath}} · 续传清单:{{manifestPath}}",
|
||||
"resume_failed": "无法继续有声读物:{{message}}",
|
||||
"open_cache_failed": "无法打开章节缓存",
|
||||
"progress_summary": "正在合成第 {{current}}/{{total}} 章",
|
||||
"eta": "剩余约 {{time}}",
|
||||
"chapter_n": "第 {{n}} 章",
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
},
|
||||
"consent": {
|
||||
"title": "協助改進 VoiceStudio?",
|
||||
"desc": "匿名且不含內容的使用統計,能協助我們更快修正錯誤——完全可選,預設為關閉。",
|
||||
"step_label": "改進 VoiceStudio",
|
||||
"body": "匿名且不含內容的使用統計:使用了哪些引擎、生成花費多久,以及錯誤的類型。絕不會傳送你的文字、音訊、檔案或任何可識別你身分的資訊。除非你同意,否則不會傳送任何資料;你可以隨時在 設定 → 隱私 中變更選擇。",
|
||||
"learn_more": "究竟會傳送什麼?",
|
||||
@@ -1554,6 +1555,8 @@
|
||||
"open": "打開",
|
||||
"install_required_models": "安裝上面所需的模型以繼續。",
|
||||
"try_dictation": "試著聽寫",
|
||||
"try_dictation_desc": "完成設定前,按一次快捷鍵確認聽寫功能可以正常運作。",
|
||||
"dictation_step_label": "聽寫",
|
||||
"next_try_dictation": "下一步:嘗試聽寫",
|
||||
"step_aria": "第 {{num}} 步:{{label}}",
|
||||
"step_completed": "已完成",
|
||||
@@ -2516,7 +2519,7 @@
|
||||
"warn_unknown_voice": "未知聲音 ‘{{name}}’ — 將使用預設聲音",
|
||||
"warn_empty_chapter": "章節「{{title}}」沒有可朗讀的文字",
|
||||
"warn_unknown_tag": "無法辨識的標記 {{tag}} — 將被朗讀出來",
|
||||
"untitled": "無標題",
|
||||
"untitled": "未命名有聲書",
|
||||
"dismiss": "關閉",
|
||||
"output": "輸出",
|
||||
"title": "有聲書",
|
||||
@@ -2528,6 +2531,14 @@
|
||||
"preview_plan": "預覽計劃",
|
||||
"stop": "停止",
|
||||
"stopped_note": "已停止 — 已完成的章節已快取;再次點擊建立以繼續。",
|
||||
"recovery_title": "已中斷的有聲書",
|
||||
"recovery_progress": "{{total}} 章中有 {{done}} 章已快取",
|
||||
"recovery_hint": "繼續時會重複使用已完成的章節,只合成剩餘內容。",
|
||||
"resume": "繼續",
|
||||
"open_chapter_cache": "開啟章節快取",
|
||||
"cache_location_hint": "章節音訊快取:{{cachePath}} · 繼續資訊清單:{{manifestPath}}",
|
||||
"resume_failed": "無法繼續有聲書:{{message}}",
|
||||
"open_cache_failed": "無法開啟章節快取",
|
||||
"progress_summary": "正在合成第 {{current}}/{{total}} 章",
|
||||
"eta": "剩餘約 {{time}}",
|
||||
"chapter_n": "第 {{n}} 章",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
audiobookPlan,
|
||||
audiobookGenerate,
|
||||
audiobookResume,
|
||||
audiobookUploadCover,
|
||||
audiobookPreviewChapter,
|
||||
audiobookImport,
|
||||
@@ -20,6 +21,7 @@ import StatsBar from '../components/audiobook/StatsBar';
|
||||
import ValidationWarnings from '../components/audiobook/ValidationWarnings';
|
||||
import AudiobookHero from '../components/audiobook/AudiobookHero';
|
||||
import AudiobookInspector from '../components/audiobook/AudiobookInspector';
|
||||
import AudiobookRecovery from '../components/audiobook/AudiobookRecovery';
|
||||
import { useAudiobookLexicon } from '../hooks/useAudiobookLexicon';
|
||||
import { parseCastNames, validateScript } from '../utils/audiobookScript';
|
||||
import { SAMPLE_AUDIOBOOK_SCRIPT } from '../data/sampleAudiobook';
|
||||
@@ -239,47 +241,8 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
[text, defaultVoice, lex, overrides, language, voiceMapArg],
|
||||
);
|
||||
|
||||
const onCreate = useCallback(async () => {
|
||||
setError('');
|
||||
setOutput('');
|
||||
setDone(null);
|
||||
setStopped(false);
|
||||
setChapters([]);
|
||||
chaptersRef.current = [];
|
||||
setAssembling(false);
|
||||
setGenerating(true);
|
||||
abortRef.current = false;
|
||||
// A per-generation AbortController: Stop aborts it, which cancels the fetch
|
||||
// end-to-end so the backend sees the disconnect and stops rendering (#1216).
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
try {
|
||||
let cover_path = null;
|
||||
if (coverFile) {
|
||||
cover_path = (await audiobookUploadCover(coverFile)).path;
|
||||
}
|
||||
// Only send metadata fields the user actually filled in.
|
||||
const metadata = Object.fromEntries(Object.entries(meta).filter(([, v]) => v && v.trim()));
|
||||
const lexicon = lexDict();
|
||||
const res = await audiobookGenerate(
|
||||
{
|
||||
text,
|
||||
default_voice: defaultVoice || null,
|
||||
format,
|
||||
loudness: loudness === 'off' ? null : loudness,
|
||||
cover_path,
|
||||
metadata: Object.keys(metadata).length ? metadata : null,
|
||||
lexicon: Object.keys(lexicon).length ? lexicon : null,
|
||||
// Multi-voice cast map (#1217): [voice:NAME] → profile id. Absent when
|
||||
// empty, so a single-voice book stays byte-identical to before.
|
||||
voice_map: voiceMapArg,
|
||||
// language pick + expressive/quality overrides + cache opt-out (#1208).
|
||||
// Only non-default values are emitted, so an untouched panel keeps the
|
||||
// request byte-identical to before.
|
||||
...overridesToRequest(overrides, language),
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const consumeGeneration = useCallback(
|
||||
async (res, snapshotScript = null) => {
|
||||
await consumeLongformStream(
|
||||
res,
|
||||
(evt) => {
|
||||
@@ -326,7 +289,8 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
} else if (evt.type === 'stopped') {
|
||||
setStopped(true);
|
||||
} else if (evt.type === 'done') {
|
||||
setOutputSnapshot(evt.output, text, chaptersRef.current);
|
||||
if (snapshotScript === null) setOutput(evt.output);
|
||||
else setOutputSnapshot(evt.output, snapshotScript, chaptersRef.current);
|
||||
setDone({
|
||||
cached_chapters: evt.cached_chapters || 0,
|
||||
failed_chapters: evt.failed_chapters || [],
|
||||
@@ -335,10 +299,56 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
setError(evt.error || 'synthesis failed');
|
||||
}
|
||||
},
|
||||
{ isAborted: () => abortRef.current, signal: controller.signal },
|
||||
{ isAborted: () => abortRef.current, signal: abortControllerRef.current?.signal },
|
||||
);
|
||||
// consumeLongformStream returns (never throws) on a caller-initiated stop.
|
||||
if (abortRef.current) setStopped(true);
|
||||
},
|
||||
[setOutput, setOutputSnapshot],
|
||||
);
|
||||
|
||||
const onCreate = useCallback(async () => {
|
||||
setError('');
|
||||
setOutput('');
|
||||
setDone(null);
|
||||
setStopped(false);
|
||||
setChapters([]);
|
||||
chaptersRef.current = [];
|
||||
setAssembling(false);
|
||||
setGenerating(true);
|
||||
abortRef.current = false;
|
||||
// A per-generation AbortController: Stop aborts it, which cancels the fetch
|
||||
// end-to-end so the backend sees the disconnect and stops rendering (#1216).
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
try {
|
||||
let cover_path = null;
|
||||
if (coverFile) {
|
||||
cover_path = (await audiobookUploadCover(coverFile)).path;
|
||||
}
|
||||
// Only send metadata fields the user actually filled in.
|
||||
const metadata = Object.fromEntries(Object.entries(meta).filter(([, v]) => v && v.trim()));
|
||||
const lexicon = lexDict();
|
||||
const res = await audiobookGenerate(
|
||||
{
|
||||
text,
|
||||
default_voice: defaultVoice || null,
|
||||
format,
|
||||
loudness: loudness === 'off' ? null : loudness,
|
||||
cover_path,
|
||||
metadata: Object.keys(metadata).length ? metadata : null,
|
||||
lexicon: Object.keys(lexicon).length ? lexicon : null,
|
||||
// Multi-voice cast map (#1217): [voice:NAME] → profile id. Absent when
|
||||
// empty, so a single-voice book stays byte-identical to before.
|
||||
voice_map: voiceMapArg,
|
||||
// language pick + expressive/quality overrides + cache opt-out (#1208).
|
||||
// Only non-default values are emitted, so an untouched panel keeps the
|
||||
// request byte-identical to before.
|
||||
...overridesToRequest(overrides, language),
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await consumeGeneration(res, text);
|
||||
} catch (e) {
|
||||
// A Stop that lands before/around the first byte aborts the fetch →
|
||||
// AbortError. Treat every self-initiated abort as "Stopped", not an error.
|
||||
@@ -360,9 +370,45 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
overrides,
|
||||
language,
|
||||
voiceMapArg,
|
||||
setOutputSnapshot,
|
||||
consumeGeneration,
|
||||
]);
|
||||
|
||||
const onResume = useCallback(
|
||||
async (jobId) => {
|
||||
let accepted = false;
|
||||
setError('');
|
||||
setOutput('');
|
||||
setDone(null);
|
||||
setStopped(false);
|
||||
setChapters([]);
|
||||
chaptersRef.current = [];
|
||||
setAssembling(false);
|
||||
setGenerating(true);
|
||||
abortRef.current = false;
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
try {
|
||||
const res = await audiobookResume(jobId, { signal: controller.signal });
|
||||
accepted = true;
|
||||
// The manifest contains the original chapter plan, but the endpoint does
|
||||
// not expose its source script. Keep playback, but do not attach whatever
|
||||
// script happens to be open now as misleading synced lyrics.
|
||||
await consumeGeneration(res);
|
||||
} catch (error) {
|
||||
if (abortRef.current || error?.name === 'AbortError') setStopped(true);
|
||||
else {
|
||||
setError(t('audiobook.resume_failed', { message: error?.message || String(error) }));
|
||||
}
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
setAssembling(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
return accepted;
|
||||
},
|
||||
[consumeGeneration, setOutput, t],
|
||||
);
|
||||
|
||||
// Stop = abort the fetch (cancels the request → backend disconnect) AND flip
|
||||
// the isAborted flag the stream consumer polls, so the read loop releases too.
|
||||
const onStop = useCallback(() => {
|
||||
@@ -400,6 +446,8 @@ export default function AudiobookTab({ profiles = [] }) {
|
||||
onStop={onStop}
|
||||
/>
|
||||
|
||||
<AudiobookRecovery t={t} generating={generating} onResume={onResume} />
|
||||
|
||||
<div className="audiobook-tab__body grid flex-auto grid-cols-[minmax(0,1fr)_minmax(440px,500px)] max-[1120px]:grid-cols-1 gap-[14px] min-h-0">
|
||||
{/* Left: script editor fills the height */}
|
||||
<div className="audiobook-tab__script flex flex-col min-h-0 gap-[7px]">
|
||||
|
||||
@@ -179,7 +179,17 @@ export default function Launchpad({
|
||||
{/* Hero — an eyebrow, one serif line, one sentence. Everything that used
|
||||
to compete with it (boxed number pill, filled CTA) is now quiet type;
|
||||
a hairline underneath does the separating that a card would have. */}
|
||||
<div className="relative z-[1] mx-auto w-full max-w-[1180px] overflow-hidden px-[44px] pb-[26px] pt-[38px] @max-[900px]/launchpad:px-[20px] @max-[900px]/launchpad:pb-[20px] @max-[900px]/launchpad:pt-[26px]">
|
||||
{/* shrink-0 on all three blocks below (#1859). `.launchpad` is a
|
||||
scrollable flex column, and a flex item shrinks before its container
|
||||
scrolls — so opening the log panel, which shrinks grid row 2, was
|
||||
absorbed by these children instead. The hero is `overflow-hidden`, so
|
||||
it clipped its own heading mid-line, and the deck rode up over it.
|
||||
Measured in Chromium at a 720px window with the panel at 560px: the
|
||||
hero box went 145px → 64px against a 145px natural height, and the
|
||||
deck's top landed 22px ABOVE the h1's bottom. With shrink-0 the hero
|
||||
stays 145px and `.launchpad` scrolls, which is what its
|
||||
`overflow-y: auto` was for. */}
|
||||
<div className="relative z-[1] mx-auto w-full max-w-[1180px] shrink-0 overflow-hidden px-[44px] pb-[26px] pt-[38px] @max-[900px]/launchpad:px-[20px] @max-[900px]/launchpad:pb-[20px] @max-[900px]/launchpad:pt-[26px]">
|
||||
{/* The signal-field waveform (the same cover the project wears on the
|
||||
web) bleeds in from the right, where the hero has only air — the
|
||||
mask ends it well before the text column, and a bottom fade keeps
|
||||
@@ -252,12 +262,12 @@ export default function Launchpad({
|
||||
down to the 900×600 minimum without a viewport @media. `shellNarrow`
|
||||
(the app-container's own width class) only tunes how comfortably the
|
||||
columns pack. */}
|
||||
<div className="relative z-[1] mx-auto w-full max-w-[1180px] px-[44px] py-[4px] @max-[900px]/launchpad:px-[20px] @max-[640px]/launchpad:px-[12px]">
|
||||
<div className="relative z-[1] mx-auto w-full max-w-[1180px] shrink-0 px-[44px] py-[4px] @max-[900px]/launchpad:px-[20px] @max-[640px]/launchpad:px-[12px]">
|
||||
<LaunchpadDeck features={features} narrow={shellNarrow} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative z-[1] mx-auto grid w-full max-w-[1180px] [grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr))] gap-6 px-[44px] pt-6 pb-8 @max-[900px]/launchpad:px-[20px] @max-[640px]/launchpad:px-[12px]"
|
||||
className="relative z-[1] mx-auto grid w-full max-w-[1180px] shrink-0 [grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr))] gap-6 px-[44px] pt-6 pb-8 @max-[900px]/launchpad:px-[20px] @max-[640px]/launchpad:px-[12px]"
|
||||
data-testid="launchpad-library-grid"
|
||||
>
|
||||
{/* Demo profile callout */}
|
||||
@@ -484,7 +494,15 @@ export default function Launchpad({
|
||||
)}
|
||||
|
||||
{/* Show checklist alongside existing projects too, but only when issues exist */}
|
||||
{(profiles.length > 0 || studioProjects.length > 0) && <ReadinessChecklist compact />}
|
||||
{/* Wrapped rather than given the class directly: ReadinessChecklist takes
|
||||
no className, is mounted twice (nested inside the empty state as well
|
||||
as here), and shrink-0 is a fact about THIS parent's flex column, not
|
||||
about the component. */}
|
||||
{(profiles.length > 0 || studioProjects.length > 0) && (
|
||||
<div className="shrink-0">
|
||||
<ReadinessChecklist compact />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,8 +119,15 @@ function PreflightPanel({ report, loading, onRecheck }) {
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-semibold">{c.label}</span>
|
||||
{/* dir="rtl" moves the ellipsis to the START of the line so a long
|
||||
path keeps its tail visible. The text itself must stay in an
|
||||
isolate: in an RTL paragraph the bidi algorithm places a
|
||||
LEADING run of numbers or neutrals (a digit, a "/") at the
|
||||
visual right edge, so "48.0 GB total" renders "GB total 48.0".
|
||||
<bdi> is dir="auto", so the run is ordered by its own content
|
||||
and only the ellipsis side follows the rtl box. */}
|
||||
<span className="truncate text-xs text-fg-muted" dir="rtl" title={c.detail}>
|
||||
{c.detail}
|
||||
<bdi>{c.detail}</bdi>
|
||||
</span>
|
||||
{c.fix && c.status !== 'pass' && (
|
||||
<span
|
||||
@@ -284,14 +291,20 @@ export default function SetupWizard({ onReady }) {
|
||||
const STEP_SUBTITLES = {
|
||||
system: t('setup.system_check_desc'),
|
||||
models: t('setup.install_models_desc'),
|
||||
consent: t('consent.title', 'Help improve VoiceStudio?'),
|
||||
dictation: t('setup.try_dictation'),
|
||||
consent: t(
|
||||
'consent.desc',
|
||||
'Anonymous, content-free usage stats that help us fix bugs faster — entirely optional, off by default.',
|
||||
),
|
||||
dictation: t(
|
||||
'setup.try_dictation_desc',
|
||||
'Press your shortcut once so you know dictation works before you finish setup.',
|
||||
),
|
||||
};
|
||||
const STEP_LABELS = {
|
||||
system: t('setup.system_check'),
|
||||
models: t('firstrun.stage_models', 'Models & engines'),
|
||||
consent: t('consent.step_label', 'Improve VoiceStudio'),
|
||||
dictation: t('setup.try_dictation'),
|
||||
dictation: t('setup.dictation_step_label', 'Dictation'),
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -440,7 +453,6 @@ export default function SetupWizard({ onReady }) {
|
||||
className="fr-rise flex min-h-0 flex-1 flex-col gap-2.5"
|
||||
style={{ '--rise': 1 }}
|
||||
>
|
||||
<SectionHead>{t('consent.title', 'Help improve VoiceStudio?')}</SectionHead>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
|
||||
<AnalyticsConsentCard onDone={() => setStep(step + 1)} />
|
||||
</div>
|
||||
@@ -464,7 +476,6 @@ export default function SetupWizard({ onReady }) {
|
||||
className="fr-rise flex min-h-0 flex-1 flex-col gap-2.5"
|
||||
style={{ '--rise': 1 }}
|
||||
>
|
||||
<SectionHead>{t('setup.try_dictation')}</SectionHead>
|
||||
<div className="max-h-[min(58vh,640px)] min-w-0 overflow-y-auto rounded-lg">
|
||||
<DictationDemo />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import i18n from '../i18n';
|
||||
import en from '../i18n/locales/en.json';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
jobs: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
reveal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/audiobook', () => ({
|
||||
audiobookListJobs: mocks.jobs,
|
||||
audiobookResume: mocks.resume,
|
||||
audiobookPlan: vi.fn(),
|
||||
audiobookGenerate: vi.fn(),
|
||||
audiobookUploadCover: vi.fn(),
|
||||
audiobookPreviewChapter: vi.fn(),
|
||||
audiobookImport: vi.fn(),
|
||||
}));
|
||||
vi.mock('../api/exports', () => ({ exportReveal: mocks.reveal }));
|
||||
vi.mock('../api/engines', () => ({
|
||||
listEngines: vi.fn().mockResolvedValue({ tts: { active: 'x', backends: [] } }),
|
||||
}));
|
||||
vi.mock('../api/generate', () => ({ audioUrl: (file) => `/audio/${file}` }));
|
||||
vi.mock('../api/system', () => ({
|
||||
systemInfo: vi.fn().mockResolvedValue({ outputs_dir: '/data/outputs' }),
|
||||
}));
|
||||
|
||||
import AudiobookRecovery from '../components/audiobook/AudiobookRecovery';
|
||||
import AudiobookTab from '../pages/AudiobookTab';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
function renderRecovery(onResume = vi.fn()) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return {
|
||||
onResume,
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<AudiobookRecovery t={i18n.t.bind(i18n)} generating={false} onResume={onResume} />
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Audiobook recovery surface (#1911)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
mocks.jobs.mockResolvedValue({
|
||||
jobs: [
|
||||
{
|
||||
job_id: 'job-safe-123',
|
||||
type: 'audiobook',
|
||||
status: 'cancelled',
|
||||
title: 'A Long Book',
|
||||
total_chapters: 30,
|
||||
chapters_done: 6,
|
||||
created_at: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.reveal.mockResolvedValue({ success: true });
|
||||
useAppStore.getState().setLastOutput('');
|
||||
mocks.resume.mockResolvedValue({
|
||||
body: {
|
||||
getReader: () => {
|
||||
let sent = false;
|
||||
return {
|
||||
read: () => {
|
||||
if (sent) return Promise.resolve({ done: true });
|
||||
sent = true;
|
||||
return Promise.resolve({
|
||||
done: false,
|
||||
value: new TextEncoder().encode(
|
||||
'data: {"type":"started","job_id":"fresh","chapters":1}\n\n' +
|
||||
'data: {"type":"chapter","index":0,"total":1,"title":"One","cached":true}\n\n' +
|
||||
'data: {"type":"done","output":"audiobook_fresh.m4b","cached_chapters":1,"failed_chapters":[]}\n\n',
|
||||
),
|
||||
});
|
||||
},
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('shows interrupted work and resumes the selected backend job', async () => {
|
||||
const onResume = vi.fn().mockResolvedValue(true);
|
||||
renderRecovery(onResume);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: en.audiobook.recovery_title })).toBeTruthy();
|
||||
expect(screen.getByText('A Long Book')).toBeTruthy();
|
||||
expect(screen.getByText('6 of 30 chapters are cached', { exact: false })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.audiobook.resume }));
|
||||
expect(onResume).toHaveBeenCalledWith('job-safe-123');
|
||||
mocks.jobs.mockResolvedValue({ jobs: [] });
|
||||
await waitFor(() => expect(screen.queryByText('A Long Book')).toBeNull());
|
||||
});
|
||||
|
||||
it('refreshes recovery inventory after an interrupted resumed render', async () => {
|
||||
const onResume = vi.fn().mockImplementation(async () => {
|
||||
mocks.jobs.mockResolvedValue({
|
||||
jobs: [
|
||||
{
|
||||
job_id: 'job-fresh-456',
|
||||
type: 'audiobook',
|
||||
status: 'cancelled',
|
||||
title: 'A Long Book (resumed)',
|
||||
total_chapters: 30,
|
||||
chapters_done: 9,
|
||||
created_at: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
return true;
|
||||
});
|
||||
renderRecovery(onResume);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: en.audiobook.resume }));
|
||||
|
||||
expect(await screen.findByText('A Long Book (resumed)')).toBeTruthy();
|
||||
expect(mocks.jobs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('restores the recovery card when the post-resume inventory refresh fails', async () => {
|
||||
const onResume = vi.fn().mockImplementation(async () => {
|
||||
mocks.jobs.mockRejectedValueOnce(new Error('inventory offline'));
|
||||
return true;
|
||||
});
|
||||
renderRecovery(onResume);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: en.audiobook.resume }));
|
||||
|
||||
expect(await screen.findByText('A Long Book')).toBeTruthy();
|
||||
expect(mocks.jobs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reveals the server-owned chapter cache through the existing safe seam', async () => {
|
||||
renderRecovery();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: en.audiobook.open_chapter_cache }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.reveal).toHaveBeenCalledWith({ path: '/data/outputs/longform_cache' }),
|
||||
);
|
||||
expect(screen.getByText(/\/data\/outputs\/longform_cache/)).toBeTruthy();
|
||||
expect(screen.getByText(/\/data\/outputs\/audiobook_job-safe-123\/resume\.json/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('stays out of the workspace when no resumable audiobook exists', async () => {
|
||||
mocks.jobs.mockResolvedValue({ jobs: [] });
|
||||
renderRecovery();
|
||||
|
||||
await waitFor(() => expect(mocks.jobs).toHaveBeenCalledOnce());
|
||||
expect(screen.queryByRole('heading', { name: en.audiobook.recovery_title })).toBeNull();
|
||||
});
|
||||
|
||||
it('wires Resume to the backend stream and restores a playable result', async () => {
|
||||
useAppStore.getState().setScript('# Different open draft\nDo not attach this as lyrics.');
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<AudiobookTab profiles={[]} />
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: en.audiobook.resume }));
|
||||
await waitFor(() => expect(mocks.resume).toHaveBeenCalledOnce());
|
||||
expect(mocks.resume.mock.calls[0][0]).toBe('job-safe-123');
|
||||
expect(mocks.resume.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
|
||||
await waitFor(() => expect(useAppStore.getState().lastOutput).toBe('audiobook_fresh.m4b'));
|
||||
expect(screen.getByText(en.audiobook.ready, { exact: false })).toBeTruthy();
|
||||
expect(useAppStore.getState().lastOutputScript).not.toContain('Different open draft');
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,8 @@ const gen = {
|
||||
},
|
||||
};
|
||||
vi.mock('../api/audiobook', () => ({
|
||||
audiobookListJobs: vi.fn().mockResolvedValue({ jobs: [] }),
|
||||
audiobookResume: vi.fn(),
|
||||
audiobookPlan: vi.fn(),
|
||||
audiobookUploadCover: vi.fn(),
|
||||
audiobookPreviewChapter: vi.fn(),
|
||||
|
||||
@@ -17,6 +17,8 @@ vi.mock('../api/engines', () => ({
|
||||
}));
|
||||
vi.mock('../api/generate', () => ({ audioUrl: (f) => `http://test.local/audio/${f}` }));
|
||||
vi.mock('../api/audiobook', () => ({
|
||||
audiobookListJobs: vi.fn().mockResolvedValue({ jobs: [] }),
|
||||
audiobookResume: vi.fn(),
|
||||
audiobookPlan: vi.fn(),
|
||||
audiobookGenerate: vi.fn(),
|
||||
audiobookUploadCover: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* The bootstrap log window is a DOM budget, not a retention policy (#1847).
|
||||
*
|
||||
* `MAX_LOG_LINES = 200` capped the `logs` state itself, so every line past the
|
||||
* 200th silently destroyed the earlier ones. Four things read that array, and
|
||||
* all four degraded once a real cold install went past the cap:
|
||||
*
|
||||
* - the Activity heading rendered `logs.length`, so the counter sat pinned at
|
||||
* exactly 200 for the rest of a multi-minute bootstrap while lines were
|
||||
* still streaming underneath. Live progress read as stalled when it was not.
|
||||
* - `handleCopyLogs` serialized the same array, so Copy could only ever hand
|
||||
* back the newest 200 lines — and this splash is the only place in the app
|
||||
* with a Copy-log affordance at all.
|
||||
* - `detectHints(message, logs)` scans for actionable failure markers. A
|
||||
* failure that happened early in a long install lost its marker, so the
|
||||
* card fell back to `hint_default` and the user was told nothing useful.
|
||||
* - `isUnrecoverableFailure(message, logs)` runs off the same scan.
|
||||
*
|
||||
* The fix keeps the whole run in state and slices only where the DOM is
|
||||
* written. The array is bounded by one bootstrap: both retry paths clear it,
|
||||
* and App.jsx unmounts the splash the moment the stage flips to 'ready'.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { BootstrapSplash } from '../components/BootstrapSplash';
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...a) => invoke(...a) }));
|
||||
|
||||
let logHandler = null;
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: vi.fn(async (name, cb) => {
|
||||
if (name === 'bootstrap-log') logHandler = cb;
|
||||
return () => {};
|
||||
}),
|
||||
}));
|
||||
vi.mock('@tauri-apps/plugin-opener', () => ({ revealItemInDir: vi.fn() }));
|
||||
|
||||
const copied = [];
|
||||
vi.mock('../utils/copyText', () => ({
|
||||
copyText: (text) => {
|
||||
copied.push(text);
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Stream `n` lines through the live `bootstrap-log` listener, the first
|
||||
* carrying a marker that has to survive.
|
||||
*
|
||||
* Deliberately NOT the `get_bootstrap_logs` backfill: that path replaced the
|
||||
* array wholesale and never sliced, so it was already unaffected by the cap.
|
||||
* Only the live append trimmed, which is why the bug needed a long *running*
|
||||
* install to show up rather than a long buffered one.
|
||||
*/
|
||||
const stream = async (n, firstLine) => {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
logHandler({
|
||||
payload: {
|
||||
stage: 'installing_deps',
|
||||
line: i === 0 ? firstLine : `dependency step ${i}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
logHandler = null;
|
||||
copied.length = 0;
|
||||
invoke.mockReset();
|
||||
invoke.mockResolvedValue([]);
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
});
|
||||
|
||||
describe('retention holds up under a real installer stream', () => {
|
||||
it('keeps a repeated line instead of mistaking it for a replay', async () => {
|
||||
// Installer output repeats constantly ("Downloading…", an identical pip
|
||||
// line per wheel). The dedup window used to run for the whole bootstrap,
|
||||
// so any line matching one of the last five was dropped — the counter
|
||||
// undercounted and Copy lost real output. It now guards only the
|
||||
// backfill→live seam, which is the only place the ambiguity exists.
|
||||
render(<BootstrapSplash stage="installing_deps" />);
|
||||
await waitFor(() => expect(logHandler).toBeTypeOf('function'));
|
||||
await act(async () => {
|
||||
for (const line of ['Downloading…', 'Resolving…', 'Downloading…']) {
|
||||
logHandler({ payload: { stage: 'installing_deps', line } });
|
||||
}
|
||||
});
|
||||
|
||||
await screen.findByText('3 lines');
|
||||
screen.getByRole('button', { name: /Copy/ }).click();
|
||||
await waitFor(() => expect(copied).toHaveLength(1));
|
||||
expect(copied[0].split('\n')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('still drops the backfill overlap at the seam', async () => {
|
||||
// The narrowing must not give up what the dedup was for: a line already
|
||||
// delivered by get_bootstrap_logs and then re-emitted live is one line.
|
||||
invoke.mockImplementation(async (cmd) =>
|
||||
cmd === 'get_bootstrap_logs'
|
||||
? [
|
||||
{ stage: 'creating_venv', line: 'venv created' },
|
||||
{ stage: 'installing_deps', line: 'resolving dependencies' },
|
||||
]
|
||||
: null,
|
||||
);
|
||||
render(<BootstrapSplash stage="installing_deps" />);
|
||||
await screen.findByText('2 lines');
|
||||
await waitFor(() => expect(logHandler).toBeTypeOf('function'));
|
||||
|
||||
await act(async () => {
|
||||
logHandler({ payload: { stage: 'installing_deps', line: 'resolving dependencies' } });
|
||||
logHandler({ payload: { stage: 'installing_deps', line: 'building wheels' } });
|
||||
});
|
||||
|
||||
// The replay is swallowed, the genuinely new line is not.
|
||||
await screen.findByText('3 lines');
|
||||
});
|
||||
|
||||
it('bounds the per-event copy no matter how long the run gets', async () => {
|
||||
// The retained run is a ref that gets pushed to, so appending is O(1);
|
||||
// the only array copied per event is `logs`, and this pins the property
|
||||
// that keeps it cheap — it never grows past the window, even at 5000
|
||||
// lines. Without that bound the append is O(n²) element copies and the
|
||||
// splash stutters on exactly the verbose installs that need it most.
|
||||
const { container } = render(<BootstrapSplash stage="installing_deps" />);
|
||||
await waitFor(() => expect(logHandler).toBeTypeOf('function'));
|
||||
await stream(5000, 'starting install');
|
||||
|
||||
await screen.findByText('5000 lines');
|
||||
expect(container.querySelector('pre').textContent.split('\n')).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a bootstrap longer than the visible window', () => {
|
||||
it('counts every line instead of freezing at the window size', async () => {
|
||||
render(<BootstrapSplash stage="installing_deps" />);
|
||||
await waitFor(() => expect(logHandler).toBeTypeOf('function'));
|
||||
await stream(640, 'starting install');
|
||||
|
||||
// Pre-fix this read "200 lines" and stayed there for the rest of the run,
|
||||
// which is the whole complaint: the heading is the only live signal that
|
||||
// a multi-minute install is still moving.
|
||||
await screen.findByText('640 lines');
|
||||
});
|
||||
|
||||
it('still writes only the window to the DOM', async () => {
|
||||
const { container } = render(<BootstrapSplash stage="installing_deps" />);
|
||||
await waitFor(() => expect(logHandler).toBeTypeOf('function'));
|
||||
await stream(640, 'starting install');
|
||||
await screen.findByText('640 lines');
|
||||
|
||||
const pre = container.querySelector('pre');
|
||||
const rendered = pre.textContent.split('\n');
|
||||
// Retention grew; the DOM budget did not.
|
||||
expect(rendered).toHaveLength(200);
|
||||
expect(rendered.at(-1)).toContain('dependency step 639');
|
||||
expect(pre.textContent).not.toContain('starting install');
|
||||
});
|
||||
|
||||
it('copies the whole run, including what scrolled out of the window', async () => {
|
||||
render(<BootstrapSplash stage="installing_deps" />);
|
||||
await waitFor(() => expect(logHandler).toBeTypeOf('function'));
|
||||
await stream(640, 'starting install');
|
||||
await screen.findByText('640 lines');
|
||||
|
||||
screen.getByRole('button', { name: /Copy/ }).click();
|
||||
await waitFor(() => expect(copied).toHaveLength(1));
|
||||
const text = copied[0];
|
||||
// The first line is the one a bug report needs and the one the cap ate.
|
||||
expect(text).toContain('starting install');
|
||||
expect(text).toContain('dependency step 639');
|
||||
expect(text.split('\n')).toHaveLength(640);
|
||||
});
|
||||
|
||||
it('keeps the actionable hint for a failure that happened early', async () => {
|
||||
// The severe case: `uv sync failed` scrolls out, `detectHints` finds
|
||||
// nothing, and the failure card offers the generic hint instead of the
|
||||
// one that names the actual problem.
|
||||
render(<BootstrapSplash stage="failed" message="Setup failed" />);
|
||||
await waitFor(() => expect(logHandler).toBeTypeOf('function'));
|
||||
await stream(640, 'uv sync failed: exit code 1');
|
||||
await screen.findByText('640 lines');
|
||||
|
||||
// en.json bootstrap.hint_uv_sync. Asserted together with the ABSENCE of
|
||||
// hint_default, because falling back to the generic advice is exactly the
|
||||
// failure mode: the card still looks helpful while saying nothing.
|
||||
await waitFor(() => {
|
||||
expect(document.body.textContent).toContain('Dependency install failed');
|
||||
});
|
||||
expect(document.body.textContent).not.toContain('Try "Retry" first');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* An unavailable engine row links to the doc that explains it (#1866).
|
||||
*
|
||||
* The row cannot explain itself. `public_backends()` replaces the computed
|
||||
* `reason` and `last_error` with two fixed strings before they reach the UI,
|
||||
* because an availability probe can carry exception text, a local path or a
|
||||
* credential. So "Engine unavailable. Check installation and configuration."
|
||||
* and "Last error: A previous engine check failed." are all the row can ever
|
||||
* say, and the second one reads like a crash when the truth is usually that an
|
||||
* optional engine was never installed.
|
||||
*
|
||||
* `docs/engines/<engine>.md` is what actually explains it — accurate, and in
|
||||
* cosyvoice's case CI-guarded against the installer registry — and nothing in
|
||||
* `frontend/src` referenced `docs/engines` at all. The backend now carries a
|
||||
* registry-authored `docs_url` that the scrub leaves alone, and the row renders
|
||||
* it with the same "Learn more →" affordance the MCP and Remote GPU panels use.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn() },
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../api/system', () => ({
|
||||
listLoadedModels: vi.fn().mockResolvedValue({ models: [], count: 0 }),
|
||||
unloadLoadedModel: vi.fn(),
|
||||
}));
|
||||
|
||||
const openExternal = vi.fn();
|
||||
vi.mock('../api/external', () => ({ openExternal: (...a) => openExternal(...a) }));
|
||||
|
||||
import EngineCompatibilityMatrix from '../components/EngineCompatibilityMatrix';
|
||||
|
||||
const DOCS = 'https://github.com/debpalash/VoiceStudio/blob/main/docs/engines/cosyvoice.md';
|
||||
|
||||
const response = () => ({
|
||||
tts: {
|
||||
active: 'omnivoice',
|
||||
backends: [
|
||||
{
|
||||
id: 'omnivoice',
|
||||
display_name: 'OmniVoice (test)',
|
||||
available: true,
|
||||
reason: null,
|
||||
docs_url: 'https://github.com/debpalash/VoiceStudio/blob/main/docs/engines/omnivoice.md',
|
||||
isolation_mode: 'in-process',
|
||||
gpu_compat: ['cpu'],
|
||||
},
|
||||
{
|
||||
id: 'cosyvoice',
|
||||
display_name: 'CosyVoice 3 (test)',
|
||||
available: false,
|
||||
// Exactly what the scrub leaves for an engine that is merely not
|
||||
// installed — no package name, no next action.
|
||||
reason: 'Engine unavailable. Check installation and configuration.',
|
||||
last_error: 'A previous engine check failed.',
|
||||
install_hint: 'git clone --recursive FunAudioLLM/CosyVoice',
|
||||
docs_url: DOCS,
|
||||
isolation_mode: 'in-process',
|
||||
gpu_compat: ['cuda', 'cpu'],
|
||||
},
|
||||
],
|
||||
},
|
||||
asr: { active: 'whisperx', backends: [] },
|
||||
llm: { active: 'off', backends: [] },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
openExternal.mockClear();
|
||||
});
|
||||
|
||||
describe('unavailable engine rows point at their doc', () => {
|
||||
const mount = () =>
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
activeId="omnivoice"
|
||||
apiListEngines={vi.fn().mockResolvedValue(response())}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
it('opens the engine doc from the why panel', async () => {
|
||||
mount();
|
||||
await waitFor(() => screen.getByText('CosyVoice 3 (test)'));
|
||||
|
||||
fireEvent.click(screen.getByTestId('why-toggle-cosyvoice'));
|
||||
const link = screen.getByTestId('engine-docs-cosyvoice');
|
||||
// Inside the why panel, next to the two placeholder strings it exists to
|
||||
// compensate for — not somewhere the user has to go looking.
|
||||
expect(screen.getByTestId('engine-detail-cosyvoice').contains(link)).toBe(true);
|
||||
|
||||
fireEvent.click(link);
|
||||
expect(openExternal).toHaveBeenCalledWith(DOCS);
|
||||
});
|
||||
|
||||
it('renders nothing when the backend sends no docs_url', async () => {
|
||||
const legacy = response();
|
||||
legacy.tts.backends[1].docs_url = null;
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
activeId="omnivoice"
|
||||
apiListEngines={vi.fn().mockResolvedValue(legacy)}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => screen.getByText('CosyVoice 3 (test)'));
|
||||
|
||||
fireEvent.click(screen.getByTestId('why-toggle-cosyvoice'));
|
||||
expect(screen.getByTestId('engine-detail-cosyvoice')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('engine-docs-cosyvoice')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import Header from '../components/Header';
|
||||
|
||||
function renderHeader(modelStatus) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
queryClient.setQueryData(['sysinfo'], {});
|
||||
queryClient.setQueryData(['engines'], {});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Header mode="audiobook" setMode={() => {}} modelStatus={modelStatus} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Header wave bars (#1911)', () => {
|
||||
it('animate for actual loading but remain still when the model is merely ready', () => {
|
||||
const ready = renderHeader('ready');
|
||||
expect(ready.getByTestId('header-wave-bars').querySelector('[class*="hqBounce"]')).toBeNull();
|
||||
ready.unmount();
|
||||
|
||||
const loading = renderHeader('loading');
|
||||
expect(
|
||||
loading.getByTestId('header-wave-bars').querySelector('[class*="hqBounce"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* The Launchpad must scroll, not compress (#1859).
|
||||
*
|
||||
* `.launchpad` is grid row 2 of `.app-container` and is itself a flex column
|
||||
* with `overflow-y: auto`. Opening the log panel grows grid row 4 (up to
|
||||
* 720px), which shrinks row 2 — the shell is built for that, and row 2 is
|
||||
* supposed to hand the shortfall to its own scrollbar.
|
||||
*
|
||||
* It did not, because a flex item shrinks before its container scrolls. The
|
||||
* three content blocks under `.launchpad` had the default `flex-shrink: 1`, so
|
||||
* they absorbed the shortfall: the hero is `overflow-hidden`, so it clipped its
|
||||
* own heading mid-line, and the deck moved up into the hero's artwork.
|
||||
*
|
||||
* jsdom does no layout, so this pins the class rule. Measured for real in
|
||||
* headless Chromium 153 at a 720px window, the same nesting the shell uses,
|
||||
* log panel at 560px:
|
||||
*
|
||||
* variant hero box / natural clipped deck top vs h1 bottom
|
||||
* footer collapsed (28px) 145 / 145 no +59
|
||||
* current 64 / 145 YES -22 ← over the h1
|
||||
* + min-height:0 on .launchpad 64 / 145 YES -22 ← no change
|
||||
* + shrink-0 on the children 145 / 145 no +59
|
||||
*
|
||||
* The third row is worth keeping: #1859 suspected the missing `min-height: 0`
|
||||
* on `.launchpad` (its two siblings `.app-container > .main-content` and
|
||||
* `.studio-panel` both set it) and flagged it as unconfirmed. It is inert here
|
||||
* — `overflow-y: auto` already zeroes a grid item's automatic minimum size, so
|
||||
* the track was shrinking correctly all along. The damage was one level in.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import i18n from '../i18n';
|
||||
import Launchpad from '../pages/Launchpad';
|
||||
|
||||
// Needs a react-query provider + live endpoints, so it is stubbed — but with a
|
||||
// marker node rather than null, so the test can still find the wrapper the page
|
||||
// puts around it.
|
||||
vi.mock('../components/ReadinessChecklist', () => ({
|
||||
default: () => <span data-readiness="stub" />,
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
profiles: [],
|
||||
studioProjects: [],
|
||||
dubHistory: [],
|
||||
exportHistory: [],
|
||||
setMode: vi.fn(),
|
||||
setIsCompareModalOpen: vi.fn(),
|
||||
handleSelectProfile: vi.fn(),
|
||||
loadProject: vi.fn(),
|
||||
};
|
||||
|
||||
// Both branches of the page, because they render DIFFERENT direct children:
|
||||
// empty gives the `flex-1` empty state, populated gives the readiness
|
||||
// checklist. Checking only one leaves the other's child unconstrained — which
|
||||
// is how the checklist was missed on the first pass, and it is the branch the
|
||||
// #1859 reporter was actually in.
|
||||
const STATES = {
|
||||
empty: baseProps,
|
||||
populated: {
|
||||
...baseProps,
|
||||
profiles: [{ id: 'p1', name: 'Test voice' }],
|
||||
studioProjects: [{ id: 's1', name: 'Test project', updated_at: Date.now() }],
|
||||
},
|
||||
};
|
||||
|
||||
const renderShell = (props) =>
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<div className="app-container">
|
||||
<Launchpad {...props} />
|
||||
</div>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
describe('Launchpad content blocks defer the shortfall to the scroll container', () => {
|
||||
for (const [state, props] of Object.entries(STATES)) {
|
||||
it(`labels every direct child as content or filler (${state})`, () => {
|
||||
// The invariant, stated so a new block cannot be added unlabelled: a
|
||||
// child either protects a height (`shrink-0`) or is deliberately elastic
|
||||
// (`flex-1`). The empty state is the elastic one — it centres a small
|
||||
// waveform in whatever space is left and has no height to lose. Anything
|
||||
// that is neither is the #1859 shape.
|
||||
const { container } = renderShell(props);
|
||||
const root = container.querySelector('.launchpad');
|
||||
expect(root).toBeTruthy();
|
||||
|
||||
let content = 0;
|
||||
for (const el of root.children) {
|
||||
const cls = el.className || '';
|
||||
if (/\bflex-1\b/.test(cls)) continue;
|
||||
expect(
|
||||
cls,
|
||||
`a .launchpad child that is neither shrink-0 nor flex-1 absorbs the log ` +
|
||||
`panel's shortfall instead of letting the page scroll (#1859): ` +
|
||||
`<${el.tagName.toLowerCase()} class="${cls}">`,
|
||||
).toMatch(/\bshrink-0\b/);
|
||||
content += 1;
|
||||
}
|
||||
// A refactor that reparents the blocks would otherwise turn this into a
|
||||
// no-op that still passes.
|
||||
expect(content).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
}
|
||||
|
||||
it('constrains the readiness checklist, which only the populated page mounts', () => {
|
||||
// ReadinessChecklist is mocked out here (it needs react-query and live
|
||||
// endpoints), so this asserts the WRAPPER the page owns rather than the
|
||||
// component's own markup — which is the reason the fix wraps it instead of
|
||||
// reaching into a shared component for a parent-specific layout fact.
|
||||
const { container } = renderShell(STATES.populated);
|
||||
const root = container.querySelector('.launchpad');
|
||||
const wrapper = [...root.children].find((el) => el.querySelector('[data-readiness]'));
|
||||
expect(
|
||||
wrapper,
|
||||
'the checklist branch did not render — fixture no longer populated',
|
||||
).toBeTruthy();
|
||||
expect(wrapper.className).toMatch(/\bshrink-0\b/);
|
||||
});
|
||||
|
||||
it('keeps the scroll container the shrink-0 defers to', () => {
|
||||
// shrink-0 without a scrolling ancestor would just move the clipping up to
|
||||
// .app-container's own `overflow: hidden`.
|
||||
const css = fs.readFileSync(path.resolve(__dirname, '..', 'index.css'), 'utf8');
|
||||
const rule = css.slice(css.indexOf('.launchpad {'));
|
||||
const body = rule.slice(0, rule.indexOf('}'));
|
||||
expect(body).toMatch(/overflow-y:\s*auto/);
|
||||
expect(body).toMatch(/flex-direction:\s*column/);
|
||||
});
|
||||
});
|
||||
@@ -75,8 +75,9 @@ describe('SetupWizard analytics consent step', () => {
|
||||
expect(await screen.findByText(/Improve VoiceStudio/i)).toBeInTheDocument();
|
||||
|
||||
await advancePastModels();
|
||||
// Headline appears (masthead subtitle + section head + card title).
|
||||
expect((await screen.findAllByText(/Help improve VoiceStudio\?/i)).length).toBeGreaterThan(0);
|
||||
// The title renders exactly once (the card's own heading) — the masthead
|
||||
// subtitle and the section head no longer repeat it (#1855).
|
||||
expect(await screen.findAllByText(/Help improve VoiceStudio\?/i)).toHaveLength(1);
|
||||
// Two equal-weight choices, no preselected default.
|
||||
expect(screen.getByTestId('analytics-consent-yes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('analytics-consent-no')).toBeInTheDocument();
|
||||
@@ -144,6 +145,14 @@ describe('SetupWizard analytics consent step', () => {
|
||||
expect(await screen.findByText(/Enter studio/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Help improve VoiceStudio\?/i)).not.toBeInTheDocument();
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
// The dictation step's rail label renders exactly once, and it's a
|
||||
// distinct string from the demo card's own title — the masthead
|
||||
// subtitle no longer repeats it, the redundant section head above the
|
||||
// demo was removed (#1855), and the rail label itself was made distinct
|
||||
// from the demo's title (#1930). DictationDemo is mocked to null here,
|
||||
// so the rail/card-title distinctness itself is covered by the real
|
||||
// (unmocked) render in SetupWizardDictation.test.jsx.
|
||||
expect(screen.getAllByText('Dictation')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows NO consent step when the user was already asked', async () => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
|
||||
// Only the wizard's non-dictation heavy children are mocked away —
|
||||
// DictationDemo is left REAL so this test exercises the actual rendered
|
||||
// title text, unlike SetupWizardConsent.test.jsx (which mocks it to null
|
||||
// and therefore cannot catch a rail-label/card-title collision, #1930).
|
||||
vi.mock('../components/WizardLibrary', () => ({ default: () => null }));
|
||||
vi.mock('../components/MediaEngineCard', () => ({ default: () => null }));
|
||||
vi.mock('../components/MirrorRescue', () => ({ default: () => null }));
|
||||
vi.mock('../components/HfTokenCard', () => ({ default: () => null }));
|
||||
vi.mock('../api/external', () => ({
|
||||
openExternal: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
// Preflight passes and models are ready, so the wizard is freely navigable.
|
||||
vi.mock('../api/hooks', () => ({
|
||||
useSetupStatus: () => ({
|
||||
data: { models_ready: true, missing: [], hf_cache_dir: '/tmp/hf' },
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
usePreflight: () => ({
|
||||
data: { ok: true, has_warnings: false, checks: [] },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// No analytics destination in this build, so the wizard skips straight from
|
||||
// models to dictation — matches SetupWizardConsent.test.jsx's equivalent case.
|
||||
const apiJson = vi.fn(() =>
|
||||
Promise.resolve({ available: false, prompted: false, opted_in: false }),
|
||||
);
|
||||
const apiFetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }));
|
||||
vi.mock('../api/client', () => ({
|
||||
apiJson: (...a) => apiJson(...a),
|
||||
apiFetch: (...a) => apiFetch(...a),
|
||||
API: '',
|
||||
}));
|
||||
|
||||
import SetupWizard from '../pages/SetupWizard';
|
||||
|
||||
function withI18n(node) {
|
||||
return <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiJson.mockClear();
|
||||
apiFetch.mockClear();
|
||||
});
|
||||
|
||||
describe('SetupWizard dictation step (real DictationDemo)', () => {
|
||||
it('shows the rail label and the demo title as two distinct strings, each once (#1930)', async () => {
|
||||
render(withI18n(<SetupWizard onReady={() => {}} />));
|
||||
|
||||
fireEvent.click(await screen.findByText(/All good — continue/i));
|
||||
fireEvent.click(await screen.findByText(/Required models ready/i));
|
||||
|
||||
// Straight to dictation — no consent step in this build.
|
||||
expect(await screen.findByText(/Enter studio/i)).toBeInTheDocument();
|
||||
|
||||
// The step rail's short label and the DictationDemo card's own title are
|
||||
// two DIFFERENT strings — no more reusing "Try dictation" for both.
|
||||
expect(screen.getAllByText('Dictation')).toHaveLength(1);
|
||||
expect(screen.getAllByText(/Try Dictation/i)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Start-truncated paths must not be reordered by the bidi algorithm.
|
||||
*
|
||||
* The bug (#1848): the preflight detail line and the storage-path row both use
|
||||
* `dir="rtl"` purely to move `text-overflow: ellipsis` to the START of the
|
||||
* line, so a long path keeps its tail on screen. But `dir="rtl"` makes the
|
||||
* whole line an RTL paragraph, and the Unicode bidi algorithm puts a LEADING
|
||||
* run of European numbers or neutrals at the visual RIGHT edge of an RTL
|
||||
* paragraph. Every detail beginning with a digit or a "/" therefore rendered
|
||||
* with its head moved to the end:
|
||||
*
|
||||
* `48.0 GB total` → `GB total 48.0`
|
||||
* `/home/me/.cache/huggingface` → `home/me/.cache/huggingface/`
|
||||
*
|
||||
* Letter-first details ("Python 3.11.15") were unaffected, which is the
|
||||
* signature of bidi reordering rather than a data bug.
|
||||
*
|
||||
* The fix is to isolate the text in a `<bdi>` (implicitly `dir="auto"`), so the
|
||||
* content is ordered by its own direction while the box keeps `direction: rtl`
|
||||
* for the ellipsis side.
|
||||
*
|
||||
* jsdom does no bidi resolution, so this pins the DOM SHAPE the fix depends on.
|
||||
* Measured for real in headless Chromium 153, same nesting as the preflight
|
||||
* row, x offsets of the first and last word relative to the line box:
|
||||
*
|
||||
* text variant head tail
|
||||
* `48.0 GB total` bare x=49 x=20 ← reversed
|
||||
* `48.0 GB total` <bdi> x=0 x=46
|
||||
* `/home/user/.cache/huggingface` bare x=175 x=107 ← reversed
|
||||
* `/home/user/.cache/huggingface` <bdi> x=0 x=112
|
||||
* `Python 3.11.15` bare x=0 x=44 ← letter-first,
|
||||
* never broken
|
||||
* an overflowing path <bdi> x=-142 x=288 ← head still
|
||||
* clipped off
|
||||
* the start
|
||||
*
|
||||
* The last row is the one that keeps the fix honest: `<bdi>` restores the
|
||||
* order without giving up the start-side ellipsis the `dir="rtl"` is there for.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import i18n from '../i18n';
|
||||
|
||||
vi.mock('../components/WizardLibrary', () => ({ default: () => null }));
|
||||
vi.mock('../components/MediaEngineCard', () => ({ default: () => null }));
|
||||
vi.mock('../components/MirrorRescue', () => ({ default: () => null }));
|
||||
vi.mock('../components/DictationDemo', () => ({ default: () => null }));
|
||||
vi.mock('../components/HfTokenCard', () => ({ default: () => null }));
|
||||
vi.mock('../api/external', () => ({ openExternal: vi.fn(() => Promise.resolve()) }));
|
||||
|
||||
const DETAIL = '48.0 GB total';
|
||||
const CACHE = '/home/user/.cache/huggingface';
|
||||
|
||||
vi.mock('../api/hooks', () => ({
|
||||
useSetupStatus: () => ({
|
||||
data: { models_ready: true, missing: [], hf_cache_dir: '/tmp/hf' },
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
usePreflight: () => ({
|
||||
data: {
|
||||
ok: true,
|
||||
has_warnings: false,
|
||||
checks: [
|
||||
{ id: 'ram', label: 'RAM', status: 'pass', detail: '48.0 GB total' },
|
||||
{ id: 'hf_cache', label: 'Model cache', status: 'pass', detail: CACHE },
|
||||
{ id: 'python', label: 'Python', status: 'pass', detail: 'Python 3.11.15' },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
apiJson: vi.fn(() => Promise.resolve({ available: false, prompted: true })),
|
||||
apiFetch: vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
|
||||
API: '',
|
||||
}));
|
||||
|
||||
import SetupWizard from '../pages/SetupWizard';
|
||||
|
||||
const withI18n = (node) => <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('preflight details survive the start-truncation hack', () => {
|
||||
it('wraps every rtl-truncated detail in a bidi isolate', () => {
|
||||
const { container } = render(withI18n(<SetupWizard onReady={() => {}} />));
|
||||
const boxes = [...container.querySelectorAll('[dir="rtl"]')];
|
||||
expect(boxes.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
for (const box of boxes) {
|
||||
// A bare text child is the bug: the reordering happens to text that the
|
||||
// rtl paragraph owns directly.
|
||||
expect(
|
||||
box.querySelector('bdi'),
|
||||
`rtl-truncated element renders its text unisolated: ${box.outerHTML}`,
|
||||
).toBeTruthy();
|
||||
expect([...box.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && n.data.trim())).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const detail = boxes.find((b) => b.textContent === DETAIL);
|
||||
expect(detail).toBeTruthy();
|
||||
// The isolate carries the whole string — splitting it would reintroduce
|
||||
// the reordering between the parts.
|
||||
expect(detail.querySelector('bdi').textContent).toBe(DETAIL);
|
||||
// The tooltip stays on the box, so hover still shows the untruncated value.
|
||||
expect(detail.getAttribute('title')).toBe(DETAIL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the rule holds for every start-truncated element in the tree', () => {
|
||||
// Recurrence guard: #1848 shipped at TWO call sites written months apart,
|
||||
// so a third one is the expected failure mode. Mechanical rule, mechanical
|
||||
// test — same reasoning as tests/test_no_hardcoded_cjk.py.
|
||||
const SRC = path.resolve(__dirname, '..');
|
||||
const walk = (dir) =>
|
||||
fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) return e.name === 'test' ? [] : walk(full);
|
||||
return /\.(jsx?|tsx?)$/.test(e.name) ? [full] : [];
|
||||
});
|
||||
|
||||
it('never lets an rtl element own its text directly', () => {
|
||||
const offenders = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
const open = /<([a-zA-Z][\w.]*)\b[^>]*?\bdir="rtl"[^>]*?>/g;
|
||||
let m;
|
||||
while ((m = open.exec(src)) !== null) {
|
||||
if (m[0].endsWith('/>')) continue; // void/self-closing: no children to isolate
|
||||
const close = src.indexOf(`</${m[1]}>`, open.lastIndex);
|
||||
const body = close === -1 ? src.slice(open.lastIndex) : src.slice(open.lastIndex, close);
|
||||
if (!body.includes('<bdi')) {
|
||||
const line = src.slice(0, m.index).split('\n').length;
|
||||
offenders.push(`${path.relative(SRC, file)}:${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
offenders,
|
||||
`dir="rtl" without a <bdi> isolate reorders digit- and slash-leading text (#1848): ${offenders.join(', ')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
+55
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, readlinkSync } from "node:fs";
|
||||
import { dirname, resolve, sep } from "node:path";
|
||||
import path, { dirname, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const DEFAULT_PORTS = [3900, 3901];
|
||||
@@ -69,7 +69,11 @@ function normalized(value, windows = process.platform === "win32") {
|
||||
return String(value || "")
|
||||
.replaceAll("/", "\\")
|
||||
.toLowerCase();
|
||||
return resolve(String(value || ""));
|
||||
// path.posix, not the host resolver: `windows` is an explicit parameter, so
|
||||
// POSIX normalisation must stay POSIX even when this runs on Windows. The
|
||||
// host resolver turned "/work/VoiceStudio" into "C:\work\VoiceStudio",
|
||||
// which then matched nothing in a POSIX command line.
|
||||
return path.posix.resolve(String(value || ""));
|
||||
}
|
||||
|
||||
export function belongsToCheckout(
|
||||
@@ -80,7 +84,7 @@ export function belongsToCheckout(
|
||||
checkoutRoot = CHECKOUT_ROOT,
|
||||
) {
|
||||
const root = normalized(checkoutRoot, windows);
|
||||
const prefix = `${root}${windows ? "\\" : sep}`;
|
||||
const prefix = `${root}${windows ? "\\" : path.posix.sep}`;
|
||||
const ownedPath = (value) => {
|
||||
if (!value) return false;
|
||||
const path = normalized(value, windows);
|
||||
@@ -160,7 +164,9 @@ function inspectWindows(pid) {
|
||||
const script = [
|
||||
`$p = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'`,
|
||||
"if ($null -ne $p) {",
|
||||
" $p | Select-Object ProcessId,ExecutablePath,CommandLine,CreationDate | ConvertTo-Json -Compress",
|
||||
// Started is formatted explicitly (round-trip 'o') so the identity string
|
||||
// is byte-stable and can be re-compared inside the stop script below.
|
||||
" $p | Select-Object ProcessId,ExecutablePath,CommandLine,@{n='Started';e={$_.CreationDate.ToUniversalTime().ToString('o')}} | ConvertTo-Json -Compress",
|
||||
"}",
|
||||
].join("; ");
|
||||
const result = spawnSync(
|
||||
@@ -175,24 +181,63 @@ function inspectWindows(pid) {
|
||||
if (!result.stdout.trim()) return null;
|
||||
const info = JSON.parse(result.stdout);
|
||||
return {
|
||||
identity: `windows:${info.CreationDate}`,
|
||||
identity: `windows:${info.Started}`,
|
||||
owned: belongsToCheckout("", info.CommandLine, info.ExecutablePath, true),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a Windows listener, bound to the process INSTANCE.
|
||||
*
|
||||
* `taskkill /pid` targets a reusable PID, so a pid recycled between inspect and
|
||||
* kill would take an unrelated process down — which is why auto-stop used to be
|
||||
* refused outright on Windows, leaving `bun run dev` permanently stuck behind
|
||||
* "stop it in Task Manager and retry" whenever a backend was orphaned. Fetching
|
||||
* the CIM instance, re-checking its creation timestamp, and terminating THAT
|
||||
* instance in one PowerShell pass closes the race: the terminate acts on the
|
||||
* object the check validated, not on a pid looked up again afterwards.
|
||||
*/
|
||||
export function stopWindowsProcess(pid, _force, identity, run = spawnSync) {
|
||||
const expected = String(identity || "").replace(/^windows:/, "");
|
||||
const script = [
|
||||
`$p = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'`,
|
||||
"if ($null -eq $p) { exit 0 }",
|
||||
`if ($p.CreationDate.ToUniversalTime().ToString('o') -ne '${expected}') { exit 3 }`,
|
||||
// Terminate reports failure through ReturnValue, not through a thrown
|
||||
// error: discarding it would report success on an access-denied kill.
|
||||
"$r = Invoke-CimMethod -InputObject $p -MethodName Terminate",
|
||||
"if ($r.ReturnValue -ne 0) { Write-Output $r.ReturnValue; exit 4 }",
|
||||
].join("; ");
|
||||
const result = run(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-Command", script],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
// exit 3 == the pid now belongs to a different process; leave it alone.
|
||||
if (result.status === 3) return;
|
||||
if (result.status === 4) {
|
||||
throw new Error(
|
||||
`Could not stop process ${pid}: Terminate returned ${String(result.stdout || "").trim()}`,
|
||||
);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Could not stop process ${pid}`);
|
||||
}
|
||||
}
|
||||
|
||||
function systemOperations() {
|
||||
const windows = process.platform === "win32";
|
||||
return {
|
||||
// Windows taskkill targets a reusable PID, not the inspected process
|
||||
// instance. Refuse automatic termination until it can be handle-bound.
|
||||
canStop: !windows,
|
||||
canStop: true,
|
||||
// macOS exposes process start time to ps at one-second resolution. That is
|
||||
// sufficient for a graceful stop, but not safe proof for SIGKILL escalation.
|
||||
canForce: process.platform !== "darwin",
|
||||
listeners: windows ? windowsListeners : unixListeners,
|
||||
inspect: windows ? inspectWindows : process.platform === "darwin" ? inspectMac : inspectLinux,
|
||||
stop(pid, force) {
|
||||
stopUnixProcess(pid, force);
|
||||
stop(pid, force, identity) {
|
||||
if (windows) stopWindowsProcess(pid, force, identity);
|
||||
else stopUnixProcess(pid, force);
|
||||
},
|
||||
sleep(ms) {
|
||||
return new Promise((done) => setTimeout(done, ms));
|
||||
|
||||
+34
-4
@@ -21,7 +21,7 @@
|
||||
// the Windows CreateProcess PATH search — no shell needed).
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, watch } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -47,6 +47,29 @@ export const CRASH_RESTART_LIMIT = 3;
|
||||
export const CRASH_RESTART_WINDOW_MS = 60_000;
|
||||
export const SOURCE_RELOAD_DEBOUNCE_MS = 250;
|
||||
|
||||
/**
|
||||
* Stop a spawned backend and everything it started.
|
||||
*
|
||||
* We spawn `uv`, which spawns uvicorn as its own child. Windows has no signals:
|
||||
* `child.kill()` maps to TerminateProcess on the DIRECT child only, so killing
|
||||
* `uv` orphaned the uvicorn grandchild — which kept holding port 3900. The next
|
||||
* spawn then failed to bind ([Errno 10048]), which the supervisor counted as a
|
||||
* crash, and three of those tore the whole dev stack down. `taskkill /T` walks
|
||||
* the tree; POSIX keeps plain signal delivery.
|
||||
*/
|
||||
export function killProcessTree(child, sig, { platform = process.platform, run = spawnSync } = {}) {
|
||||
if (platform !== "win32") {
|
||||
child.kill(sig);
|
||||
return "signal";
|
||||
}
|
||||
const result = run("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
||||
if (!result || result.error || result.status !== 0) {
|
||||
child.kill(sig); // taskkill unavailable or the process already exited
|
||||
return "fallback";
|
||||
}
|
||||
return "taskkill";
|
||||
}
|
||||
|
||||
export function isBackendSourceChange(filename) {
|
||||
return typeof filename === "string" && filename.toLowerCase().endsWith(".py");
|
||||
}
|
||||
@@ -123,6 +146,7 @@ export function createBackendSupervisor({
|
||||
spawnBackend = () => spawn("uv", uvRunArgs(), { stdio: "inherit" }),
|
||||
watchBackend = (onChange) =>
|
||||
watch("backend", { recursive: true }, (_event, filename) => onChange(filename?.toString())),
|
||||
killBackend = (proc, sig) => killProcessTree(proc, sig),
|
||||
schedule = setTimeout,
|
||||
cancelSchedule = clearTimeout,
|
||||
now = Date.now,
|
||||
@@ -135,6 +159,10 @@ export function createBackendSupervisor({
|
||||
let restartTimer = null;
|
||||
let reloadTimer = null;
|
||||
let reloadRequested = false;
|
||||
// True when the reload we asked for was served by a forced tree-kill, which
|
||||
// reports a non-zero exit and no signal. Only then may such an exit be read
|
||||
// as "the reload we asked for" instead of "it crashed mid-reload".
|
||||
let reloadKillForced = false;
|
||||
let watcher = null;
|
||||
let crashTimes = [];
|
||||
let requestedExitCode = null;
|
||||
@@ -165,7 +193,7 @@ export function createBackendSupervisor({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.kill(sig);
|
||||
killBackend(child, sig);
|
||||
} catch {
|
||||
exit(requestedExitCode ?? 0);
|
||||
}
|
||||
@@ -180,7 +208,7 @@ export function createBackendSupervisor({
|
||||
reloadRequested = true;
|
||||
report(`[dev-backend] ${filename} changed; reloading backend…`);
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
reloadKillForced = killBackend(child, "SIGTERM") === "taskkill";
|
||||
} catch {
|
||||
reloadRequested = false;
|
||||
}
|
||||
@@ -236,7 +264,9 @@ export function createBackendSupervisor({
|
||||
|
||||
if (reloadRequested) {
|
||||
reloadRequested = false;
|
||||
const expectedReloadExit = code === 0 || signal === "SIGTERM";
|
||||
const expectedReloadExit =
|
||||
code === 0 || signal === "SIGTERM" || reloadKillForced;
|
||||
reloadKillForced = false;
|
||||
if (expectedReloadExit) {
|
||||
start();
|
||||
return;
|
||||
|
||||
@@ -228,7 +228,8 @@ def test_indextts2_entry_has_subprocess_isolation_mode(fresh_app):
|
||||
assert by_id["indextts2"]["isolation_mode"] == "subprocess"
|
||||
|
||||
|
||||
def test_omnivoice_entry_has_in_process_isolation_mode(fresh_app):
|
||||
def test_omnivoice_entry_has_in_process_isolation_mode(fresh_app, monkeypatch):
|
||||
_force_cpu_host(monkeypatch)
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines")
|
||||
assert r.status_code == 200
|
||||
@@ -237,8 +238,49 @@ def test_omnivoice_entry_has_in_process_isolation_mode(fresh_app):
|
||||
assert by_id["omnivoice"]["isolation_mode"] == "in-process"
|
||||
|
||||
|
||||
def test_gpu_compat_omnivoice_variants_include_rocm(fresh_app):
|
||||
def test_mps_picker_hides_redundant_omnivoice_sidecar_but_api_accepts_it(
|
||||
fresh_app, monkeypatch
|
||||
):
|
||||
"""Keep stored/direct compatibility ids valid without advertising a duplicate."""
|
||||
from engines.omnivoice_subprocess import (
|
||||
OmniVoiceMPSSubprocessBackend,
|
||||
OmniVoiceSubprocessBackend,
|
||||
)
|
||||
from services import tts_backend
|
||||
|
||||
_force_host(monkeypatch, "mps")
|
||||
monkeypatch.delenv("OMNIVOICE_TTS_BACKEND", raising=False)
|
||||
monkeypatch.setattr(
|
||||
OmniVoiceSubprocessBackend,
|
||||
"is_available",
|
||||
classmethod(lambda cls: (True, "ready")),
|
||||
)
|
||||
|
||||
client = _client(fresh_app)
|
||||
payload = client.get("/engines/tts").json()
|
||||
rows = {row["id"] for row in payload["backends"]}
|
||||
assert "omnivoice" in rows
|
||||
assert "omnivoice-subprocess" not in rows
|
||||
assert payload["active"] == "omnivoice"
|
||||
|
||||
canonical = next(
|
||||
row for row in payload["backends"] if row["id"] == "omnivoice"
|
||||
)
|
||||
assert canonical["isolation_mode"] == "subprocess"
|
||||
assert tts_backend.get_backend_class("omnivoice") is OmniVoiceMPSSubprocessBackend
|
||||
|
||||
response = client.post(
|
||||
"/engines/select",
|
||||
json={"family": "tts", "backend_id": "omnivoice-subprocess"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["active"] == "omnivoice-subprocess"
|
||||
assert tts_backend.get_backend_class("omnivoice-subprocess") is OmniVoiceSubprocessBackend
|
||||
|
||||
|
||||
def test_gpu_compat_omnivoice_variants_include_rocm(fresh_app, monkeypatch):
|
||||
"""Both OmniVoice paths use torch's HIP-backed CUDA device on ROCm."""
|
||||
_force_host(monkeypatch, "rocm")
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines")
|
||||
by_id = {b["id"]: b for b in r.json()["tts"]["backends"]}
|
||||
|
||||
@@ -212,6 +212,11 @@ def test_list_backends_shape(registry_sandbox):
|
||||
"disk_usage",
|
||||
# Sanitized actual-vs-declared provider/device evidence (#1717).
|
||||
"execution_evidence",
|
||||
# Public URL of the engine's docs page, or None when it has none
|
||||
# (#1866). The unavailable-engine row uses it for its Learn more link,
|
||||
# so an engine that gains a registry entry without a doc silently
|
||||
# loses that link — tests/test_engine_docs.py guards the mapping.
|
||||
"docs_url",
|
||||
}
|
||||
mlx_audio_extra = {"curated_models", "active_model_id"}
|
||||
for entry in out:
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseSsListeners,
|
||||
parseWindowsListeners,
|
||||
stopUnixProcess,
|
||||
stopWindowsProcess,
|
||||
} from "../../scripts/clear-dev-ports.mjs";
|
||||
|
||||
test("parses only requested Windows TCP listeners", () => {
|
||||
@@ -154,3 +155,47 @@ test("waits for the listener to disappear after force stop", async () => {
|
||||
]);
|
||||
assert.equal(sleeps, 12);
|
||||
});
|
||||
|
||||
// Windows auto-stop is allowed again, but only bound to the process INSTANCE:
|
||||
// a pid recycled between inspect and kill must never be terminated.
|
||||
test("windows stop re-checks the process identity before terminating", () => {
|
||||
const calls = [];
|
||||
const run = (_exe, args) => {
|
||||
calls.push(args.at(-1));
|
||||
return { status: 0 };
|
||||
};
|
||||
stopWindowsProcess(4242, false, "windows:2026-09-08T21:25:58.5000000Z", run);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0], /ProcessId = 4242/);
|
||||
assert.match(calls[0], /2026-09-08T21:25:58\.5000000Z/);
|
||||
assert.match(calls[0], /Invoke-CimMethod -InputObject \$p -MethodName Terminate/);
|
||||
// The identity must be compared before the terminate, never after.
|
||||
assert.ok(calls[0].indexOf("-ne '2026") < calls[0].indexOf("Invoke-CimMethod"));
|
||||
});
|
||||
|
||||
test("windows stop leaves a recycled pid alone instead of failing the run", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
stopWindowsProcess(4242, false, "windows:whatever", () => ({ status: 3 })),
|
||||
);
|
||||
});
|
||||
|
||||
test("windows stop surfaces a real termination failure", () => {
|
||||
assert.throws(
|
||||
() => stopWindowsProcess(4242, false, "windows:whatever", () => ({ status: 1 })),
|
||||
/Could not stop process 4242/,
|
||||
);
|
||||
});
|
||||
|
||||
// Win32_Process.Terminate reports failure through ReturnValue, not by throwing:
|
||||
// discarding it would report success on an access-denied kill, and the port
|
||||
// would still be held.
|
||||
test("windows stop fails when Terminate reports a non-zero ReturnValue", () => {
|
||||
const run = (_exe, args) => {
|
||||
assert.match(args.at(-1), /ReturnValue -ne 0/);
|
||||
return { status: 4, stdout: "2" };
|
||||
};
|
||||
assert.throws(
|
||||
() => stopWindowsProcess(4242, false, "windows:whatever", run),
|
||||
/Could not stop process 4242: Terminate returned 2/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
buildExitBanner,
|
||||
createBackendSupervisor,
|
||||
isBackendSourceChange,
|
||||
killProcessTree,
|
||||
resolveDataDir,
|
||||
tailFile,
|
||||
} from '../../scripts/dev-backend.mjs';
|
||||
|
||||
function supervisorHarness() {
|
||||
function supervisorHarness(overrides = {}) {
|
||||
const children = [];
|
||||
const timers = [];
|
||||
const exits = [];
|
||||
@@ -64,6 +65,7 @@ function supervisorHarness() {
|
||||
exit: (code) => exits.push(code),
|
||||
report: (message) => reports.push(message),
|
||||
dataDir: () => '/missing-test-data',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -282,3 +284,45 @@ test('failure to spawn uv exits immediately instead of looping', () => {
|
||||
assert.deepEqual(exits, [1]);
|
||||
assert.match(reports[0], /could not start uv: uv is missing/);
|
||||
});
|
||||
|
||||
// Windows has no signals: killing the spawned `uv` left the uvicorn grandchild
|
||||
// alive on port 3900, so every restart failed to bind and the supervisor tore
|
||||
// the stack down after three "crashes".
|
||||
test('a backend is stopped by process tree on windows and by signal elsewhere', () => {
|
||||
const child = { pid: 4242, killedWith: null, kill(sig) { this.killedWith = sig; } };
|
||||
const calls = [];
|
||||
const run = (exe, args) => {
|
||||
calls.push([exe, ...args].join(' '));
|
||||
return { status: 0 };
|
||||
};
|
||||
|
||||
assert.equal(killProcessTree(child, 'SIGTERM', { platform: 'win32', run }), 'taskkill');
|
||||
assert.deepEqual(calls, ['taskkill /pid 4242 /T /F']);
|
||||
assert.equal(child.killedWith, null, 'must not also signal the direct child');
|
||||
|
||||
assert.equal(killProcessTree(child, 'SIGTERM', { platform: 'linux', run }), 'signal');
|
||||
assert.equal(calls.length, 1, 'POSIX must not shell out');
|
||||
assert.equal(child.killedWith, 'SIGTERM');
|
||||
});
|
||||
|
||||
test('a failed taskkill falls back to signalling the direct child', () => {
|
||||
const child = { pid: 4242, killedWith: null, kill(sig) { this.killedWith = sig; } };
|
||||
const run = () => ({ status: 128 });
|
||||
assert.equal(killProcessTree(child, 'SIGTERM', { platform: 'win32', run }), 'fallback');
|
||||
assert.equal(child.killedWith, 'SIGTERM');
|
||||
});
|
||||
|
||||
test('a forced tree-kill reload restarts instead of reporting a crash', () => {
|
||||
const harness = supervisorHarness({ killBackend: () => 'taskkill' });
|
||||
harness.supervisor.start();
|
||||
harness.watchers[0].onChange('main.py');
|
||||
harness.runNextTimer();
|
||||
|
||||
// taskkill /F reports a non-zero exit and no signal — that is the reload we
|
||||
// asked for, not a crash.
|
||||
harness.children[0].emit('exit', 1, null);
|
||||
|
||||
assert.doesNotMatch(harness.reports.join('\n'), /BACKEND DIED/);
|
||||
assert.equal(harness.children.length, 2);
|
||||
assert.deepEqual(harness.exits, []);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
|
||||
def test_remote_chapter_does_not_prepare_local_model(tmp_path, monkeypatch):
|
||||
@@ -20,6 +19,7 @@ def test_remote_chapter_does_not_prepare_local_model(tmp_path, monkeypatch):
|
||||
async def fake_run(op, *, local, remote, decision, job):
|
||||
assert op == remote.operation == "audiobook"
|
||||
assert local.prepare is not None
|
||||
assert remote.params["text"] == "hello"
|
||||
out = tmp_path / "remote.wav"
|
||||
out.write_bytes(b"wav")
|
||||
return str(out), 1.0, False, None
|
||||
@@ -34,6 +34,55 @@ def test_remote_chapter_does_not_prepare_local_model(tmp_path, monkeypatch):
|
||||
assert result[0].endswith("remote.wav")
|
||||
|
||||
|
||||
def test_local_chapter_uses_canonical_text_scaled_timeout(tmp_path, monkeypatch):
|
||||
from api.routers import audiobook
|
||||
from services import gpu_gateway, model_manager
|
||||
from services.audiobook import Chapter, ExpressiveOptions, Span
|
||||
from worker.routing import Decision
|
||||
|
||||
class Backend:
|
||||
gpu_compat = ("mps", "cpu")
|
||||
|
||||
chapter = Chapter("One", [Span(None, "a" * 900), Span(None, "b" * 901)])
|
||||
expected_text = f"{'a' * 900}\n{'b' * 901}"
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(audiobook, "_resolve_voice", lambda _id: {
|
||||
"ref_audio": None, "ref_text": None, "instruct": None, "seed": None,
|
||||
})
|
||||
monkeypatch.setattr(audiobook, "_voice_profile_exists", lambda _id: False)
|
||||
monkeypatch.setattr("services.tts_backend.active_backend_id", lambda: "test")
|
||||
monkeypatch.setattr("services.tts_backend.get_backend_class", lambda _id: Backend)
|
||||
|
||||
async def fake_prepare(*_args, **_kwargs):
|
||||
return lambda *_args, **_kwargs: None, 24_000, lambda _id: {}, "test"
|
||||
|
||||
def fake_timeout(text, *, engine=None, **_kwargs):
|
||||
calls.append((text, engine))
|
||||
return 315.05
|
||||
|
||||
async def fake_run(op, *, local, remote, decision, job):
|
||||
prepared = await local.prepare()
|
||||
assert op == "audiobook"
|
||||
assert prepared.timeout == 315.05
|
||||
assert remote.params["text"] == expected_text
|
||||
return "local.wav", 1.0, False, None
|
||||
|
||||
monkeypatch.setattr(audiobook, "_prepare_synth", fake_prepare)
|
||||
monkeypatch.setattr(model_manager, "generate_timeout_s", fake_timeout)
|
||||
monkeypatch.setattr(gpu_gateway, "run", fake_run)
|
||||
|
||||
result = asyncio.run(audiobook._run_chapter(
|
||||
chapter,
|
||||
decision=Decision(False, "local"), job=gpu_gateway.JobRun("audiobook"),
|
||||
default_voice=None, language=None, opts=ExpressiveOptions(), voice_map=None,
|
||||
lexicon=None, cache_dir=str(tmp_path),
|
||||
))
|
||||
|
||||
assert result[0] == "local.wav"
|
||||
assert calls == [(expected_text, Backend)]
|
||||
|
||||
|
||||
def test_audiobook_worker_marks_and_encodes_chapter(monkeypatch):
|
||||
import numpy as np
|
||||
from worker.executor import TaskExecutor
|
||||
|
||||
@@ -89,7 +89,9 @@ def test_asr_evidence_failure_does_not_drop_tts_evidence(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
tts_backend,
|
||||
"list_backends",
|
||||
lambda: [{"id": "tts-ok", "available": True, "execution_evidence": evidence}],
|
||||
lambda **_kwargs: [
|
||||
{"id": "tts-ok", "available": True, "execution_evidence": evidence}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(asr_backend, "active_backend_id", lambda: "asr-broken")
|
||||
def fail_asr_registry():
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Every TTS engine points at a doc page, and that page exists (#1866).
|
||||
|
||||
The engine rows in Model Catalogue → Engines cannot explain an unavailable
|
||||
engine themselves: `api.public_engine_metadata.public_backends` replaces the
|
||||
computed `reason` and `last_error` with fixed strings before they reach the UI,
|
||||
because an availability probe can carry exception text or a local path. The doc
|
||||
pages under `docs/engines/` are what does explain it, and nothing in the app
|
||||
linked to them, so the point of failure was a dead end.
|
||||
|
||||
`_ENGINE_DOCS` closes that, and this file keeps it honest. A registry entry
|
||||
whose file was renamed, or a new engine added without one, would otherwise ship
|
||||
a broken "Learn more" link — the kind of drift a mechanical test catches and a
|
||||
reviewer does not. Same idea as tests/test_cosyvoice_install_docs.py, which
|
||||
cross-checks a docs claim against the installer registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _registry():
|
||||
from services import tts_backend
|
||||
|
||||
return tts_backend
|
||||
|
||||
|
||||
def test_every_registered_engine_has_a_doc():
|
||||
tb = _registry()
|
||||
missing = sorted(set(tb._REGISTRY.keys()) - set(tb._ENGINE_DOCS))
|
||||
assert missing == [], (
|
||||
"engine(s) with no docs/engines page — add one and map it in "
|
||||
f"_ENGINE_DOCS, or the row's Learn more link disappears: {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_no_doc_entry_outlives_its_engine():
|
||||
tb = _registry()
|
||||
orphans = sorted(set(tb._ENGINE_DOCS) - set(tb._REGISTRY.keys()))
|
||||
assert orphans == [], f"_ENGINE_DOCS maps engine ids that no longer exist: {orphans}"
|
||||
|
||||
|
||||
def test_every_mapped_doc_is_really_there():
|
||||
tb = _registry()
|
||||
broken = sorted(
|
||||
f"{bid} -> {path}"
|
||||
for bid, path in tb._ENGINE_DOCS.items()
|
||||
if not os.path.isfile(os.path.join(_REPO_ROOT, path))
|
||||
)
|
||||
assert broken == [], f"_ENGINE_DOCS points at files that do not exist: {broken}"
|
||||
|
||||
|
||||
def test_docs_url_is_built_from_the_project_link_constant():
|
||||
tb = _registry()
|
||||
from core import links
|
||||
|
||||
url = tb._engine_docs_url("cosyvoice")
|
||||
assert url == f"{links.PROJECT_REPO_BLOB_MAIN}/docs/engines/cosyvoice.md"
|
||||
# An id the registry does not carry gets no link rather than a 404 URL.
|
||||
assert tb._engine_docs_url("not-an-engine") is None
|
||||
|
||||
|
||||
def test_docs_url_survives_the_public_metadata_scrub():
|
||||
"""The whole point: `reason` is replaced, `docs_url` is not.
|
||||
|
||||
A fix that put the doc link anywhere the scrub touches would ship a null
|
||||
href to the one screen that needs it.
|
||||
"""
|
||||
from api.public_engine_metadata import public_backends
|
||||
|
||||
entry = {
|
||||
"id": "cosyvoice",
|
||||
"available": False,
|
||||
"reason": "cosyvoice package not installed. Install from /home/alice/CosyVoice",
|
||||
"last_error": "cosyvoice package not installed.",
|
||||
"docs_url": "https://example.invalid/docs/engines/cosyvoice.md",
|
||||
"install_hint": "git clone --recursive FunAudioLLM/CosyVoice",
|
||||
}
|
||||
(public,) = public_backends([entry])
|
||||
|
||||
assert public["reason"] == "Engine unavailable. Check installation and configuration."
|
||||
assert public["last_error"] == "A previous engine check failed."
|
||||
assert "/home/alice" not in public["reason"]
|
||||
assert public["docs_url"] == entry["docs_url"]
|
||||
assert public["install_hint"] == entry["install_hint"]
|
||||
@@ -13,7 +13,13 @@ import os
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from services.segmented_download import segmented_download, DownloadCancelled
|
||||
import services.segmented_download as sd
|
||||
from services.segmented_download import (
|
||||
segmented_download,
|
||||
DownloadCancelled,
|
||||
_plan_segments,
|
||||
_MAX_SEGMENT_BYTES,
|
||||
)
|
||||
|
||||
|
||||
PAYLOAD = bytes((i % 256) for i in range(1_000_000)) # 1 MB deterministic body
|
||||
@@ -108,3 +114,119 @@ def test_on_bytes_reports_total(tmp_path):
|
||||
seen = []
|
||||
_download(_ranged_handler(), dest, expected_size=len(PAYLOAD), on_bytes=lambda d: seen.append(d))
|
||||
assert sum(seen) == len(PAYLOAD)
|
||||
|
||||
|
||||
# ── #1224 follow-up: bounded segments so a flaky link makes progress ────────
|
||||
#
|
||||
# Progress is committed to the manifest only when a WHOLE segment lands, so the
|
||||
# segment size is also the most bytes a dropped connection can throw away.
|
||||
# Sizing segments as size/num_connections made that ~100 MB on an 800 MB blob:
|
||||
# on a link dropping every ~50 MB no segment ever completed, the manifest was
|
||||
# never written, and every retry restarted from zero.
|
||||
|
||||
def test_large_file_is_split_into_bounded_segments():
|
||||
"""Fail-before: the old plan returned 8 segments of ~100 MB for this size."""
|
||||
size = 805_665_628 # the k2-fsa/OmniVoice blob that reproduced the stall
|
||||
segs = _plan_segments(size, 8)
|
||||
|
||||
assert max(e - s + 1 for s, e in segs) <= _MAX_SEGMENT_BYTES
|
||||
assert len(segs) > 8, "segment count must not be capped by num_connections"
|
||||
# Exact cover: no gap, no overlap, no byte past the end.
|
||||
assert segs[0][0] == 0 and segs[-1][1] == size - 1
|
||||
assert all(segs[i][1] + 1 == segs[i + 1][0] for i in range(len(segs) - 1))
|
||||
|
||||
|
||||
def test_small_file_still_single_segment():
|
||||
"""The cap must not shard tiny files into per-request overhead."""
|
||||
assert len(_plan_segments(1_000_000, 8)) == 1
|
||||
|
||||
|
||||
def test_concurrency_stays_at_num_connections(tmp_path, monkeypatch):
|
||||
"""Many bounded segments must not all fire at once.
|
||||
|
||||
The handler has to HOLD requests open: a synchronous mock returns before any
|
||||
other task is scheduled, so nothing ever overlaps and the assertion passes
|
||||
without exercising the semaphore at all.
|
||||
"""
|
||||
monkeypatch.setattr(sd, "_MIN_SEGMENT_BYTES", 16 * 1024)
|
||||
monkeypatch.setattr(sd, "_MAX_SEGMENT_BYTES", 32 * 1024)
|
||||
connections = 4
|
||||
assert len(_plan_segments(len(PAYLOAD), connections)) > connections, (
|
||||
"test needs more segments than connections"
|
||||
)
|
||||
dest = str(tmp_path / "m.bin")
|
||||
state = {"inflight": 0, "peak": 0}
|
||||
|
||||
async def _run():
|
||||
saturated = asyncio.Event()
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "HEAD":
|
||||
return httpx.Response(200, headers={
|
||||
"content-length": str(len(PAYLOAD)), "accept-ranges": "bytes"})
|
||||
state["inflight"] += 1
|
||||
state["peak"] = max(state["peak"], state["inflight"])
|
||||
try:
|
||||
if state["inflight"] >= connections:
|
||||
saturated.set()
|
||||
# Hold the range open until the pool fills, so overlap is
|
||||
# observable without sleeping. The timeout keeps an
|
||||
# over-restrictive semaphore a failure instead of a hang.
|
||||
try:
|
||||
await asyncio.wait_for(saturated.wait(), timeout=1)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
lo, hi = request.headers["range"].replace("bytes=", "").split("-")
|
||||
return httpx.Response(206, content=PAYLOAD[int(lo):int(hi) + 1])
|
||||
finally:
|
||||
state["inflight"] -= 1
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler), follow_redirects=False
|
||||
) as client:
|
||||
await segmented_download(
|
||||
"https://cdn.example.com/f.bin", dest, client=client,
|
||||
expected_size=len(PAYLOAD), num_connections=connections,
|
||||
)
|
||||
|
||||
asyncio.run(_run())
|
||||
assert state["peak"] == connections, (
|
||||
f"expected exactly {connections} ranges in flight, saw {state['peak']}"
|
||||
)
|
||||
with open(dest, "rb") as f:
|
||||
assert f.read() == PAYLOAD
|
||||
|
||||
|
||||
def test_dropped_connection_resumes_from_manifest(tmp_path, monkeypatch):
|
||||
"""A drop must cost one segment, not the whole file."""
|
||||
monkeypatch.setattr(sd, "_MIN_SEGMENT_BYTES", 16 * 1024)
|
||||
monkeypatch.setattr(sd, "_MAX_SEGMENT_BYTES", 32 * 1024)
|
||||
dest = str(tmp_path / "m.bin")
|
||||
served = []
|
||||
fail_after = {"n": 3}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "HEAD":
|
||||
return httpx.Response(200, headers={
|
||||
"content-length": str(len(PAYLOAD)), "accept-ranges": "bytes"})
|
||||
if fail_after["n"] > 0:
|
||||
fail_after["n"] -= 1
|
||||
if fail_after["n"] == 0:
|
||||
raise httpx.RemoteProtocolError("peer closed connection", request=request)
|
||||
lo, hi = request.headers["range"].replace("bytes=", "").split("-")
|
||||
served.append(int(hi) - int(lo) + 1)
|
||||
return httpx.Response(206, content=PAYLOAD[int(lo):int(hi) + 1])
|
||||
|
||||
with pytest.raises(httpx.RemoteProtocolError):
|
||||
_download(handler, dest, expected_size=len(PAYLOAD), num_connections=2)
|
||||
|
||||
assert os.path.exists(dest + ".part.done"), "completed segments must be committed"
|
||||
before = sum(served)
|
||||
assert before > 0
|
||||
|
||||
_download(handler, dest, expected_size=len(PAYLOAD), num_connections=2)
|
||||
with open(dest, "rb") as f:
|
||||
assert f.read() == PAYLOAD
|
||||
# Resumed, not restarted: total bytes served stay below two full copies.
|
||||
assert sum(served) < 2 * len(PAYLOAD)
|
||||
assert sum(served) >= len(PAYLOAD)
|
||||
|
||||
@@ -88,6 +88,91 @@ def test_engine_health_route_logs_but_does_not_return_private_diagnostic(
|
||||
assert "hf_abcdefghijklmnopqrstuvwxyz1234567890" not in result["message"]
|
||||
|
||||
|
||||
def test_engine_health_log_names_the_engine_and_failure_class(monkeypatch, caplog):
|
||||
"""The withheld line still withholds, but it now says what failed (#1866).
|
||||
|
||||
The response tells the user to "check the backend log for details" and
|
||||
docs/engines/*.md asks a user diagnosing an unavailable engine to copy that
|
||||
engine's log lines. The old line carried neither the engine nor the kind of
|
||||
failure, so a log with several engines probed could not answer either.
|
||||
|
||||
The boundary above is unchanged and deliberately so: the id comes off the
|
||||
resolved registry class rather than the caller, and the failure is a stable
|
||||
class name rather than the exception text — the same shape
|
||||
``core.public_errors.public_failure`` already logs as ``class=...``.
|
||||
"""
|
||||
from api.routers import engines
|
||||
|
||||
class BrokenEngine:
|
||||
id = "broken-engine"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls):
|
||||
raise RuntimeError(_PRIVATE)
|
||||
|
||||
monkeypatch.setattr(engines, "_resolve_engine_class", lambda _engine_id: BrokenEngine)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
engines.engine_health("broken-engine")
|
||||
|
||||
assert "engine=broken-engine" in caplog.text
|
||||
assert "probe=raised:RuntimeError" in caplog.text
|
||||
assert caplog.text.count("\n") == 1 # one record, one line — nothing forged
|
||||
# Still nothing engine-owned: no message text, no path, no token.
|
||||
assert _PRIVATE not in caplog.text
|
||||
assert "/home/alice" not in caplog.text
|
||||
assert "hf_abcdefghijklmnopqrstuvwxyz1234567890" not in caplog.text
|
||||
assert "Traceback" not in caplog.text
|
||||
|
||||
|
||||
def test_engine_health_log_flattens_a_newline_bearing_engine_id(monkeypatch, caplog):
|
||||
"""The id is registry-authored, but the line is still flattened."""
|
||||
from api.routers import engines
|
||||
|
||||
class ForgedId:
|
||||
id = "evil\nWARNING FORGED ENGINE LOG"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls):
|
||||
return False, "nope"
|
||||
|
||||
monkeypatch.setattr(engines, "_resolve_engine_class", lambda _engine_id: ForgedId)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
engines.engine_health("evil")
|
||||
|
||||
assert "FORGED ENGINE LOG" not in caplog.text
|
||||
assert "engine=evil-WARNING-FORGED-ENGINE-LOG" in caplog.text
|
||||
assert caplog.text.count("\n") == 1
|
||||
|
||||
|
||||
def test_engine_health_log_distinguishes_not_available_from_a_raised_probe(
|
||||
monkeypatch, caplog
|
||||
):
|
||||
"""`probe=` says what the probe did, and claims nothing more.
|
||||
|
||||
A probe that returns rather than raises reads `returned-unavailable`
|
||||
whatever the cause — a package that was never installed and a sidecar that
|
||||
died both land here, because SubprocessBackend.health_check() swallows its
|
||||
own exceptions by contract. Telling those apart needs structured failure
|
||||
metadata from the probes, so the field deliberately does not pretend to.
|
||||
"""
|
||||
from api.routers import engines
|
||||
|
||||
class NotInstalled:
|
||||
id = "not-installed"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls):
|
||||
return False, "voxcpm package not installed."
|
||||
|
||||
monkeypatch.setattr(engines, "_resolve_engine_class", lambda _engine_id: NotInstalled)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
engines.engine_health("not-installed")
|
||||
|
||||
assert "engine=not-installed" in caplog.text
|
||||
assert "probe=returned-unavailable" in caplog.text
|
||||
assert "voxcpm package not installed" not in caplog.text
|
||||
|
||||
|
||||
def test_tailscale_enable_failure_keeps_service_output_private(monkeypatch, caplog):
|
||||
import asyncio
|
||||
|
||||
|
||||
@@ -11,20 +11,110 @@ import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend"))
|
||||
|
||||
from api.routers.setup.download import _segmented_enabled # noqa: E402
|
||||
|
||||
|
||||
def _enabled():
|
||||
"""Resolve the app module at run time, not at collection."""
|
||||
from api.routers.setup.download import _segmented_enabled
|
||||
|
||||
return _segmented_enabled()
|
||||
|
||||
|
||||
def test_segmented_is_on_by_default(monkeypatch):
|
||||
monkeypatch.delenv("OMNIVOICE_SEGMENTED_DOWNLOAD", raising=False)
|
||||
assert _segmented_enabled() is True
|
||||
assert _enabled() is True
|
||||
|
||||
|
||||
def test_env_override_can_disable(monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_SEGMENTED_DOWNLOAD", "0")
|
||||
assert _segmented_enabled() is False
|
||||
assert _enabled() is False
|
||||
|
||||
|
||||
def test_env_override_truthy_keeps_it_on(monkeypatch):
|
||||
for val in ("1", "true", "on", "yes"):
|
||||
monkeypatch.setenv("OMNIVOICE_SEGMENTED_DOWNLOAD", val)
|
||||
assert _segmented_enabled() is True
|
||||
assert _enabled() is True
|
||||
|
||||
|
||||
# The accelerator must be re-entered on the NEXT attempt after a dropped
|
||||
# connection, so it resumes from its .part manifest. Falling straight through to
|
||||
# snapshot_download in the same attempt finishes the install from a separate
|
||||
# .incomplete file and strands the manifest — restart-from-zero all over again.
|
||||
|
||||
import httpx # noqa: E402
|
||||
|
||||
_MAX = 5
|
||||
|
||||
|
||||
def _plan(exc, attempt, max_attempts=_MAX):
|
||||
"""Resolve the app module at run time, not at collection.
|
||||
|
||||
A module-level import of an app module goes stale when an earlier test
|
||||
pollutes ``sys.modules``.
|
||||
"""
|
||||
from api.routers.setup.download import _segmented_retry_plan
|
||||
|
||||
return _segmented_retry_plan(exc, attempt, max_attempts)
|
||||
|
||||
|
||||
def _dropped():
|
||||
return httpx.RemoteProtocolError(
|
||||
"peer closed connection without sending complete message body"
|
||||
)
|
||||
|
||||
|
||||
def test_dropped_connection_reraises_so_the_next_attempt_resumes():
|
||||
for attempt in (1, 2, 3):
|
||||
disable, reraise = _plan(_dropped(), attempt)
|
||||
assert reraise is True, f"attempt {attempt} must reach the outer retry"
|
||||
assert disable is False, f"attempt {attempt} must keep the accelerator"
|
||||
|
||||
|
||||
def test_the_accelerator_keeps_the_second_to_last_attempt():
|
||||
"""Handover must not start early.
|
||||
|
||||
Disabling AND falling through in the same attempt would abandon the
|
||||
resumable manifest one attempt sooner than needed and restart the file
|
||||
through a separate `.incomplete`.
|
||||
"""
|
||||
disable, reraise = _plan(_dropped(), _MAX - 1)
|
||||
assert (disable, reraise) == (True, True), (
|
||||
"the attempt that exhausts the accelerator still re-raises, so the "
|
||||
"plain path starts on the last attempt, not the second-to-last"
|
||||
)
|
||||
|
||||
|
||||
def test_final_attempt_takes_the_plain_path_without_reraising():
|
||||
"""The accelerator can never be the reason an install fails outright."""
|
||||
assert _plan(_dropped(), _MAX) == (True, False)
|
||||
assert _plan(_dropped(), 1, 1) == (True, False), "single-attempt install"
|
||||
|
||||
|
||||
def test_a_non_network_failure_disables_the_accelerator_at_once():
|
||||
"""An accelerator that cannot work here must not burn every retry."""
|
||||
assert _plan(ValueError("sha256 mismatch"), 1) == (True, False)
|
||||
|
||||
|
||||
def _note(disable, reraise):
|
||||
from api.routers.setup.download import _segmented_retry_note
|
||||
|
||||
return _segmented_retry_note(disable, reraise)
|
||||
|
||||
|
||||
def test_the_log_does_not_promise_a_fallback_that_has_not_happened_yet():
|
||||
"""The exhausting attempt re-raises, so its fallback is next — not now."""
|
||||
assert _note(*_plan(_dropped(), _MAX - 1)) == (
|
||||
"exhausted — retrying once more, then snapshot_download takes over"
|
||||
)
|
||||
assert "now" not in _note(*_plan(_dropped(), _MAX - 1))
|
||||
|
||||
# The branch that really does hand over says so.
|
||||
assert _note(*_plan(_dropped(), _MAX)).endswith("snapshot_download now")
|
||||
assert _note(*_plan(ValueError("sha256 mismatch"), 1)).endswith(
|
||||
"snapshot_download now"
|
||||
)
|
||||
|
||||
# Still accelerating.
|
||||
assert _note(*_plan(_dropped(), 1)) == (
|
||||
"kept for the next attempt (resumes from its manifest)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user