Merge branch 'fix/review-2175' into chore/electron-0.5.4
This commit is contained in:
@@ -16,6 +16,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Name the voice profile when its saved language is one the active engine can't speak, instead of advising a language picker already set to Auto (#2175, #2156) — thanks @shivsin25!
|
||||
- Validate Python dependencies before reusing a desktop runtime and offer setup for incomplete environments (#2176)
|
||||
- Check active model cloning support before starting voice conversion (#2147)
|
||||
- Accept both valid SIGKILL diagnostics in the desktop lifecycle regression check (#2170)
|
||||
|
||||
@@ -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,11 +1999,14 @@ 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
|
||||
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,
|
||||
"type": "error", **stream_failure("generation_failed"),
|
||||
"retryable": True, "target_label": e.worker_label or _target_label,
|
||||
"hint": e.hint,
|
||||
})
|
||||
except Exception as exc:
|
||||
@@ -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
|
||||
|
||||
@@ -54,7 +54,14 @@ HF repo id. The env var overrides the persisted UI choice.
|
||||
- Language support is per-model (Kokoro ~8 languages, others vary). An
|
||||
unsupported language for Kokoro produces a clear error naming what it
|
||||
does support ([#977](https://github.com/debpalash/VoiceStudio/issues/977))
|
||||
— leave language on Auto or switch to a multilingual engine.
|
||||
— pick a language it supports or switch to a multilingual engine.
|
||||
- Auto is not an escape hatch from that. With the picker on Auto the request
|
||||
carries no language, and a selected voice profile's saved language fills the
|
||||
gap ([#533](https://github.com/debpalash/VoiceStudio/issues/533)) — so a
|
||||
profile saved as, say, Persian still reaches Kokoro and is still refused. The
|
||||
error names the profile as the source in that case
|
||||
([#2156](https://github.com/debpalash/VoiceStudio/issues/2156)); change the
|
||||
profile's language, or pick a supported one explicitly for the render.
|
||||
|
||||
## Platform notes
|
||||
|
||||
@@ -79,6 +86,10 @@ See also: [benchmarks.md](../benchmarks.md),
|
||||
Consecutive chunks with the same native sample rate are resampled together to
|
||||
preserve filter context at chunk boundaries; rate changes start a new group.
|
||||
|
||||
Profile language refusals are terminal request errors on both local and remote
|
||||
rendering, including streaming. Electron and web show localized guidance that
|
||||
Auto inherits the profile language; neither silently retries the same refusal.
|
||||
|
||||
Kokoro language errors list every language in the installed model’s table.
|
||||
“British English” and `en-gb` both select its British English voice pipeline.
|
||||
Display names from newer installed Kokoro tables are accepted too, so a language
|
||||
|
||||
@@ -2530,6 +2530,7 @@
|
||||
"recorder_unsupported": "لا يستطيع هذا النظام ترميز صوت الميكروفون."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "يستخدم الوضع التلقائي لغة ملف الصوت ({{language}})، التي لا يدعمها هذا المحرك. غيّر لغة الملف أو اختر لغة مدعومة أو بدّل المحرك.",
|
||||
"generation_in_progress": "هناك عملية توليد قيد التشغيل بالفعل — انتظر حتى تنتهي قبل بدء عملية أخرى.",
|
||||
"error_prefix": "خطأ: {{message}}",
|
||||
"ignored_duplicate": "تم التجاهل (تم تعيين الفئة بالفعل): {{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"channels_stereo": "Stereo"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Automatisch verwendet die Sprache des Stimmprofils ({{language}}), die diese Engine nicht unterstützt. Ändere die Profilsprache, wähle eine unterstützte Sprache oder wechsle die Engine.",
|
||||
"generation_in_progress": "Eine Generierung läuft bereits – warte, bis sie abgeschlossen ist, bevor du eine weitere startest.",
|
||||
"error_prefix": "Fehler: {{message}}",
|
||||
"ignored_duplicate": "Ignoriert (Kategorie bereits festgelegt): {{items}}",
|
||||
|
||||
@@ -292,6 +292,7 @@
|
||||
"recorder_unsupported": "This system cannot encode microphone audio."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Auto uses the voice profile’s language ({{language}}), which this engine does not support. Change the profile language, choose a supported language, or switch engines.",
|
||||
"enter_text": "Enter some text first.",
|
||||
"upload_or_select": "Upload a reference clip or pick a saved voice.",
|
||||
"trim_hint": "The clip is {{duration}}s — trim to ≤{{max}}s for best cloning.",
|
||||
|
||||
@@ -2524,6 +2524,7 @@
|
||||
"channels_mono": "Mono"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Automático usa el idioma del perfil de voz ({{language}}), que este motor no admite. Cambia el idioma del perfil, elige uno compatible o cambia de motor.",
|
||||
"generation_in_progress": "Ya hay una generación en curso; espera a que termine antes de iniciar otra.",
|
||||
"ignored_duplicate": "Ignorado (categoría ya establecida): {{items}}",
|
||||
"ignored_conflict": "Se ignoró {{items}} — un dialecto chino y un acento inglés no se pueden combinar.",
|
||||
|
||||
@@ -2524,6 +2524,7 @@
|
||||
"microphone_number": "Microphone {{number}}"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Auto utilise la langue du profil vocal ({{language}}), non prise en charge par ce moteur. Modifiez la langue du profil, choisissez une langue compatible ou changez de moteur.",
|
||||
"generation_in_progress": "Une génération est déjà en cours — attendez sa fin avant d’en lancer une autre.",
|
||||
"error_prefix": "Erreur : {{message}}",
|
||||
"ignored_duplicate": "Ignoré (catégorie déjà définie) : {{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"recorder_unsupported": "यह सिस्टम माइक्रोफ़ोन ऑडियो एन्कोड नहीं कर सकता।"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "ऑटो वॉइस प्रोफ़ाइल की भाषा ({{language}}) का उपयोग करता है, जिसे यह इंजन समर्थन नहीं करता। प्रोफ़ाइल की भाषा बदलें, समर्थित भाषा चुनें या इंजन बदलें।",
|
||||
"generation_in_progress": "एक जनरेशन पहले से चल रहा है — दूसरा शुरू करने से पहले उसके पूरा होने की प्रतीक्षा करें।",
|
||||
"error_prefix": "त्रुटि: {{message}}",
|
||||
"ignored_duplicate": "अनदेखा (श्रेणी पहले से ही सेट): {{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"channels_stereo": "Stereo"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Otomatis menggunakan bahasa profil suara ({{language}}), yang tidak didukung mesin ini. Ubah bahasa profil, pilih bahasa yang didukung, atau ganti mesin.",
|
||||
"generation_in_progress": "Pembuatan sedang berjalan — tunggu hingga selesai sebelum memulai yang lain.",
|
||||
"error_prefix": "Kesalahan: {{message}}",
|
||||
"ignored_duplicate": "Diabaikan (kategori sudah ditetapkan): {{items}}",
|
||||
|
||||
@@ -2524,6 +2524,7 @@
|
||||
"channels_stereo": "Stereo"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Auto usa la lingua del profilo vocale ({{language}}), non supportata da questo motore. Modifica la lingua del profilo, scegli una lingua supportata o cambia motore.",
|
||||
"generation_in_progress": "È già in corso una generazione: attendi che termini prima di avviarne un’altra.",
|
||||
"error_prefix": "Errore: {{message}}",
|
||||
"ignored_duplicate": "Ignorato (categoria già impostata): {{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"recorder_unsupported": "このシステムではマイク音声をエンコードできません。"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "自動では音声プロファイルの言語({{language}})が使われますが、このエンジンは対応していません。プロファイルの言語を変更するか、対応言語または別のエンジンを選択してください。",
|
||||
"generation_in_progress": "生成がすでに実行中です。完了してから次の生成を開始してください。",
|
||||
"error_prefix": "エラー: {{message}}",
|
||||
"ignored_duplicate": "無視 (カテゴリはすでに設定されています): {{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"recorder_unsupported": "이 시스템에서는 마이크 오디오를 인코딩할 수 없습니다."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "자동은 이 엔진이 지원하지 않는 음성 프로필의 언어({{language}})를 사용합니다. 프로필 언어를 변경하거나 지원되는 언어 또는 다른 엔진을 선택하세요.",
|
||||
"generation_in_progress": "이미 생성 작업이 실행 중입니다. 완료된 후 새 작업을 시작하세요.",
|
||||
"error_prefix": "오류: {{message}}",
|
||||
"ignored_duplicate": "무시됨(카테고리가 이미 설정됨): {{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"channels_stereo": "Stereo"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Automatisch gebruikt de taal van het stemprofiel ({{language}}), die deze engine niet ondersteunt. Wijzig de profieltaal, kies een ondersteunde taal of wissel van engine.",
|
||||
"generation_in_progress": "Er wordt al audio gegenereerd — wacht tot dit klaar is voordat je opnieuw begint.",
|
||||
"error_prefix": "Fout: {{message}}",
|
||||
"ignored_duplicate": "Genegeerd (categorie al ingesteld): {{items}}",
|
||||
|
||||
@@ -2526,6 +2526,7 @@
|
||||
"channels_stereo": "Stereo"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Tryb automatyczny używa języka profilu głosu ({{language}}), którego ten silnik nie obsługuje. Zmień język profilu, wybierz obsługiwany język lub zmień silnik.",
|
||||
"generation_in_progress": "Generowanie już trwa — poczekaj na jego zakończenie przed rozpoczęciem kolejnego.",
|
||||
"error_prefix": "Błąd: {{message}}",
|
||||
"ignored_duplicate": "Ignorowane (kategoria już ustawiona): {{items}}",
|
||||
|
||||
@@ -2524,6 +2524,7 @@
|
||||
"channels_mono": "Mono"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Automático usa o idioma do perfil de voz ({{language}}), não suportado por este motor. Altere o idioma do perfil, escolha um idioma compatível ou mude de motor.",
|
||||
"generation_in_progress": "Já existe uma geração em andamento — aguarde a conclusão antes de iniciar outra.",
|
||||
"error_prefix": "Erro: {{message}}",
|
||||
"ignored_duplicate": "Ignorado (categoria já definida): {{items}}",
|
||||
|
||||
@@ -2526,6 +2526,7 @@
|
||||
"recorder_unsupported": "Эта система не может кодировать звук с микрофона."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Авто использует язык голосового профиля ({{language}}), который этот движок не поддерживает. Измените язык профиля, выберите поддерживаемый язык или другой движок.",
|
||||
"generation_in_progress": "Генерация уже выполняется — дождитесь её завершения, прежде чем запускать следующую.",
|
||||
"error_prefix": "Ошибка: {{message}}",
|
||||
"ignored_duplicate": "Игнорируется (категория уже установлена): {{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"channels_stereo": "Stereo"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Auto använder röstprofilens språk ({{language}}), som denna motor inte stöder. Ändra profilens språk, välj ett språk som stöds eller byt motor.",
|
||||
"generation_in_progress": "En generering pågår redan – vänta tills den är klar innan du startar en ny.",
|
||||
"error_prefix": "Fel: {{message}}",
|
||||
"ignored_duplicate": "Ignorerad (kategori redan inställd): {{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"recorder_unsupported": "ระบบนี้ไม่สามารถเข้ารหัสเสียงจากไมโครโฟนได้"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "อัตโนมัติใช้ภาษาของโปรไฟล์เสียง ({{language}}) ซึ่งเอนจินนี้ไม่รองรับ เปลี่ยนภาษาโปรไฟล์ เลือกภาษาที่รองรับ หรือเปลี่ยนเอนจิน",
|
||||
"generation_in_progress": "มีการสร้างเสียงกำลังทำงานอยู่ — รอให้เสร็จก่อนเริ่มงานใหม่",
|
||||
"error_prefix": "ข้อผิดพลาด: {{message}}",
|
||||
"ignored_duplicate": "ละเว้น (หมวดหมู่ที่ตั้งไว้แล้ว): {{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"channels_stereo": "Stereo"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Otomatik, bu motorun desteklemediği ses profili dilini ({{language}}) kullanır. Profil dilini değiştirin, desteklenen bir dil seçin veya motoru değiştirin.",
|
||||
"generation_in_progress": "Bir oluşturma işlemi zaten çalışıyor — yenisini başlatmadan önce bitmesini bekleyin.",
|
||||
"error_prefix": "Hata: {{message}}",
|
||||
"ignored_duplicate": "Yoksayıldı (kategori zaten ayarlandı): {{items}}",
|
||||
|
||||
@@ -2526,6 +2526,7 @@
|
||||
"recorder_unsupported": "Ця система не може кодувати звук із мікрофона."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Авто використовує мову голосового профілю ({{language}}), яку цей рушій не підтримує. Змініть мову профілю, виберіть підтримувану мову або інший рушій.",
|
||||
"generation_in_progress": "Генерація вже виконується — дочекайтеся її завершення, перш ніж запускати наступну.",
|
||||
"error_prefix": "Помилка: {{message}}",
|
||||
"ignored_duplicate": "Проігноровано (категорію вже встановлено): {{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"recorder_unsupported": "Hệ thống này không thể mã hóa âm thanh từ micrô."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Tự động sử dụng ngôn ngữ của hồ sơ giọng nói ({{language}}), nhưng bộ máy này không hỗ trợ. Đổi ngôn ngữ hồ sơ, chọn ngôn ngữ được hỗ trợ hoặc đổi bộ máy.",
|
||||
"generation_in_progress": "Một tác vụ tạo âm thanh đang chạy — hãy đợi hoàn tất trước khi bắt đầu tác vụ khác.",
|
||||
"error_prefix": "Lỗi: {{message}}",
|
||||
"ignored_duplicate": "Đã bỏ qua (danh mục đã được đặt): {{items}}",
|
||||
|
||||
@@ -2526,6 +2526,7 @@
|
||||
"recorder_unsupported": "此系统无法编码麦克风音频。"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "自动模式使用声音档案的语言({{language}}),但此引擎不支持该语言。请修改档案语言、选择支持的语言或更换引擎。",
|
||||
"generation_in_progress": "已有生成任务正在运行,请等待其完成后再启动新任务。",
|
||||
"error_prefix": "错误:{{message}}",
|
||||
"ignored_duplicate": "忽略(类别已设置):{{items}}",
|
||||
|
||||
@@ -2522,6 +2522,7 @@
|
||||
"recorder_unsupported": "此系統無法編碼麥克風音訊。"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "自動模式使用聲音設定檔的語言({{language}}),但此引擎不支援該語言。請修改設定檔語言、選擇支援的語言或更換引擎。",
|
||||
"generation_in_progress": "已有產生工作正在執行,請等待完成後再啟動新工作。",
|
||||
"error_prefix": "錯誤:{{message}}",
|
||||
"ignored_duplicate": "忽略(類別已設定):{{items}}",
|
||||
|
||||
@@ -169,3 +169,23 @@ it('uses a structured error message without losing recovery metadata', async ()
|
||||
expect(error.message).toBe(detail.message);
|
||||
expect(error.payload?.detail).toEqual(detail);
|
||||
});
|
||||
|
||||
it('localizes structured profile language failures', async () => {
|
||||
const translate = vi.spyOn(i18next, 't').mockReturnValue('Localized profile guidance');
|
||||
try {
|
||||
const detail = {
|
||||
code: 'profile_language_rejected',
|
||||
language: 'Persian',
|
||||
message: 'English fallback',
|
||||
};
|
||||
const error = await errorFromResponse(
|
||||
new Response(JSON.stringify({ detail }), { status: 400 }),
|
||||
);
|
||||
expect(error.detail).toBe('Localized profile guidance');
|
||||
expect(translate).toHaveBeenCalledWith('tts_errors.profile_language_rejected', {
|
||||
language: 'Persian',
|
||||
});
|
||||
} finally {
|
||||
translate.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { languageRejectionMessage } from '../../../../../../frontend/src/utils/languageRejection.ts';
|
||||
/**
|
||||
* Same-origin API client. The renderer never talks to 127.0.0.1:<port>
|
||||
* directly (CORS); `/api/*` is proxied by the dev server / the app:// protocol
|
||||
@@ -44,6 +45,8 @@ export function describeError(err: unknown): string {
|
||||
}
|
||||
|
||||
function detailToString(detail: unknown): string {
|
||||
const localized = languageRejectionMessage(detail, tr);
|
||||
if (localized) return localized;
|
||||
if (
|
||||
detail &&
|
||||
typeof detail === 'object' &&
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import i18n from 'i18next';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CLONE_MAX_SECONDS,
|
||||
REF_HARD_MAX_SECONDS,
|
||||
generateClone,
|
||||
generateCloneStreaming,
|
||||
shouldFallbackToClassic,
|
||||
parseGenerateHeaders,
|
||||
sanitizeInstruct,
|
||||
toGenerateForm,
|
||||
@@ -277,3 +280,31 @@ describe('generateClone', () => {
|
||||
await assertion;
|
||||
});
|
||||
});
|
||||
|
||||
it('localizes streamed profile language refusals and prevents classic fallback', async () => {
|
||||
const translate = vi.spyOn(i18n, 't').mockReturnValue('Localized profile guidance');
|
||||
try {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
code: 'profile_language_rejected',
|
||||
language: 'Persian',
|
||||
detail: 'English fallback',
|
||||
retryable: false,
|
||||
terminal: true,
|
||||
}) + '\n',
|
||||
{ headers: { 'content-type': 'application/x-ndjson' } },
|
||||
),
|
||||
),
|
||||
);
|
||||
const error = await generateCloneStreaming(BASE_INPUT).catch((e) => e);
|
||||
expect(error.message).toBe('Localized profile guidance');
|
||||
expect(shouldFallbackToClassic(error)).toBe(false);
|
||||
} finally {
|
||||
translate.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import i18next from 'i18next';
|
||||
import { languageRejectionMessage } from '../../../../../../frontend/src/utils/languageRejection.ts';
|
||||
import { ApiError, apiFetch, isAbortError } from './client';
|
||||
import type { CloneGenerateInput, GenerateResult } from './types';
|
||||
import { beginAppActivity } from '@/lib/app-activity';
|
||||
@@ -255,6 +257,9 @@ interface StreamEvent {
|
||||
count?: number;
|
||||
text?: string[];
|
||||
detail?: string;
|
||||
code?: string;
|
||||
language?: string;
|
||||
terminal?: boolean;
|
||||
retryable?: boolean;
|
||||
percent?: number;
|
||||
}
|
||||
@@ -300,12 +305,15 @@ export async function generateCloneStreaming(
|
||||
} else if (event.type === 'done') {
|
||||
meta = event;
|
||||
} else if (event.type === 'error') {
|
||||
const message = event.detail || 'TTS stream reported an error';
|
||||
const terminal = [
|
||||
'[clone_ref_unusable]',
|
||||
'[clone_ref_too_long]',
|
||||
'[clone_ref_no_speech]',
|
||||
].some((marker) => message.includes(marker));
|
||||
const message =
|
||||
languageRejectionMessage(event, i18next.t) ||
|
||||
event.detail ||
|
||||
'TTS stream reported an error';
|
||||
const terminal =
|
||||
event.terminal === true ||
|
||||
['[clone_ref_unusable]', '[clone_ref_too_long]', '[clone_ref_no_speech]'].some((marker) =>
|
||||
message.includes(marker),
|
||||
);
|
||||
throw new StreamingPreviewError(message, { retryable: event.retryable, terminal });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"src/preload/index.d.ts",
|
||||
"src/shared/**/*",
|
||||
"src/renderer/env.d.ts",
|
||||
"../frontend/src/utils/languageRejection.ts",
|
||||
"../frontend/src/utils/coalescedJsonStorage.ts",
|
||||
"../frontend/src/utils/indexedDbLongformStore.ts",
|
||||
"../frontend/src/utils/cookieExport.ts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { languageRejectionMessage } from '../utils/languageRejection.ts';
|
||||
import i18n from 'i18next';
|
||||
import { abortableDelay } from '../utils/abortableDelay.ts';
|
||||
// Backend base URL.
|
||||
@@ -593,7 +594,8 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
|
||||
// human-readable `message` — use it for the Error message instead of
|
||||
// letting the object stringify to "[object Object]".
|
||||
const msg =
|
||||
detail &&
|
||||
languageRejectionMessage(detail, i18n.t) ||
|
||||
(detail &&
|
||||
typeof detail === 'object' &&
|
||||
'code' in detail &&
|
||||
detail.code === 'argos_runtime_unavailable'
|
||||
@@ -602,7 +604,7 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
|
||||
})
|
||||
: typeof detail === 'string'
|
||||
? detail
|
||||
: ((detail as { message?: string })?.message ?? JSON.stringify(detail));
|
||||
: ((detail as { message?: string })?.message ?? JSON.stringify(detail)));
|
||||
// The backend names the exception type in `error_class` on its 500s.
|
||||
// Lifting it here is what lets the bug report say which failure it was.
|
||||
const errorClass =
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "انتهت صلاحية جلسة الدبلجة هذه أو تم تنظيفها — أعد تحميل الفيديو لبدء مقطع جديد."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "يستخدم الوضع التلقائي لغة ملف الصوت ({{language}})، التي لا يدعمها هذا المحرك. غيّر لغة الملف أو اختر لغة مدعومة أو بدّل المحرك.",
|
||||
"enter_text": "الرجاء إدخال النص",
|
||||
"upload_or_select": "قم بتحميل ملف صوتي أو حدد ملفًا صوتيًا",
|
||||
"trim_hint": "الصوت هو {{duration}}s — قم بقصه إلى ≥{{max}}s للحصول على أفضل استنساخ",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Diese Dub-Sitzung ist abgelaufen oder wurde bereinigt. Laden Sie Ihr Video erneut hoch, um eine neue zu starten."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Automatisch verwendet die Sprache des Stimmprofils ({{language}}), die diese Engine nicht unterstützt. Ändere die Profilsprache, wähle eine unterstützte Sprache oder wechsle die Engine.",
|
||||
"enter_text": "Bitte geben Sie Text ein",
|
||||
"upload_or_select": "Laden Sie ein Audio hoch oder wählen Sie ein Sprachprofil aus",
|
||||
"trim_hint": "Audio ist {{duration}}s – für bestes Klonen auf ≤{{max}}s kürzen",
|
||||
|
||||
@@ -2890,6 +2890,7 @@
|
||||
"upgrading_preview": "Upgrading {{count}} preview-quality segment(s) to full quality…"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Auto uses the voice profile’s language ({{language}}), which this engine does not support. Change the profile language, choose a supported language, or switch engines.",
|
||||
"enter_text": "Please enter text",
|
||||
"upload_or_select": "Upload an audio or select a voice profile",
|
||||
"trim_hint": "Audio is {{duration}}s — trim to ≤{{max}}s for best cloning",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Esta sesión de doblaje expiró o se limpió: vuelve a subir tu video para comenzar uno nuevo."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Automático usa el idioma del perfil de voz ({{language}}), que este motor no admite. Cambia el idioma del perfil, elige uno compatible o cambia de motor.",
|
||||
"enter_text": "Por favor ingresa texto",
|
||||
"upload_or_select": "Sube un audio o selecciona un perfil de voz",
|
||||
"trim_hint": "El audio es {{duration}}s; recórtelo a ≤{{max}}s para una mejor clonación.",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Cette session de doublage a expiré ou a été nettoyée : téléchargez à nouveau votre vidéo pour en démarrer une nouvelle."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Auto utilise la langue du profil vocal ({{language}}), non prise en charge par ce moteur. Modifiez la langue du profil, choisissez une langue compatible ou changez de moteur.",
|
||||
"enter_text": "Veuillez saisir du texte",
|
||||
"upload_or_select": "Téléchargez un audio ou sélectionnez un profil vocal",
|
||||
"trim_hint": "L'audio est de {{duration}}s — coupez à ≤{{max}}s pour un meilleur clonage",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "यह डब सत्र समाप्त हो गया है या साफ़ कर दिया गया है - नया शुरू करने के लिए अपना वीडियो पुनः अपलोड करें।"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "ऑटो वॉइस प्रोफ़ाइल की भाषा ({{language}}) का उपयोग करता है, जिसे यह इंजन समर्थन नहीं करता। प्रोफ़ाइल की भाषा बदलें, समर्थित भाषा चुनें या इंजन बदलें।",
|
||||
"enter_text": "कृपया पाठ दर्ज करें",
|
||||
"upload_or_select": "एक ऑडियो अपलोड करें या एक वॉयस प्रोफ़ाइल चुनें",
|
||||
"trim_hint": "ऑडियो {{duration}}s है - सर्वोत्तम क्लोनिंग के लिए ≤{{max}}s तक ट्रिम करें",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Sesi sulih suara ini telah habis masa berlakunya atau telah dibersihkan — unggah ulang video Anda untuk memulai yang baru."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Otomatis menggunakan bahasa profil suara ({{language}}), yang tidak didukung mesin ini. Ubah bahasa profil, pilih bahasa yang didukung, atau ganti mesin.",
|
||||
"enter_text": "Silakan masukkan teks",
|
||||
"upload_or_select": "Unggah audio atau pilih profil suara",
|
||||
"trim_hint": "Audio adalah {{duration}}s — potong ke ≤{{max}}s untuk kloning terbaik",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Questa sessione di doppiaggio è scaduta o è stata ripulita: ricarica il tuo video per avviarne uno nuovo."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Auto usa la lingua del profilo vocale ({{language}}), non supportata da questo motore. Modifica la lingua del profilo, scegli una lingua supportata o cambia motore.",
|
||||
"enter_text": "Inserisci il testo",
|
||||
"upload_or_select": "Carica un audio o seleziona un profilo vocale",
|
||||
"trim_hint": "L'audio è {{duration}}s: taglia a ≤{{max}}s per una migliore clonazione",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "このダビング セッションは期限切れかクリーンアップされました。新しいビデオを開始するにはビデオを再アップロードしてください。"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "自動では音声プロファイルの言語({{language}})が使われますが、このエンジンは対応していません。プロファイルの言語を変更するか、対応言語または別のエンジンを選択してください。",
|
||||
"enter_text": "テキストを入力してください",
|
||||
"upload_or_select": "音声をアップロードするか、音声プロファイルを選択してください",
|
||||
"trim_hint": "オーディオは {{duration}}s — 最適なクローン作成のために ≤{{max}}s にトリミングします",
|
||||
|
||||
@@ -2600,6 +2600,7 @@
|
||||
"session_expired": "이 더빙 세션이 만료되었거나 정리되었습니다. 새 동영상을 시작하려면 동영상을 다시 업로드하세요."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "자동은 이 엔진이 지원하지 않는 음성 프로필의 언어({{language}})를 사용합니다. 프로필 언어를 변경하거나 지원되는 언어 또는 다른 엔진을 선택하세요.",
|
||||
"enter_text": "텍스트를 입력해주세요",
|
||||
"upload_or_select": "오디오 업로드 또는 음성 프로필 선택",
|
||||
"trim_hint": "오디오는 {{duration}}s입니다. 최상의 복제를 위해 ≤{{max}}s로 조정하세요.",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Deze kopieersessie is verlopen of opgeschoond. Upload je video opnieuw om een nieuwe te starten."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Automatisch gebruikt de taal van het stemprofiel ({{language}}), die deze engine niet ondersteunt. Wijzig de profieltaal, kies een ondersteunde taal of wissel van engine.",
|
||||
"enter_text": "Voer tekst in",
|
||||
"upload_or_select": "Upload een audio of selecteer een stemprofiel",
|
||||
"trim_hint": "Audio is {{duration}}s — trim tot ≤{{max}}s voor de beste kloonfunctie",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Ta sesja dubowania wygasła lub została wyczyszczona — prześlij film ponownie, aby rozpocząć nową."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Tryb automatyczny używa języka profilu głosu ({{language}}), którego ten silnik nie obsługuje. Zmień język profilu, wybierz obsługiwany język lub zmień silnik.",
|
||||
"enter_text": "Proszę wpisać tekst",
|
||||
"upload_or_select": "Prześlij plik audio lub wybierz profil głosowy",
|
||||
"trim_hint": "Dźwięk wynosi {{duration}}s — przytnij do ≤{{max}}s, aby uzyskać najlepsze klonowanie",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Esta sessão de dublagem expirou ou foi limpa. Reenvie seu vídeo para iniciar uma nova."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Automático usa o idioma do perfil de voz ({{language}}), não suportado por este motor. Altere o idioma do perfil, escolha um idioma compatível ou mude de motor.",
|
||||
"enter_text": "Por favor insira o texto",
|
||||
"upload_or_select": "Carregue um áudio ou selecione um perfil de voz",
|
||||
"trim_hint": "O áudio é {{duration}}s – ajuste para ≤{{max}}s para melhor clonagem",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Срок действия сеанса дублирования истек или он был удален — загрузите видео повторно, чтобы начать новое."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Авто использует язык голосового профиля ({{language}}), который этот движок не поддерживает. Измените язык профиля, выберите поддерживаемый язык или другой движок.",
|
||||
"enter_text": "Пожалуйста, введите текст",
|
||||
"upload_or_select": "Загрузите аудио или выберите голосовой профиль",
|
||||
"trim_hint": "Звук составляет {{duration}}s — обрезайте до ≤{{max}}s для лучшего клонирования",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Den här dubbsessionen löpte ut eller rensades upp – ladda upp din video igen för att starta en ny."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Auto använder röstprofilens språk ({{language}}), som denna motor inte stöder. Ändra profilens språk, välj ett språk som stöds eller byt motor.",
|
||||
"enter_text": "Vänligen ange text",
|
||||
"upload_or_select": "Ladda upp ett ljud eller välj en röstprofil",
|
||||
"trim_hint": "Ljudet är {{duration}}s — trimma till ≤{{max}}s för bästa kloning",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "เซสชั่นพากย์นี้หมดอายุหรือถูกล้างออกไปแล้ว — อัปโหลดวิดีโอของคุณอีกครั้งเพื่อเริ่มวิดีโอใหม่"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "อัตโนมัติใช้ภาษาของโปรไฟล์เสียง ({{language}}) ซึ่งเอนจินนี้ไม่รองรับ เปลี่ยนภาษาโปรไฟล์ เลือกภาษาที่รองรับ หรือเปลี่ยนเอนจิน",
|
||||
"enter_text": "กรุณากรอกข้อความ",
|
||||
"upload_or_select": "อัปโหลดเสียงหรือเลือกโปรไฟล์เสียง",
|
||||
"trim_hint": "เสียงคือ {{duration}}s — ตัดเป็น ≤{{max}}s เพื่อการโคลนที่ดีที่สุด",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Bu dub oturumunun süresi doldu veya temizlendi. Yeni bir video başlatmak için videonuzu yeniden yükleyin."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Otomatik, bu motorun desteklemediği ses profili dilini ({{language}}) kullanır. Profil dilini değiştirin, desteklenen bir dil seçin veya motoru değiştirin.",
|
||||
"enter_text": "Lütfen metni girin",
|
||||
"upload_or_select": "Bir ses yükleyin veya bir ses profili seçin",
|
||||
"trim_hint": "Ses {{duration}}s — en iyi klonlama için ≤{{max}}s'ye kırpın",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Цей сеанс дубляжу закінчився або його було очищено — повторно завантажте відео, щоб почати нове."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Авто використовує мову голосового профілю ({{language}}), яку цей рушій не підтримує. Змініть мову профілю, виберіть підтримувану мову або інший рушій.",
|
||||
"enter_text": "Будь ласка, введіть текст",
|
||||
"upload_or_select": "Завантажте аудіо або виберіть голосовий профіль",
|
||||
"trim_hint": "Аудіо {{duration}}s — обріжте до ≤{{max}}s для найкращого клонування",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "Phiên lồng tiếng này đã hết hạn hoặc đã bị xóa - hãy tải lại video của bạn lên để bắt đầu một video mới."
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "Tự động sử dụng ngôn ngữ của hồ sơ giọng nói ({{language}}), nhưng bộ máy này không hỗ trợ. Đổi ngôn ngữ hồ sơ, chọn ngôn ngữ được hỗ trợ hoặc đổi bộ máy.",
|
||||
"enter_text": "Vui lòng nhập văn bản",
|
||||
"upload_or_select": "Tải lên âm thanh hoặc chọn cấu hình giọng nói",
|
||||
"trim_hint": "Âm thanh là {{duration}}s — cắt thành ≤{{max}}s để sao chép tốt nhất",
|
||||
|
||||
@@ -2605,6 +2605,7 @@
|
||||
"session_expired": "此配音会话已过期或已清理——请重新上传视频以开始新的会话。"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "自动模式使用声音档案的语言({{language}}),但此引擎不支持该语言。请修改档案语言、选择支持的语言或更换引擎。",
|
||||
"enter_text": "请输入文字",
|
||||
"upload_or_select": "上传音频或选择语音配置文件",
|
||||
"trim_hint": "音频为 {{duration}}s — 修剪至 ≤{{max}}s 以获得最佳克隆效果",
|
||||
|
||||
@@ -2219,6 +2219,7 @@
|
||||
"session_expired": "此配音會話已過期或已清理 - 重新上傳您的影片以開始新的影片。"
|
||||
},
|
||||
"tts_errors": {
|
||||
"profile_language_rejected": "自動模式使用聲音設定檔的語言({{language}}),但此引擎不支援該語言。請修改設定檔語言、選擇支援的語言或更換引擎。",
|
||||
"enter_text": "請輸入文字",
|
||||
"upload_or_select": "上傳音訊或選擇語音設定文件",
|
||||
"trim_hint": "音訊為 {{duration}}s — 修剪至 ≤{{max}}s 以獲得最佳克隆效果",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import i18n from 'i18next';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Streaming TTS preview (feat: streaming-tts-preview): the NDJSON client must
|
||||
@@ -380,6 +381,32 @@ describe('streamGenerateSpeech', () => {
|
||||
expect(plain.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('localizes a terminal profile language refusal without retrying', async () => {
|
||||
const translate = vi.spyOn(i18n, 't').mockReturnValue('Localized profile guidance');
|
||||
try {
|
||||
apiFetch.mockResolvedValue(
|
||||
ndjsonResponse([
|
||||
{
|
||||
type: 'error',
|
||||
code: 'profile_language_rejected',
|
||||
language: 'Persian',
|
||||
detail: 'English fallback',
|
||||
retryable: false,
|
||||
terminal: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const error = await streamGenerateSpeech(new FormData(), {}).catch((e) => e);
|
||||
expect(error.message).toBe('Localized profile guidance');
|
||||
expect(shouldFallbackToClassic(error)).toBe(false);
|
||||
expect(translate).toHaveBeenCalledWith('tts_errors.profile_language_rejected', {
|
||||
language: 'Persian',
|
||||
});
|
||||
} finally {
|
||||
translate.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('marks actionable clone-reference errors terminal to prevent a classic retry', async () => {
|
||||
apiFetch.mockResolvedValue(
|
||||
ndjsonResponse([
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Localize only the known structured failure, never a server-supplied key. */
|
||||
export function languageRejectionMessage(
|
||||
value: unknown,
|
||||
translate: (key: string, options: { language: string }) => string,
|
||||
): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const failure = value as { code?: unknown; language?: unknown };
|
||||
if (failure.code !== 'profile_language_rejected' || typeof failure.language !== 'string')
|
||||
return undefined;
|
||||
return translate('tts_errors.profile_language_rejected', { language: failure.language });
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import i18n from 'i18next';
|
||||
import { languageRejectionMessage } from './languageRejection.ts';
|
||||
/**
|
||||
* streamingTts.js — streaming TTS preview (feat: streaming-tts-preview).
|
||||
*
|
||||
@@ -399,12 +401,13 @@ async function _streamGenerateSpeech(
|
||||
} else if (ev.type === 'done') {
|
||||
meta = ev;
|
||||
} else if (ev.type === 'error') {
|
||||
const detail = ev.detail || 'TTS stream reported an error';
|
||||
const terminal = [
|
||||
'[clone_ref_unusable]',
|
||||
'[clone_ref_too_long]',
|
||||
'[clone_ref_no_speech]',
|
||||
].some((marker) => detail.includes(marker));
|
||||
const detail =
|
||||
languageRejectionMessage(ev, i18n.t) || ev.detail || 'TTS stream reported an error';
|
||||
const terminal =
|
||||
ev.terminal === true ||
|
||||
['[clone_ref_unusable]', '[clone_ref_too_long]', '[clone_ref_no_speech]'].some((marker) =>
|
||||
detail.includes(marker),
|
||||
);
|
||||
throw new StreamingPreviewError(detail, {
|
||||
retryable: ev.retryable === true,
|
||||
retryAfter: ev.retry_after ?? null,
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"""#2156: a language the user never picked must not be blamed on the picker.
|
||||
|
||||
The reporter was on mlx-audio (Kokoro) with the language picker on "Auto" and
|
||||
got:
|
||||
|
||||
400 Bad Request: mlx-audio's Kokoro model (mlx-community/Kokoro-82M-bf16)
|
||||
doesn't support language='Persian'. … Pick one of those, leave language as
|
||||
'Auto', or switch to a multilingual engine …
|
||||
|
||||
They had left it on Auto. The UI omits `language` entirely while its picker
|
||||
reads "Auto" (`frontend/src/hooks/useProfiles.js`: `if (reqLang && reqLang !==
|
||||
'Auto') formData.append(...)`), and #533 fills that gap from the selected voice
|
||||
profile. So "Auto" is precisely how 'Persian' got there — the one remedy the
|
||||
message leads with is the state the user was already in, and nothing in it
|
||||
points at the voice profile that actually supplied the language.
|
||||
|
||||
This completes #1257's line of work rather than reopening it: that issue chose
|
||||
to name the engine and the way out instead of maintaining per-model language
|
||||
maps ("a brittle map that goes stale on each engine update"). Same principle
|
||||
here — say where the language came from, don't enumerate languages.
|
||||
"""
|
||||
import importlib
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
||||
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
||||
|
||||
def _gen_mod():
|
||||
"""Imported lazily so the end-to-end tests below still run (and fail on
|
||||
their assertions, not on a missing symbol) against a tree without the fix."""
|
||||
return importlib.import_module("api.routers.generation")
|
||||
|
||||
|
||||
# The real wording from services/tts_backend.py::resolve_kokoro_lang_code.
|
||||
KOKORO_REFUSAL = (
|
||||
"mlx-audio's Kokoro model (mlx-community/Kokoro-82M-bf16) doesn't support "
|
||||
"language='Persian'. Kokoro supports: Chinese, English, French, Hindi, "
|
||||
"Italian, Japanese, Portuguese, Spanish. Pick one of those, leave language "
|
||||
"as 'Auto', or switch to a multilingual engine (e.g. OmniVoice) for other "
|
||||
"languages."
|
||||
)
|
||||
|
||||
KOKORO_SUPPORTED = ("Chinese", "English", "French", "Hindi",
|
||||
"Italian", "Japanese", "Portuguese", "Spanish")
|
||||
|
||||
|
||||
def _tts_mod():
|
||||
return importlib.import_module("services.tts_backend")
|
||||
|
||||
|
||||
def _make_refusing_engine(engine_id="fake-kokoro-2156"):
|
||||
"""An engine that refuses unknown languages the way Kokoro really does."""
|
||||
class _FakeEngine(_tts_mod().TTSBackend):
|
||||
id = engine_id
|
||||
display_name = "Fake Kokoro (test)"
|
||||
applies_own_mastering = False
|
||||
gpu_compat = ("cpu",)
|
||||
calls: list = []
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> int:
|
||||
return 24000
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
return ["multi"]
|
||||
|
||||
@classmethod
|
||||
def is_available(cls):
|
||||
return True, "ready"
|
||||
|
||||
def generate(self, text, **kw) -> torch.Tensor:
|
||||
type(self).calls.append((text, kw))
|
||||
language = kw.get("language")
|
||||
if language and language not in KOKORO_SUPPORTED:
|
||||
raise ValueError(
|
||||
f"mlx-audio's Kokoro model (mlx-community/Kokoro-82M-bf16) "
|
||||
f"doesn't support language={language!r}. Kokoro supports: "
|
||||
f"{', '.join(KOKORO_SUPPORTED)}. Pick one of those, leave "
|
||||
f"language as 'Auto', or switch to a multilingual engine "
|
||||
f"(e.g. OmniVoice) for other languages."
|
||||
)
|
||||
return torch.zeros(1, 24000)
|
||||
|
||||
return _FakeEngine
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
|
||||
return TestClient(app, client=("127.0.0.1", 50000))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _init_db():
|
||||
from core.db import init_db
|
||||
|
||||
init_db()
|
||||
|
||||
|
||||
def _profile(language):
|
||||
from core.db import db_conn
|
||||
|
||||
pid = f"vp-{uuid.uuid4().hex[:8]}"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, language, kind, created_at) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(pid, f"{language} Narrator", language, "clone", 0.0),
|
||||
)
|
||||
return pid
|
||||
|
||||
|
||||
def _drop(pid):
|
||||
from core.db import db_conn
|
||||
|
||||
with db_conn() as conn:
|
||||
conn.execute("DELETE FROM generation_history WHERE profile_id=?", (pid,))
|
||||
conn.execute("DELETE FROM voice_profiles WHERE id=?", (pid,))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def persian_profile(_init_db):
|
||||
pid = _profile("Persian")
|
||||
yield pid
|
||||
_drop(pid)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def english_profile(_init_db):
|
||||
pid = _profile("English")
|
||||
yield pid
|
||||
_drop(pid)
|
||||
|
||||
|
||||
# ── the reported failure ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_profile_supplied_language_names_the_profile_not_the_picker(
|
||||
client, monkeypatch, persian_profile
|
||||
):
|
||||
fake = _make_refusing_engine()
|
||||
monkeypatch.setitem(_tts_mod()._REGISTRY, fake.id, fake)
|
||||
fake.calls.clear()
|
||||
|
||||
# `language` omitted — exactly what the UI sends with the picker on "Auto".
|
||||
res = client.post("/generate", data={
|
||||
"text": "Salam", "profile_id": persian_profile, "engine": fake.id,
|
||||
})
|
||||
|
||||
assert res.status_code == 400, res.text
|
||||
detail = res.json()["detail"]
|
||||
if isinstance(detail, dict):
|
||||
assert detail["code"] == "profile_language_rejected"
|
||||
assert detail["language"] == "Persian"
|
||||
detail = detail["message"]
|
||||
# Says where the language actually came from …
|
||||
assert "voice profile" in detail.lower()
|
||||
assert "Persian" in detail
|
||||
# … and that Auto is not an escape from it, since Auto is what filled it in.
|
||||
assert "does not override" in detail
|
||||
# … and keeps the engine's own capability list, quoted once, not nested.
|
||||
assert "Kokoro supports:" in detail
|
||||
assert detail.count("Engine's own message:") == 1
|
||||
|
||||
|
||||
def test_the_profile_language_still_reached_the_engine(
|
||||
client, monkeypatch, persian_profile
|
||||
):
|
||||
"""Guards the premise: this is a profile fill, not the user's choice."""
|
||||
fake = _make_refusing_engine()
|
||||
monkeypatch.setitem(_tts_mod()._REGISTRY, fake.id, fake)
|
||||
fake.calls.clear()
|
||||
|
||||
client.post("/generate", data={
|
||||
"text": "Salam", "profile_id": persian_profile, "engine": fake.id,
|
||||
})
|
||||
|
||||
assert [kw.get("language") for _t, kw in fake.calls] == ["Persian"]
|
||||
|
||||
|
||||
def test_an_explicitly_requested_language_is_not_blamed_on_the_profile(
|
||||
client, monkeypatch, english_profile
|
||||
):
|
||||
"""The user really did pick it, so the profile wording would be a lie —
|
||||
they get the engine's own message, unchanged."""
|
||||
fake = _make_refusing_engine()
|
||||
monkeypatch.setitem(_tts_mod()._REGISTRY, fake.id, fake)
|
||||
fake.calls.clear()
|
||||
|
||||
res = client.post("/generate", data={
|
||||
"text": "Salam", "profile_id": english_profile, "engine": fake.id,
|
||||
"language": "Persian",
|
||||
})
|
||||
|
||||
assert res.status_code == 400, res.text
|
||||
detail = res.json()["detail"]
|
||||
if isinstance(detail, dict):
|
||||
assert detail["code"] == "profile_language_rejected"
|
||||
assert detail["language"] == "Persian"
|
||||
detail = detail["message"]
|
||||
assert "voice profile" not in detail.lower()
|
||||
assert "does not override" not in detail
|
||||
assert "doesn't support language='Persian'" in detail
|
||||
|
||||
|
||||
def test_a_supported_profile_language_still_drives_generation(
|
||||
client, monkeypatch, english_profile
|
||||
):
|
||||
"""#533 is untouched: a profile language the engine *can* speak still
|
||||
reaches it and still renders."""
|
||||
fake = _make_refusing_engine()
|
||||
monkeypatch.setitem(_tts_mod()._REGISTRY, fake.id, fake)
|
||||
fake.calls.clear()
|
||||
|
||||
res = client.post("/generate", data={
|
||||
"text": "Hello", "profile_id": english_profile, "engine": fake.id,
|
||||
})
|
||||
|
||||
assert res.status_code == 200, res.text
|
||||
assert [kw.get("language") for _t, kw in fake.calls] == ["English"]
|
||||
|
||||
|
||||
def test_a_non_language_failure_under_a_profile_is_untouched(
|
||||
client, monkeypatch, persian_profile
|
||||
):
|
||||
"""Over-matching guard: having a profile language must not rewrite every
|
||||
ValueError as a language problem."""
|
||||
class _Boom(_make_refusing_engine("fake-boom-2156")):
|
||||
def generate(self, text, **kw):
|
||||
raise ValueError("Reference clip is shorter than 3 seconds.")
|
||||
|
||||
monkeypatch.setitem(_tts_mod()._REGISTRY, _Boom.id, _Boom)
|
||||
|
||||
res = client.post("/generate", data={
|
||||
"text": "Salam", "profile_id": persian_profile, "engine": _Boom.id,
|
||||
})
|
||||
|
||||
assert res.status_code == 400, res.text
|
||||
detail = res.json()["detail"]
|
||||
if isinstance(detail, dict):
|
||||
assert detail["code"] == "profile_language_rejected"
|
||||
assert detail["language"] == "Persian"
|
||||
detail = detail["message"]
|
||||
assert "shorter than 3 seconds" in detail
|
||||
assert "voice profile" not in detail.lower()
|
||||
|
||||
|
||||
# ── units ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_real_kokoro_wording_is_recognised_as_a_language_rejection():
|
||||
# #1257's signature list never matched this — "doesn't support language="
|
||||
# contains none of "invalid language code" / "unsupported language …" — so
|
||||
# the provenance check would have skipped the engine actually reported.
|
||||
assert _gen_mod()._is_language_rejection(KOKORO_REFUSAL)
|
||||
|
||||
|
||||
def test_a_self_describing_rejection_is_not_wrapped_twice():
|
||||
"""Kokoro already names its engine and its languages. #1257's rewrite must
|
||||
leave it alone, or the user reads "Engine's own message:" twice."""
|
||||
class _Engine:
|
||||
id = "mlx-audio"
|
||||
display_name = "MLX Audio"
|
||||
|
||||
original = ValueError(KOKORO_REFUSAL)
|
||||
assert _gen_mod()._language_rejection_or(original, _Engine(), "Persian") is original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reason", [
|
||||
"Invalid language code. Supported languages: ar (Arabic), da (Danish)",
|
||||
"Unsupported language: bn",
|
||||
])
|
||||
def test_generic_rejections_are_still_rewritten_with_engine_context(reason):
|
||||
"""#1257 keeps working for the messages it was written for."""
|
||||
class _Engine:
|
||||
id = "mlx-audio"
|
||||
display_name = "MLX Audio"
|
||||
|
||||
rewritten = _gen_mod()._language_rejection_or(ValueError(reason), _Engine(), "bn")
|
||||
assert rewritten is not ValueError
|
||||
assert "MLX Audio" in str(rewritten)
|
||||
|
||||
|
||||
# ── review findings on the first cut of this fix ────────────────────────────
|
||||
|
||||
|
||||
def test_a_generic_rejection_is_quoted_once_not_twice(client, monkeypatch, persian_profile):
|
||||
"""Greptile P2. `_language_rejection_or` wraps a *generic* rejection with
|
||||
the engine remedy before the handler sees it. Building the profile message
|
||||
from that wrapper repeated both the engine-switch advice and "Engine's own
|
||||
message:" twice — so the profile message is built from the engine's own
|
||||
text, not from the wrapper around it."""
|
||||
class _Generic(_make_refusing_engine("fake-generic-2156")):
|
||||
def generate(self, text, **kw):
|
||||
raise ValueError(
|
||||
"Invalid language code. Supported languages: ar (Arabic), "
|
||||
"da (Danish), de (German)"
|
||||
)
|
||||
|
||||
monkeypatch.setitem(_tts_mod()._REGISTRY, _Generic.id, _Generic)
|
||||
|
||||
res = client.post("/generate", data={
|
||||
"text": "Salam", "profile_id": persian_profile, "engine": _Generic.id,
|
||||
})
|
||||
|
||||
assert res.status_code == 400, res.text
|
||||
detail = res.json()["detail"]
|
||||
if isinstance(detail, dict):
|
||||
assert detail["code"] == "profile_language_rejected"
|
||||
assert detail["language"] == "Persian"
|
||||
detail = detail["message"]
|
||||
assert "voice profile" in detail.lower()
|
||||
assert detail.count("Engine's own message:") == 1
|
||||
assert detail.count("switch engine in Model Catalogue") == 1
|
||||
# The engine's own text survives exactly once.
|
||||
assert detail.count("Invalid language code") == 1
|
||||
|
||||
|
||||
def _route_remotely(monkeypatch, failure):
|
||||
"""Send the render to a worker, and fail it there with `failure`."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services import gpu_gateway
|
||||
|
||||
gen = _gen_mod()
|
||||
monkeypatch.setattr(
|
||||
gen, "_routing_decision",
|
||||
lambda: SimpleNamespace(remote=True, label="gpu-box", reason=""),
|
||||
)
|
||||
|
||||
async def _boom(*_a, **_k):
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(gpu_gateway, "run", _boom)
|
||||
|
||||
|
||||
def test_a_remote_language_refusal_is_a_400_not_a_retryable_503(
|
||||
client, monkeypatch, persian_profile
|
||||
):
|
||||
"""Greptile P1. A worker's rejection comes home as RemoteJobFailed, which is
|
||||
caught ahead of the ValueError branch — so the profile-aware 400 never ran
|
||||
and the user was told to retry on this machine, where the same engine
|
||||
refuses the same language."""
|
||||
from services import gpu_gateway
|
||||
|
||||
fake = _make_refusing_engine("fake-remote-2156")
|
||||
monkeypatch.setitem(_tts_mod()._REGISTRY, fake.id, fake)
|
||||
_route_remotely(monkeypatch, gpu_gateway.RemoteJobFailed(
|
||||
KOKORO_REFUSAL, worker_label="gpu-box"))
|
||||
|
||||
res = client.post("/generate", data={
|
||||
"text": "Salam", "profile_id": persian_profile, "engine": fake.id,
|
||||
})
|
||||
|
||||
assert res.status_code == 400, f"{res.status_code}: {res.text}"
|
||||
assert res.headers.get("X-OmniVoice-Retryable") != "true"
|
||||
detail = res.json()["detail"]
|
||||
if isinstance(detail, dict):
|
||||
assert detail["code"] == "profile_language_rejected"
|
||||
assert detail["language"] == "Persian"
|
||||
detail = detail["message"]
|
||||
assert "voice profile" in detail.lower()
|
||||
assert "Run it on this machine instead" not in detail
|
||||
|
||||
|
||||
def test_a_remote_non_language_failure_is_still_a_retryable_503(
|
||||
client, monkeypatch, persian_profile
|
||||
):
|
||||
"""Guard on the same branch: only language refusals change class — a real
|
||||
worker failure keeps its retryable 503 and its 'run it here' offer."""
|
||||
from services import gpu_gateway
|
||||
|
||||
fake = _make_refusing_engine("fake-remote-ok-2156")
|
||||
monkeypatch.setitem(_tts_mod()._REGISTRY, fake.id, fake)
|
||||
_route_remotely(monkeypatch, gpu_gateway.RemoteJobFailed(
|
||||
"CUDA out of memory on the worker", worker_label="gpu-box"))
|
||||
|
||||
res = client.post("/generate", data={
|
||||
"text": "Salam", "profile_id": persian_profile, "engine": fake.id,
|
||||
})
|
||||
|
||||
assert res.status_code == 503, f"{res.status_code}: {res.text}"
|
||||
assert res.headers.get("X-OmniVoice-Retryable") == "true"
|
||||
|
||||
|
||||
def test_the_wrapper_keeps_the_engines_own_error_reachable():
|
||||
class _Engine:
|
||||
id = "mlx-audio"
|
||||
display_name = "MLX Audio"
|
||||
|
||||
original = ValueError("Invalid language code. Supported languages: ar (Arabic)")
|
||||
wrapped = _gen_mod()._language_rejection_or(original, _Engine(), "Persian")
|
||||
assert wrapped is not original
|
||||
assert _gen_mod()._root_language_error(wrapped) is original
|
||||
# An unwrapped error is its own root.
|
||||
assert _gen_mod()._root_language_error(original) is original
|
||||
|
||||
|
||||
def _row(**over):
|
||||
row = {
|
||||
"kind": "clone", "instruct": None, "is_locked": 0,
|
||||
"ref_audio_path": None, "locked_audio_path": None, "ref_text": None,
|
||||
"seed": None, "vd_states": None, "language": None,
|
||||
}
|
||||
row.update(over)
|
||||
return row
|
||||
|
||||
|
||||
def test_the_resolver_flags_a_profile_filled_language():
|
||||
out = _gen_mod()._resolve_profile_conditioning(_row(language="Persian"))
|
||||
assert out["language"] == "Persian"
|
||||
assert out["language_from_profile"] is True
|
||||
|
||||
|
||||
def test_the_resolver_does_not_flag_an_explicit_request_language():
|
||||
out = _gen_mod()._resolve_profile_conditioning(_row(language="Persian"), language="French")
|
||||
assert out["language"] == "French"
|
||||
assert out["language_from_profile"] is False
|
||||
|
||||
|
||||
def test_the_resolver_does_not_flag_when_the_profile_has_no_language():
|
||||
out = _gen_mod()._resolve_profile_conditioning(_row(language=None))
|
||||
assert out["language"] is None
|
||||
assert out["language_from_profile"] is False
|
||||
|
||||
|
||||
def test_an_explicit_auto_is_still_filled_from_the_profile():
|
||||
# "Auto" and an absent value mean the same thing to #533; the flag must be
|
||||
# set either way, since neither is the user naming a language.
|
||||
out = _gen_mod()._resolve_profile_conditioning(_row(language="Persian"), language="Auto")
|
||||
assert out["language"] == "Persian"
|
||||
assert out["language_from_profile"] is True
|
||||
|
||||
@pytest.mark.parametrize('remote', [False, True])
|
||||
def test_streamed_profile_language_refusal_is_terminal(client, monkeypatch, persian_profile, remote):
|
||||
import json
|
||||
from services import gpu_gateway
|
||||
fake = _make_refusing_engine('fake-stream-language-2156')
|
||||
monkeypatch.setitem(_tts_mod()._REGISTRY, fake.id, fake)
|
||||
if remote:
|
||||
_route_remotely(monkeypatch, gpu_gateway.RemoteJobFailed(KOKORO_REFUSAL, worker_label='gpu-box'))
|
||||
response = client.post('/generate', data={
|
||||
'text': 'Salam', 'profile_id': persian_profile, 'engine': fake.id, 'stream': 'true',
|
||||
})
|
||||
assert response.status_code == 200, response.text
|
||||
frames = [json.loads(line) for line in response.text.splitlines() if line]
|
||||
error = next(frame for frame in frames if frame['type'] == 'error')
|
||||
assert error['code'] == 'profile_language_rejected'
|
||||
assert error['language'] == 'Persian'
|
||||
assert error['retryable'] is False
|
||||
assert error['terminal'] is True
|
||||
Reference in New Issue
Block a user