Merge branch 'fix/review-2175' into chore/electron-0.5.4

This commit is contained in:
Palash Debnath
2026-09-17 22:47:26 +05:30
55 changed files with 781 additions and 30 deletions
+1
View File
@@ -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)
+145 -12
View File
@@ -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
+12 -1
View File
@@ -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 models 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 profiles 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 den 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 unaltra.",
"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();
}
});
+14 -6
View File
@@ -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 });
}
};
+1
View File
@@ -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",
+4 -2
View File
@@ -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 =
+1
View File
@@ -2219,6 +2219,7 @@
"session_expired": "انتهت صلاحية جلسة الدبلجة هذه أو تم تنظيفها — أعد تحميل الفيديو لبدء مقطع جديد."
},
"tts_errors": {
"profile_language_rejected": "يستخدم الوضع التلقائي لغة ملف الصوت ({{language}})، التي لا يدعمها هذا المحرك. غيّر لغة الملف أو اختر لغة مدعومة أو بدّل المحرك.",
"enter_text": "الرجاء إدخال النص",
"upload_or_select": "قم بتحميل ملف صوتي أو حدد ملفًا صوتيًا",
"trim_hint": "الصوت هو {{duration}}s — قم بقصه إلى ≥{{max}}s للحصول على أفضل استنساخ",
+1
View File
@@ -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",
+1
View File
@@ -2890,6 +2890,7 @@
"upgrading_preview": "Upgrading {{count}} preview-quality segment(s) to full quality…"
},
"tts_errors": {
"profile_language_rejected": "Auto uses the voice profiles 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",
+1
View File
@@ -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.",
+1
View File
@@ -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",
+1
View File
@@ -2219,6 +2219,7 @@
"session_expired": "यह डब सत्र समाप्त हो गया है या साफ़ कर दिया गया है - नया शुरू करने के लिए अपना वीडियो पुनः अपलोड करें।"
},
"tts_errors": {
"profile_language_rejected": "ऑटो वॉइस प्रोफ़ाइल की भाषा ({{language}}) का उपयोग करता है, जिसे यह इंजन समर्थन नहीं करता। प्रोफ़ाइल की भाषा बदलें, समर्थित भाषा चुनें या इंजन बदलें।",
"enter_text": "कृपया पाठ दर्ज करें",
"upload_or_select": "एक ऑडियो अपलोड करें या एक वॉयस प्रोफ़ाइल चुनें",
"trim_hint": "ऑडियो {{duration}}s है - सर्वोत्तम क्लोनिंग के लिए ≤{{max}}s तक ट्रिम करें",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -2219,6 +2219,7 @@
"session_expired": "このダビング セッションは期限切れかクリーンアップされました。新しいビデオを開始するにはビデオを再アップロードしてください。"
},
"tts_errors": {
"profile_language_rejected": "自動では音声プロファイルの言語({{language}})が使われますが、このエンジンは対応していません。プロファイルの言語を変更するか、対応言語または別のエンジンを選択してください。",
"enter_text": "テキストを入力してください",
"upload_or_select": "音声をアップロードするか、音声プロファイルを選択してください",
"trim_hint": "オーディオは {{duration}}s — 最適なクローン作成のために ≤{{max}}s にトリミングします",
+1
View File
@@ -2600,6 +2600,7 @@
"session_expired": "이 더빙 세션이 만료되었거나 정리되었습니다. 새 동영상을 시작하려면 동영상을 다시 업로드하세요."
},
"tts_errors": {
"profile_language_rejected": "자동은 이 엔진이 지원하지 않는 음성 프로필의 언어({{language}})를 사용합니다. 프로필 언어를 변경하거나 지원되는 언어 또는 다른 엔진을 선택하세요.",
"enter_text": "텍스트를 입력해주세요",
"upload_or_select": "오디오 업로드 또는 음성 프로필 선택",
"trim_hint": "오디오는 {{duration}}s입니다. 최상의 복제를 위해 ≤{{max}}s로 조정하세요.",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -2219,6 +2219,7 @@
"session_expired": "Срок действия сеанса дублирования истек или он был удален — загрузите видео повторно, чтобы начать новое."
},
"tts_errors": {
"profile_language_rejected": "Авто использует язык голосового профиля ({{language}}), который этот движок не поддерживает. Измените язык профиля, выберите поддерживаемый язык или другой движок.",
"enter_text": "Пожалуйста, введите текст",
"upload_or_select": "Загрузите аудио или выберите голосовой профиль",
"trim_hint": "Звук составляет {{duration}}s — обрезайте до ≤{{max}}s для лучшего клонирования",
+1
View File
@@ -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",
+1
View File
@@ -2219,6 +2219,7 @@
"session_expired": "เซสชั่นพากย์นี้หมดอายุหรือถูกล้างออกไปแล้ว — อัปโหลดวิดีโอของคุณอีกครั้งเพื่อเริ่มวิดีโอใหม่"
},
"tts_errors": {
"profile_language_rejected": "อัตโนมัติใช้ภาษาของโปรไฟล์เสียง ({{language}}) ซึ่งเอนจินนี้ไม่รองรับ เปลี่ยนภาษาโปรไฟล์ เลือกภาษาที่รองรับ หรือเปลี่ยนเอนจิน",
"enter_text": "กรุณากรอกข้อความ",
"upload_or_select": "อัปโหลดเสียงหรือเลือกโปรไฟล์เสียง",
"trim_hint": "เสียงคือ {{duration}}s — ตัดเป็น ≤{{max}}s เพื่อการโคลนที่ดีที่สุด",
+1
View File
@@ -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",
+1
View File
@@ -2219,6 +2219,7 @@
"session_expired": "Цей сеанс дубляжу закінчився або його було очищено — повторно завантажте відео, щоб почати нове."
},
"tts_errors": {
"profile_language_rejected": "Авто використовує мову голосового профілю ({{language}}), яку цей рушій не підтримує. Змініть мову профілю, виберіть підтримувану мову або інший рушій.",
"enter_text": "Будь ласка, введіть текст",
"upload_or_select": "Завантажте аудіо або виберіть голосовий профіль",
"trim_hint": "Аудіо {{duration}}s — обріжте до ≤{{max}}s для найкращого клонування",
+1
View File
@@ -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",
+1
View File
@@ -2605,6 +2605,7 @@
"session_expired": "此配音会话已过期或已清理——请重新上传视频以开始新的会话。"
},
"tts_errors": {
"profile_language_rejected": "自动模式使用声音档案的语言({{language}}),但此引擎不支持该语言。请修改档案语言、选择支持的语言或更换引擎。",
"enter_text": "请输入文字",
"upload_or_select": "上传音频或选择语音配置文件",
"trim_hint": "音频为 {{duration}}s — 修剪至 ≤{{max}}s 以获得最佳克隆效果",
+1
View File
@@ -2219,6 +2219,7 @@
"session_expired": "此配音會話已過期或已清理 - 重新上傳您的影片以開始新的影片。"
},
"tts_errors": {
"profile_language_rejected": "自動模式使用聲音設定檔的語言({{language}}),但此引擎不支援該語言。請修改設定檔語言、選擇支援的語言或更換引擎。",
"enter_text": "請輸入文字",
"upload_or_select": "上傳音訊或選擇語音設定文件",
"trim_hint": "音訊為 {{duration}}s — 修剪至 ≤{{max}}s 以獲得最佳克隆效果",
+27
View File
@@ -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([
+11
View File
@@ -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 });
}
+9 -6
View File
@@ -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