Merge branch 'fix/review-2175' into chore/electron-0.5.4
This commit is contained in:
@@ -143,7 +143,7 @@ def _resolve_profile_conditioning(row, *, ref_text=None, instruct=None,
|
||||
out = {
|
||||
"ref_audio_path": None, "ref_text": ref_text, "instruct": instruct,
|
||||
"seed": seed, "language": language, "kind": None,
|
||||
"persist_ref_text": False,
|
||||
"persist_ref_text": False, "language_from_profile": False,
|
||||
}
|
||||
# `kind` is authoritative (0005): 'design' profiles condition on their
|
||||
# deterministic rendered sample + instruct; 'clone' on the user's
|
||||
@@ -212,6 +212,12 @@ def _resolve_profile_conditioning(row, *, ref_text=None, instruct=None,
|
||||
prof_lang = None
|
||||
if prof_lang and prof_lang != "Auto":
|
||||
out["language"] = prof_lang
|
||||
# #2156: record that the caller never asked for this language. The
|
||||
# UI omits `language` entirely while its picker reads "Auto", so a
|
||||
# profile-filled language must not be reported back as if the user
|
||||
# had picked it — an engine that can't speak it would otherwise
|
||||
# tell them to "leave language as Auto", which is what they did.
|
||||
out["language_from_profile"] = True
|
||||
return out
|
||||
|
||||
|
||||
@@ -1043,6 +1049,18 @@ _LANGUAGE_REJECTION_SIGNATURES = (
|
||||
"unsupported language code",
|
||||
)
|
||||
|
||||
# Engine-specific rejections that ALREADY name the engine and what it supports,
|
||||
# so #1257's generic rewrite deliberately leaves them alone — re-wrapping them
|
||||
# only nests "Engine's own message:" twice. They still have to be recognised as
|
||||
# language rejections for #2156's provenance check, which cares about the
|
||||
# *cause* of the language, not the quality of the wording.
|
||||
_SELF_DESCRIBING_LANGUAGE_REJECTIONS = (
|
||||
# services/tts_backend.py: "…doesn't support language='Persian'. Kokoro
|
||||
# supports: …" — mlx-audio's Kokoro, the engine reported in #2156.
|
||||
"doesn't support language",
|
||||
"does not support language",
|
||||
)
|
||||
|
||||
#: `unsupported language: xx` / `unsupported language 'xx'` — but not
|
||||
#: `unsupported language model ...`.
|
||||
_LANGUAGE_REJECTION_RE = re.compile(
|
||||
@@ -1052,15 +1070,87 @@ _LANGUAGE_REJECTION_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _is_language_rejection(text: str) -> bool:
|
||||
"""True when an engine failure is about the LANGUAGE it was handed.
|
||||
|
||||
Matched on the message, not the type: the engines multiplex third-party
|
||||
libraries that each raise their own class. Covers the self-describing
|
||||
wordings too — #1257's rewrite skips those, but #2156 still needs to know a
|
||||
language was refused so it can say where that language came from.
|
||||
"""
|
||||
low = text.lower()
|
||||
return (
|
||||
any(sig in low for sig in _LANGUAGE_REJECTION_SIGNATURES)
|
||||
or any(sig in low for sig in _SELF_DESCRIBING_LANGUAGE_REJECTIONS)
|
||||
or bool(_LANGUAGE_REJECTION_RE.search(text))
|
||||
)
|
||||
|
||||
|
||||
def _profile_language_rejection_detail(exc: BaseException, language) -> str:
|
||||
"""The 400 body for a language the *voice profile* supplied, not the user.
|
||||
|
||||
#2156: the UI omits `language` while its picker reads "Auto", and #533
|
||||
fills that gap from the selected profile. When the active engine can't
|
||||
speak the profile's language the engine's own message tells the user to
|
||||
"leave language as 'Auto'" — which is exactly what they did, so the advice
|
||||
cannot be acted on. Name the real source and the remedies that exist.
|
||||
"""
|
||||
return (
|
||||
f"This voice profile is saved with the language '{language}', and the "
|
||||
f"active engine can't speak it. The language picker being on \"Auto\" "
|
||||
f"does not override that — Auto fills the language in from the "
|
||||
f"profile. Set this voice profile's language to one the engine "
|
||||
f"supports, pick a supported language explicitly for this render, or "
|
||||
f"switch engine in Model Catalogue (the VoiceStudio engine has the "
|
||||
f"widest coverage). Engine's own message: {exc}"
|
||||
)
|
||||
|
||||
|
||||
def _language_rejection_payload(exc, language, *, from_profile):
|
||||
"""Stable, non-retryable error metadata for both response transports."""
|
||||
from core.public_errors import stream_failure
|
||||
failure = stream_failure("invalid_request")
|
||||
failure["terminal"] = True
|
||||
if from_profile:
|
||||
failure.update(
|
||||
code="profile_language_rejected", language=language,
|
||||
detail=_profile_language_rejection_detail(_root_language_error(exc), language),
|
||||
)
|
||||
return failure
|
||||
|
||||
|
||||
def _language_rejection_http_error(exc: BaseException, language, *, from_profile):
|
||||
"""The 400 a refused language deserves, wherever the refusal was raised.
|
||||
|
||||
A language an engine cannot speak is never retryable — not by waiting, and
|
||||
not by re-running the same request on another machine. Built here so the
|
||||
local (`ValueError`) and remote (`RemoteJobFailed`) handlers cannot drift:
|
||||
#2156 shipped the profile-aware branch on the local path only, and a remote
|
||||
render kept answering with a retryable 503 that offered "run it on this
|
||||
machine instead", which cannot help.
|
||||
"""
|
||||
root = _root_language_error(exc)
|
||||
detail = (
|
||||
_profile_language_rejection_detail(root, language)
|
||||
if from_profile else str(exc)
|
||||
)
|
||||
if from_profile:
|
||||
detail = {"code": "profile_language_rejected", "language": language, "message": detail}
|
||||
return HTTPException(status_code=400, detail=detail)
|
||||
|
||||
|
||||
def _language_rejection_or(e: BaseException, backend, language):
|
||||
"""``e`` rewritten with engine context when it's a language rejection.
|
||||
|
||||
Returns ``e`` unchanged otherwise, so this is safe to wrap any failure in.
|
||||
Matched on the message, not the type: the engines multiplex third-party
|
||||
libraries that each raise their own class.
|
||||
Deliberately narrower than :func:`_is_language_rejection`: a message that
|
||||
already names its engine and the languages it supports is left alone rather
|
||||
than nested inside a second "Engine's own message:".
|
||||
"""
|
||||
text = str(e)
|
||||
low = text.lower()
|
||||
if any(sig in low for sig in _SELF_DESCRIBING_LANGUAGE_REJECTIONS):
|
||||
return e
|
||||
if not any(sig in low for sig in _LANGUAGE_REJECTION_SIGNATURES) and not (
|
||||
_LANGUAGE_REJECTION_RE.search(text)
|
||||
):
|
||||
@@ -1069,13 +1159,23 @@ def _language_rejection_or(e: BaseException, backend, language):
|
||||
type(backend), "id", type(backend).__name__
|
||||
)
|
||||
requested = f" '{language}'" if language else ""
|
||||
return ValueError(
|
||||
rewritten = ValueError(
|
||||
f"The {engine} engine can't speak{requested}. VoiceStudio offers every "
|
||||
f"language its default engine supports, but each engine covers a "
|
||||
f"different set — pick one this engine supports, or switch engine in "
|
||||
f"Model Catalogue (the VoiceStudio engine has the widest coverage) "
|
||||
f"and generate again. Engine's own message: {e}"
|
||||
)
|
||||
# Keep the engine's own text reachable. #2156's profile message quotes the
|
||||
# engine once; without this it would quote THIS wrapper, repeating both the
|
||||
# engine-switch remedy and "Engine's own message:" twice.
|
||||
rewritten.engine_language_error = e
|
||||
return rewritten
|
||||
|
||||
|
||||
def _root_language_error(exc: BaseException) -> BaseException:
|
||||
"""The engine's own rejection, unwrapping :func:`_language_rejection_or`."""
|
||||
return getattr(exc, "engine_language_error", exc)
|
||||
|
||||
|
||||
def _persist_profile_ref_text(profile_id: str, ref_text: str) -> None:
|
||||
@@ -1570,6 +1670,9 @@ async def generate_speech(
|
||||
ref_lease = None
|
||||
used_seed = seed
|
||||
resolved_profile_id = None
|
||||
# #2156: True once a profile's stored language fills a language the caller
|
||||
# never sent, so a rejection can name the profile instead of the picker.
|
||||
language_from_profile = False
|
||||
history_mode = None # profile.kind when a profile drives; else inferred at insert
|
||||
# #1032: profile id to persist an auto-transcribed reference transcript to.
|
||||
# Set only for a plain (unlocked) clone profile whose stored ref_text is
|
||||
@@ -1598,6 +1701,7 @@ async def generate_speech(
|
||||
instruct = _cond["instruct"]
|
||||
used_seed = _cond["seed"]
|
||||
language = _cond["language"]
|
||||
language_from_profile = _cond["language_from_profile"]
|
||||
if _cond["persist_ref_text"]:
|
||||
persist_ref_text_profile_id = profile_id
|
||||
elif ref_audio is not None:
|
||||
@@ -1872,10 +1976,14 @@ async def generate_speech(
|
||||
# holding what is often its only slot until the lease lapses.
|
||||
render.cancel()
|
||||
raise
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.error("Remote generation request rejected")
|
||||
from core.public_errors import stream_failure
|
||||
yield _line({"type": "error", **stream_failure("invalid_request")})
|
||||
failure = (
|
||||
_language_rejection_payload(e, language, from_profile=language_from_profile)
|
||||
if _is_language_rejection(str(e)) else stream_failure("invalid_request")
|
||||
)
|
||||
yield _line({"type": "error", **failure})
|
||||
except gpu_gateway.ModelNotDownloaded as e:
|
||||
logger.warning("Remote model missing on %s", _target_label)
|
||||
from core.public_errors import stream_failure
|
||||
@@ -1891,13 +1999,16 @@ async def generate_speech(
|
||||
except gpu_gateway.RemoteJobFailed as e:
|
||||
logger.error("Remote generate failed on %s", _target_label)
|
||||
from core.public_errors import stream_failure
|
||||
yield _line({
|
||||
"type": "error",
|
||||
**stream_failure("generation_failed"),
|
||||
"retryable": True,
|
||||
"target_label": e.worker_label or _target_label,
|
||||
"hint": e.hint,
|
||||
})
|
||||
if _is_language_rejection(str(e)):
|
||||
yield _line({"type": "error", **_language_rejection_payload(
|
||||
e, language, from_profile=language_from_profile,
|
||||
)})
|
||||
else:
|
||||
yield _line({
|
||||
"type": "error", **stream_failure("generation_failed"),
|
||||
"retryable": True, "target_label": e.worker_label or _target_label,
|
||||
"hint": e.hint,
|
||||
})
|
||||
except Exception as exc:
|
||||
# Mid-job remote failure is NOT quietly redone here: the client
|
||||
# treats a retryable error as "surface it", so the user decides
|
||||
@@ -2206,10 +2317,14 @@ async def generate_speech(
|
||||
failure = stream_failure("generation_timeout")
|
||||
failure["retry_after"] = 30
|
||||
yield _line({"type": "error", **failure})
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.error("Streaming generation request rejected")
|
||||
from core.public_errors import stream_failure
|
||||
yield _line({"type": "error", **stream_failure("invalid_request")})
|
||||
failure = (
|
||||
_language_rejection_payload(e, language, from_profile=language_from_profile)
|
||||
if _is_language_rejection(str(e)) else stream_failure("invalid_request")
|
||||
)
|
||||
yield _line({"type": "error", **failure})
|
||||
except Exception as exc:
|
||||
# A streaming request answers 200 and carries its failure as an
|
||||
# in-band error frame, so it never reaches the global 500
|
||||
@@ -2392,6 +2507,15 @@ async def generate_speech(
|
||||
# the client can offer "run it on this machine instead" — a resubmit
|
||||
# the user chose, with a wait they were told about.
|
||||
logger.error("Remote generate failed on %s: %s", _target_label, e)
|
||||
# #2156: a language the engine can't speak is a request problem, not a
|
||||
# worker problem. It travels home as RemoteJobFailed — caught here,
|
||||
# ahead of the ValueError branch below — so without this the user is
|
||||
# told to retry on this machine, where the same engine refuses the same
|
||||
# language. Answer it as the 400 it is, on either path.
|
||||
if _is_language_rejection(str(e)):
|
||||
raise _language_rejection_http_error(
|
||||
e, language, from_profile=language_from_profile
|
||||
) from e
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"{e} {e.hint or 'Run it on this machine instead, or pick another GPU.'}",
|
||||
@@ -2426,6 +2550,15 @@ async def generate_speech(
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
except ValueError as e:
|
||||
logger.error("Validation failed: %s", e)
|
||||
# #2156: the language the engine refused was never chosen by the user —
|
||||
# it came from the selected voice profile because the picker was on
|
||||
# "Auto". The engine's own remedy ("leave language as 'Auto'") is then
|
||||
# unfollowable, so say where the language actually came from. Only this
|
||||
# scope knows that; the engine adapters never see the provenance.
|
||||
if language_from_profile and _is_language_rejection(str(e)):
|
||||
raise _language_rejection_http_error(
|
||||
e, language, from_profile=True
|
||||
) from e
|
||||
# Most ValueErrors here are VoiceStudio's own validation messages and
|
||||
# are exactly what the user should read. A few are raw library text
|
||||
# naming parameters and files the user cannot act on — those get the
|
||||
|
||||
Reference in New Issue
Block a user