fix(generate): localize terminal language refusals across streaming paths

This commit is contained in:
Palash Debnath
2026-09-17 20:21:30 +05:30
parent ed9e40253c
commit a222974568
54 changed files with 242 additions and 25 deletions
+37 -11
View File
@@ -1106,6 +1106,19 @@ def _profile_language_rejection_detail(exc: BaseException, language) -> str:
)
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.
@@ -1121,6 +1134,8 @@ def _language_rejection_http_error(exc: BaseException, language, *, from_profile
_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)
@@ -1961,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
@@ -1980,13 +1999,16 @@ async def generate_speech(
except gpu_gateway.RemoteJobFailed as e:
logger.error("Remote generate failed on %s", _target_label)
from core.public_errors import stream_failure
yield _line({
"type": "error",
**stream_failure("generation_failed"),
"retryable": True,
"target_label": e.worker_label or _target_label,
"hint": e.hint,
})
if _is_language_rejection(str(e)):
yield _line({"type": "error", **_language_rejection_payload(
e, language, from_profile=language_from_profile,
)})
else:
yield _line({
"type": "error", **stream_failure("generation_failed"),
"retryable": True, "target_label": e.worker_label or _target_label,
"hint": e.hint,
})
except Exception as exc:
# Mid-job remote failure is NOT quietly redone here: the client
# treats a retryable error as "surface it", so the user decides
@@ -2295,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
+4
View File
@@ -85,3 +85,7 @@ 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.
@@ -2529,6 +2529,7 @@
"recorder_unsupported": "لا يستطيع هذا النظام ترميز صوت الميكروفون."
},
"tts_errors": {
"profile_language_rejected": "يستخدم الوضع التلقائي لغة ملف الصوت ({{language}})، التي لا يدعمها هذا المحرك. غيّر لغة الملف أو اختر لغة مدعومة أو بدّل المحرك.",
"generation_in_progress": "هناك عملية توليد قيد التشغيل بالفعل — انتظر حتى تنتهي قبل بدء عملية أخرى.",
"error_prefix": "خطأ: {{message}}",
"ignored_duplicate": "تم التجاهل (تم تعيين الفئة بالفعل): {{items}}",
@@ -2521,6 +2521,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.",
@@ -2523,6 +2523,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.",
@@ -2523,6 +2523,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}}",
@@ -2521,6 +2521,7 @@
"recorder_unsupported": "यह सिस्टम माइक्रोफ़ोन ऑडियो एन्कोड नहीं कर सकता।"
},
"tts_errors": {
"profile_language_rejected": "ऑटो वॉइस प्रोफ़ाइल की भाषा ({{language}}) का उपयोग करता है, जिसे यह इंजन समर्थन नहीं करता। प्रोफ़ाइल की भाषा बदलें, समर्थित भाषा चुनें या इंजन बदलें।",
"generation_in_progress": "एक जनरेशन पहले से चल रहा है — दूसरा शुरू करने से पहले उसके पूरा होने की प्रतीक्षा करें।",
"error_prefix": "त्रुटि: {{message}}",
"ignored_duplicate": "अनदेखा (श्रेणी पहले से ही सेट): {{items}}",
@@ -2521,6 +2521,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}}",
@@ -2523,6 +2523,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}}",
@@ -2521,6 +2521,7 @@
"recorder_unsupported": "このシステムではマイク音声をエンコードできません。"
},
"tts_errors": {
"profile_language_rejected": "自動では音声プロファイルの言語({{language}})が使われますが、このエンジンは対応していません。プロファイルの言語を変更するか、対応言語または別のエンジンを選択してください。",
"generation_in_progress": "生成がすでに実行中です。完了してから次の生成を開始してください。",
"error_prefix": "エラー: {{message}}",
"ignored_duplicate": "無視 (カテゴリはすでに設定されています): {{items}}",
@@ -2521,6 +2521,7 @@
"recorder_unsupported": "이 시스템에서는 마이크 오디오를 인코딩할 수 없습니다."
},
"tts_errors": {
"profile_language_rejected": "자동은 이 엔진이 지원하지 않는 음성 프로필의 언어({{language}})를 사용합니다. 프로필 언어를 변경하거나 지원되는 언어 또는 다른 엔진을 선택하세요.",
"generation_in_progress": "이미 생성 작업이 실행 중입니다. 완료된 후 새 작업을 시작하세요.",
"error_prefix": "오류: {{message}}",
"ignored_duplicate": "무시됨(카테고리가 이미 설정됨): {{items}}",
@@ -2521,6 +2521,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}}",
@@ -2525,6 +2525,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}}",
@@ -2523,6 +2523,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}}",
@@ -2525,6 +2525,7 @@
"recorder_unsupported": "Эта система не может кодировать звук с микрофона."
},
"tts_errors": {
"profile_language_rejected": "Авто использует язык голосового профиля ({{language}}), который этот движок не поддерживает. Измените язык профиля, выберите поддерживаемый язык или другой движок.",
"generation_in_progress": "Генерация уже выполняется — дождитесь её завершения, прежде чем запускать следующую.",
"error_prefix": "Ошибка: {{message}}",
"ignored_duplicate": "Игнорируется (категория уже установлена): {{items}}",
@@ -2521,6 +2521,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}}",
@@ -2521,6 +2521,7 @@
"recorder_unsupported": "ระบบนี้ไม่สามารถเข้ารหัสเสียงจากไมโครโฟนได้"
},
"tts_errors": {
"profile_language_rejected": "อัตโนมัติใช้ภาษาของโปรไฟล์เสียง ({{language}}) ซึ่งเอนจินนี้ไม่รองรับ เปลี่ยนภาษาโปรไฟล์ เลือกภาษาที่รองรับ หรือเปลี่ยนเอนจิน",
"generation_in_progress": "มีการสร้างเสียงกำลังทำงานอยู่ — รอให้เสร็จก่อนเริ่มงานใหม่",
"error_prefix": "ข้อผิดพลาด: {{message}}",
"ignored_duplicate": "ละเว้น (หมวดหมู่ที่ตั้งไว้แล้ว): {{items}}",
@@ -2521,6 +2521,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}}",
@@ -2525,6 +2525,7 @@
"recorder_unsupported": "Ця система не може кодувати звук із мікрофона."
},
"tts_errors": {
"profile_language_rejected": "Авто використовує мову голосового профілю ({{language}}), яку цей рушій не підтримує. Змініть мову профілю, виберіть підтримувану мову або інший рушій.",
"generation_in_progress": "Генерація вже виконується — дочекайтеся її завершення, перш ніж запускати наступну.",
"error_prefix": "Помилка: {{message}}",
"ignored_duplicate": "Проігноровано (категорію вже встановлено): {{items}}",
@@ -2521,6 +2521,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}}",
@@ -2525,6 +2525,7 @@
"recorder_unsupported": "此系统无法编码麦克风音频。"
},
"tts_errors": {
"profile_language_rejected": "自动模式使用声音档案的语言({{language}}),但此引擎不支持该语言。请修改档案语言、选择支持的语言或更换引擎。",
"generation_in_progress": "已有生成任务正在运行,请等待其完成后再启动新任务。",
"error_prefix": "错误:{{message}}",
"ignored_duplicate": "忽略(类别已设置):{{items}}",
@@ -2521,6 +2521,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,
@@ -156,6 +156,10 @@ def test_a_profile_supplied_language_names_the_profile_not_the_picker(
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
@@ -197,6 +201,10 @@ def test_an_explicitly_requested_language_is_not_blamed_on_the_profile(
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
@@ -236,6 +244,10 @@ def test_a_non_language_failure_under_a_profile_is_untouched(
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()
@@ -300,6 +312,10 @@ def test_a_generic_rejection_is_quoted_once_not_twice(client, monkeypatch, persi
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
@@ -346,6 +362,10 @@ def test_a_remote_language_refusal_is_a_400_not_a_retryable_503(
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
@@ -417,3 +437,22 @@ def test_an_explicit_auto_is_still_filled_from_the_profile():
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