fix(coach): dynamische Sprache (Text-Detection + App-Locale-Fallback)

LLM-Prompt (message.post + sos-stream):
- LANG_INSTRUCTIONS Map raus, ersetzt durch dynamische Instruktion
  'Reply in {detectedFromUser} ... fallback: {appLang}'
- Lyra matcht jetzt die Sprache der letzten User-Message (per
  detectLang Unicode-Detection); App-Locale ist nur noch Fallback
- Instruktion doppelt eingehängt (Anfang + Ende des System-Prompts)
  gegen recency bias bei langen deutschen Prompts

TTS (speak dispatcher + speak-cartesia + speak-elevenlabs):
- Kein 'de'-Default mehr für language. detectLang(text, locale) leitet
  Sprache primär aus dem Antwort-Text ab (Arabic/Cyrillic/CJK/Turkish-
  Letters), Locale als Fallback
- Cartesia + ElevenLabs: language/language_code nur senden wenn
  ableitbar, sonst Provider auto-detect statt erzwungenem 'de'
- speak-cartesia: sonic-2 → sonic-3 (Multi-Lang, war beim Dispatcher-
  Fix gestern vergessen worden)
- Google: en-US neutraler Fallback statt de-DE-Bias

Neu: server/utils/detect-lang.ts
This commit is contained in:
chahinebrini 2026-05-31 00:12:40 +02:00
parent b956b3b1fc
commit 685782b538
45 changed files with 118 additions and 179 deletions

View File

@ -1,67 +0,0 @@
#!/bin/bash
# Rebreak Native: Dev-Server (Metro) + iOS Build & Run
# Pendant zu apps/rebreak/dev-ios.sh, aber für React Native + Expo statt Capacitor.
set -e
cd "$(dirname "$0")"
echo "🧠 Rebreak Native iOS Dev"
echo "========================="
# Modi:
# ./dev-ios.sh → öffnet Xcode-Workspace (Default — User baut auf iPhone)
# ./dev-ios.sh --device → physisches iPhone via USB (CLI-Build + Auto-Launch)
# ./dev-ios.sh --simulator → iOS Simulator (schnellster Test-Loop, weniger Auth/Push-Test)
MODE="${1:-xcode}"
# Metro-Port wird NICHT mehr automatisch gekillt — falls du Metro in einem
# anderen Terminal laufen hast, würde das hier die Session zerstören.
# Falls Metro hängt: manuell `lsof -ti:8081 | xargs kill -9` ausführen.
# Cocoapods: läuft beim ersten Run automatisch via expo run:ios.
# Falls "objectVersion 70 not supported" Fehler unter Xcode 26 → CocoaPods updaten:
# sudo gem install cocoapods --pre
#
# Podfile-Fixes werden durch Config-Plugins automatisch reinpatcht:
# - plugins/with-fmt-consteval-fix.js → FMT_USE_CONSTEVAL=0 (Xcode 16 + RN 0.79)
# - plugins/with-rebreak-protection-ios.js → NEFilter Extension Target
# → expo prebuild --clean ist daher SAFE (Plugins regenerieren die Patches).
#
# Für radikalen Cache-Reset: ./clean-ios.sh
# Bei Build-Errors aus dem Nichts: ./clean-ios.sh --build
# 3. Run je nach Mode
case "$MODE" in
--xcode|xcode|"")
# Falls Xcode bereits mit Rebreak.xcodeproj (statt .xcworkspace) offen ist,
# schließe ihn erst. Sonst kriegst du zwei Project-Windows.
osascript -e 'tell application "Xcode" to close every window whose name contains "Rebreak.xcodeproj"' 2>/dev/null || true
echo "🔨 Opening Xcode-Workspace..."
open -a Xcode ios/Rebreak.xcworkspace
echo ""
echo "✅ Xcode offen — In Xcode:"
echo " 1. iPhone via USB anschließen (falls nicht schon)"
echo " 2. Top-Bar: iPhone als Run-Target wählen (links neben Play-Button)"
echo " 3. Cmd+R für Build & Run auf iPhone"
echo ""
echo " Metro: separat starten via 'pnpm expo start --dev-client' falls noch nicht läuft"
;;
--device|device)
echo "📱 Building für physisches iPhone (USB)..."
echo " Erste Mal: Xcode wird geöffnet zum Signing-Setup."
pnpm expo run:ios --device
;;
--simulator|simulator)
echo "📱 Building für iOS Simulator..."
pnpm expo run:ios
;;
*)
echo "Unknown mode: $MODE"
echo "Usage: ./dev-ios.sh [--xcode|--device|--simulator]"
exit 1
;;
esac

View File

@ -1,41 +0,0 @@
#!/bin/bash
# Rebreak Native — Dev auf physischem iPhone (kein Simulator).
#
# Was es macht:
# - Killt alte Metro-Instanzen auf 8081 (saubere Session)
# - Startet Metro mit --host lan damit iPhone via WiFi connecten kann
# - Druckt deine LAN-IP zum manuellen Eintragen falls Bonjour failt
#
# Auf iPhone:
# 1. Mac + iPhone müssen im SELBEN WiFi sein
# 2. App komplett killen (App-Switcher → swipe up)
# 3. App neu öffnen — dev-client sollte Metro automatisch finden
# 4. Falls nicht: dev-launcher → "Enter URL manually" → http://<LAN-IP>:8081
#
# WICHTIG: Im Metro-Terminal NICHT `i` drücken — sonst startet Simulator!
# Nur `r` für Reload.
set -e
cd "$(dirname "$0")"
# FamilyControls-Flag: default ON für lokale Dev-Builds, override via
# REBREAK_ENABLE_FAMILY_CONTROLS=0 ./dev-iphone.sh
# wenn man bewusst ohne FC testen will (z.B. TestFlight-Parity-Check).
# app.config.ts liest die Var um Constants.expoConfig.extra.familyControlsEnabled
# zu setzen → lib/protection.ts.FAMILY_CONTROLS_AVAILABLE → Blocker-UI.
export REBREAK_ENABLE_FAMILY_CONTROLS="${REBREAK_ENABLE_FAMILY_CONTROLS:-1}"
echo "🧹 Killing old Metro on port 8081..."
lsof -ti:8081 | xargs kill -9 2>/dev/null || true
echo ""
echo "📡 Mac LAN-IP für iPhone:"
ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo " (kein WiFi/Ethernet detected)"
echo ""
echo " Falls dev-client Metro nicht automatisch findet:"
echo " im iPhone-Launcher → 'Enter URL manually' → http://<LAN-IP>:8081"
echo ""
echo "🚀 Starting Metro with --host lan..."
echo " (Drücke 'r' für Reload, NICHT 'i' — sonst startet Simulator!)"
echo ""
exec pnpm expo start --host lan --clear --dev-client

View File

@ -1,34 +0,0 @@
#!/bin/bash
# Rebreak Native: Metro Bundler (Kill + Clean Restart).
# Killt jede laufende Instanz auf 8081, dann frischer Start mit --clear-Cache.
# Aufruf: ./metro.sh (mit cache-clear, Default)
# ./metro.sh --keep (ohne --clear, schneller wenn keine Dependency-Changes)
set -e
cd "$(dirname "$0")"
echo "🚇 Metro Bundler"
echo "================"
# 1) Existierende Metro-Instanz auf Port 8081 killen
PIDS=$(lsof -iTCP:8081 -sTCP:LISTEN -n -P 2>/dev/null | awk 'NR>1 {print $2}' | sort -u)
if [ -n "$PIDS" ]; then
echo "→ Killing existing Metro on :8081 (PIDs: $PIDS)"
echo "$PIDS" | xargs kill -9 2>/dev/null || true
sleep 1
else
echo "→ Kein Metro auf :8081 aktiv"
fi
# 2) Stale node-Prozesse die expo CLI gestartet haben (Belt-and-Suspenders)
pkill -f "expo start" 2>/dev/null || true
pkill -f "react-native/cli/build" 2>/dev/null || true
# 3) Start
if [ "$1" = "--keep" ]; then
echo "→ Starte Metro (Cache behalten)"
exec npx expo start
else
echo "→ Starte Metro mit --clear (Haste-Map + Transformer-Cache reset)"
exec npx expo start --clear
fi

View File

@ -305,6 +305,7 @@ import { usePrisma } from "../../utils/prisma";
import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory"; import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory";
import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract"; import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract";
import { stripMarkdown } from "../../utils/strip-markdown"; import { stripMarkdown } from "../../utils/strip-markdown";
import { detectLang } from "../../utils/detect-lang";
/** /**
* Lyra-Plan-Beschreibung dynamisch aus PLAN_LIMITS generieren. * Lyra-Plan-Beschreibung dynamisch aus PLAN_LIMITS generieren.
@ -476,17 +477,27 @@ export default defineEventHandler(async (event) => {
generatePlanDetails(), generatePlanDetails(),
); );
// Sprach-Instruktion // Sprach-Instruktion: dynamisch nach (a) Sprache der letzten User-Message
const LANG_INSTRUCTIONS: Record<string, string> = { // und (b) App-Sprach-Einstellung als Fallback. Vorher war's hartcodiert
de: "Antworte IMMER auf Deutsch, egal in welcher Sprache der User schreibt.", // "antworte IMMER auf X" — wenn User mitten im Chat die Sprache wechselte,
en: "Always respond in English, regardless of what language the user writes in.", // antwortete Lyra weiter in der App-Sprache. Jetzt: match user.
tr: "Her zaman Türkçe yanıt ver, kullanıcı hangi dilde yazarsa yazsın.", const LANG_NAMES: Record<string, string> = {
ar: "رد دائماً باللغة العربية، بغض النظر عن اللغة التي يكتب بها المستخدم.", de: "German", en: "English", tr: "Turkish", ar: "Arabic",
fr: "Réponds toujours en français, quelle que soit la langue dans laquelle l'utilisateur écrit.", fr: "French", es: "Spanish", it: "Italian", pt: "Portuguese",
ru: "Russian", ja: "Japanese", ko: "Korean", zh: "Chinese",
he: "Hebrew", th: "Thai",
}; };
const langInstruction = const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
LANG_INSTRUCTIONS[locale ?? "de"] ?? LANG_INSTRUCTIONS.de; const detectedFromUser = detectLang(lastUserMsg?.content ?? "", locale);
systemPrompt = `${langInstruction}\n\n${systemPrompt}`; const appLangCode = locale ? locale.split("-")[0].toLowerCase() : null;
const appLangName =
(appLangCode && LANG_NAMES[appLangCode]) || "the user's language";
const detectedName =
(detectedFromUser && LANG_NAMES[detectedFromUser]) || null;
const langInstruction = detectedName
? `LANGUAGE: Reply in ${detectedName} — match the language the user just wrote in. If they switch languages, switch with them. App default fallback: ${appLangName}.`
: `LANGUAGE: Reply in ${appLangName} (the user's app language). If the user clearly writes in another language, match theirs.`;
systemPrompt = `${langInstruction}\n\n${systemPrompt}\n\n${langInstruction}`;
// Plan-Kontext injizieren // Plan-Kontext injizieren
const PLAN_LABELS: Record<string, string> = { const PLAN_LABELS: Record<string, string> = {

View File

@ -23,6 +23,7 @@ import { COACH_SYSTEM_PROMPT } from "./message.post";
import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory"; import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory";
import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract"; import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract";
import { getProfile } from "../../db/profile"; import { getProfile } from "../../db/profile";
import { detectLang } from "../../utils/detect-lang";
const SOS_INSTRUCTION = `\n\nDU BEFINDEST DICH IN EINEM AKUTEN SOS-MOMENT. WICHTIGE REGELN: const SOS_INSTRUCTION = `\n\nDU BEFINDEST DICH IN EINEM AKUTEN SOS-MOMENT. WICHTIGE REGELN:
- Antworte als REINER TEXT, KEINE JSON-Wrapper, KEIN Markdown, KEINE Aufzählungen. - Antworte als REINER TEXT, KEINE JSON-Wrapper, KEIN Markdown, KEINE Aufzählungen.
@ -89,15 +90,25 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
// System-Prompt: Coach-Basis + SOS-Streaming-Regeln // Sprach-Instruktion: dynamisch nach User-Message-Sprache + App-Locale-
const LANG: Record<string, string> = { // Fallback. Vorher hardcoded "antworte immer auf X" — ignorierte
de: "Antworte IMMER auf Deutsch.", // Sprach-Wechsel mitten in der Session.
en: "Always respond in English.", const LANG_NAMES: Record<string, string> = {
tr: "Her zaman Türkçe yanıt ver.", de: "German", en: "English", tr: "Turkish", ar: "Arabic",
ar: "رد دائماً باللغة العربية.", fr: "French", es: "Spanish", it: "Italian", pt: "Portuguese",
fr: "Réponds toujours en français.", ru: "Russian", ja: "Japanese", ko: "Korean", zh: "Chinese",
he: "Hebrew", th: "Thai",
}; };
const lang = LANG[locale ?? "de"] ?? LANG.de; const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
const detectedFromUser = detectLang(lastUserMsg?.content ?? "", locale);
const appLangCode = locale ? locale.split("-")[0].toLowerCase() : null;
const appLangName =
(appLangCode && LANG_NAMES[appLangCode]) || "the user's language";
const detectedName =
(detectedFromUser && LANG_NAMES[detectedFromUser]) || null;
const lang = detectedName
? `LANGUAGE: Reply in ${detectedName} — match the language the user just wrote in. If they switch languages, switch with them. App default fallback: ${appLangName}.`
: `LANGUAGE: Reply in ${appLangName} (the user's app language). If the user clearly writes in another language, match theirs.`;
// Memory-Injection: Lyra-Erinnerungen aus früheren Sessions laden // Memory-Injection: Lyra-Erinnerungen aus früheren Sessions laden
let memoryBlock = ""; let memoryBlock = "";
@ -189,7 +200,7 @@ export default defineEventHandler(async (event) => {
console.error("[sos-stream] profile load (non-fatal):", e); console.error("[sos-stream] profile load (non-fatal):", e);
} }
const systemPrompt = `${nicknamePrefix}${demographicsBlock}${memoryBlock}${lang}\n\n${COACH_SYSTEM_PROMPT.replace("{{PLAN_DETAILS}}", "")}${SOS_INSTRUCTION}`; const systemPrompt = `${nicknamePrefix}${demographicsBlock}${memoryBlock}${lang}\n\n${COACH_SYSTEM_PROMPT.replace("{{PLAN_DETAILS}}", "")}${SOS_INSTRUCTION}\n\n${lang}`;
// Erste Nachricht muss user sein // Erste Nachricht muss user sein
const firstUserIdx = messages.findIndex((m) => m.role === "user"); const firstUserIdx = messages.findIndex((m) => m.role === "user");

View File

@ -1,22 +1,28 @@
/** /**
* POST /api/coach/speak-cartesia * POST /api/coach/speak-cartesia
* Cartesia Sonic-2 schnellstes TTS (~75ms first-byte), native German. * Cartesia Sonic-3 schnellstes TTS (~75ms first-byte), 42 Sprachen multilingual.
* *
* Returns audio/mpeg. Voice via runtimeConfig.cartesiaVoiceId * Returns audio/mpeg. Voice via runtimeConfig.cartesiaVoiceId
* (Fallback `b9de4a89-2257-424b-94c2-db18ba68c81a` wenn unset). * (Fallback `b9de4a89-2257-424b-94c2-db18ba68c81a` wenn unset).
*/ */
import { detectLang } from "../../utils/detect-lang";
const FALLBACK_VOICE_ID = "b9de4a89-2257-424b-94c2-db18ba68c81a"; const FALLBACK_VOICE_ID = "b9de4a89-2257-424b-94c2-db18ba68c81a";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireUser(event); await requireUser(event);
const body = await readBody(event); const body = await readBody(event);
const { text } = body as { text: string }; const { text, locale } = body as { text: string; locale?: string };
if (!text?.trim()) { if (!text?.trim()) {
throw createError({ statusCode: 400, message: "text fehlt" }); throw createError({ statusCode: 400, message: "text fehlt" });
} }
// Sprache dynamisch: Text-Script-Detection > App-Locale-Hint > null (Provider
// auto-detect). Kein "de"-Default mehr, sonst klingt arabischer Text deutsch.
const lang = detectLang(text, locale);
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const key = const key =
(config.cartesiaApiKey as string) || process.env.CARTESIA_API_KEY || ""; (config.cartesiaApiKey as string) || process.env.CARTESIA_API_KEY || "";
@ -40,7 +46,8 @@ export default defineEventHandler(async (event) => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
model_id: "sonic-2", // sonic-3 unterstützt 42 Sprachen inkl. ar/tr (sonic-2 = nur de/en).
model_id: "sonic-3",
transcript: text.slice(0, 4096), transcript: text.slice(0, 4096),
voice: { mode: "id", id: voiceId }, voice: { mode: "id", id: voiceId },
output_format: { output_format: {
@ -48,7 +55,9 @@ export default defineEventHandler(async (event) => {
sample_rate: 22050, sample_rate: 22050,
bit_rate: 64000, bit_rate: 64000,
}, },
language: "de", // language nur setzen wenn aus Detection oder Locale ableitbar —
// sonst Sonic-3 auto-detecten lassen.
...(lang ? { language: lang } : {}),
}), }),
}); });

View File

@ -6,18 +6,24 @@
* Returns audio/mpeg. Voice ist deterministisch konstant über mehrere Calls * Returns audio/mpeg. Voice ist deterministisch konstant über mehrere Calls
* identisch zu Gemini-Verhalten, kein Mode-Switch wie bei gpt-4o-mini-tts. * identisch zu Gemini-Verhalten, kein Mode-Switch wie bei gpt-4o-mini-tts.
*/ */
import { detectLang } from "../../utils/detect-lang";
const FALLBACK_VOICE_ID = "kdmDKE6EkgrWrrykO9Qt"; // Alexandra const FALLBACK_VOICE_ID = "kdmDKE6EkgrWrrykO9Qt"; // Alexandra
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireUser(event); await requireUser(event);
const body = await readBody(event); const body = await readBody(event);
const { text } = body as { text: string }; const { text, locale } = body as { text: string; locale?: string };
if (!text?.trim()) { if (!text?.trim()) {
throw createError({ statusCode: 400, message: "text fehlt" }); throw createError({ statusCode: 400, message: "text fehlt" });
} }
// Sprache dynamisch ableiten (Text-Script > Locale-Hint > null) statt
// hardcoded "de" — sonst landen arabische Texte in deutscher Phonetik.
const lang = detectLang(text, locale);
const config = useRuntimeConfig(); const config = useRuntimeConfig();
// Fallback chain: runtimeConfig (Nuxt build-time) → process.env (runtime injection // Fallback chain: runtimeConfig (Nuxt build-time) → process.env (runtime injection
// via Infisical at pm2-start). Stellt sicher dass auch dann ein Key vorhanden ist // via Infisical at pm2-start). Stellt sicher dass auch dann ein Key vorhanden ist
@ -66,6 +72,9 @@ export default defineEventHandler(async (event) => {
// Turbo v2.5: ~50% schneller als multilingual_v2, marginal niedrigere // Turbo v2.5: ~50% schneller als multilingual_v2, marginal niedrigere
// Quality — Trade-off lohnt sich für SOS (latency > Studio-Polish). // Quality — Trade-off lohnt sich für SOS (latency > Studio-Polish).
model_id: "eleven_turbo_v2_5", model_id: "eleven_turbo_v2_5",
// language_code nur explizit wenn wir ihn aus Detection/Locale
// ableiten konnten — sonst ElevenLabs auto-detecten lassen.
...(lang ? { language_code: lang } : {}),
voice_settings: { voice_settings: {
stability: 0.5, stability: 0.5,
similarity_boost: 0.75, similarity_boost: 0.75,

View File

@ -1,6 +1,7 @@
import type { H3Event } from "h3"; import type { H3Event } from "h3";
import type { VoiceConfig } from "../../utils/plan-features"; import type { VoiceConfig } from "../../utils/plan-features";
import { getPlanLimits } from "../../utils/plan-features"; import { getPlanLimits } from "../../utils/plan-features";
import { detectLang } from "../../utils/detect-lang";
import { import {
getRemainingVoiceQuota, getRemainingVoiceQuota,
consumeVoiceQuota, consumeVoiceQuota,
@ -42,9 +43,10 @@ export default defineEventHandler(async (event) => {
} }
const trimmed = text.slice(0, 4096); const trimmed = text.slice(0, 4096);
// i18n-Locale (z.B. "ar", "de-DE") → 2-Buchstaben-Basis-Sprachcode für die // Sprache dynamisch: erst Text-Script-Detection (Arabic/Cyrillic/CJK/Turkish
// Provider. Ohne das sprach Lyra arabischen Text mit deutscher Stimme/Phonetik. // letters …), Fallback auf App-Locale aus body. Kein "de"-Default mehr —
const lang = (locale ?? "de").split("-")[0].toLowerCase(); // wenn beides leer ist, lassen wir den Provider auto-detecten.
const lang = detectLang(trimmed, locale);
// ─── Load profile + plan ──────────────────────────────────────────────── // ─── Load profile + plan ────────────────────────────────────────────────
const db = usePrisma(); const db = usePrisma();
@ -102,7 +104,7 @@ async function speakGoogle(
voiceCfg: VoiceConfig, voiceCfg: VoiceConfig,
userId: string, userId: string,
plan: string, plan: string,
lang: string, lang: string | null,
) { ) {
const key = (config.googleApiKey as string) || process.env.GOOGLE_API_KEY || ""; const key = (config.googleApiKey as string) || process.env.GOOGLE_API_KEY || "";
if (!key) { if (!key) {
@ -117,7 +119,9 @@ async function speakGoogle(
de: "de-DE", en: "en-US", fr: "fr-FR", ar: "ar-XA", de: "de-DE", en: "en-US", fr: "fr-FR", ar: "ar-XA",
tr: "tr-TR", es: "es-ES", pt: "pt-PT", it: "it-IT", tr: "tr-TR", es: "es-ES", pt: "pt-PT", it: "it-IT",
}; };
const languageCode = GOOGLE_LANG[lang] ?? "de-DE"; // Google verlangt zwingend languageCode — wenn weder Detection noch Locale
// greifen, neutraler en-US-Fallback statt de-DE-Bias.
const languageCode = (lang && GOOGLE_LANG[lang]) || "en-US";
const voiceName = lang === "de" ? (voiceCfg.model ?? "de-DE-Neural2-F") : undefined; const voiceName = lang === "de" ? (voiceCfg.model ?? "de-DE-Neural2-F") : undefined;
const response = await fetch( const response = await fetch(
@ -171,7 +175,7 @@ async function speakCartesia(
voiceCfg: VoiceConfig, voiceCfg: VoiceConfig,
userId: string, userId: string,
plan: string, plan: string,
lang: string, lang: string | null,
) { ) {
const key = (config.cartesiaApiKey as string) || process.env.CARTESIA_API_KEY || ""; const key = (config.cartesiaApiKey as string) || process.env.CARTESIA_API_KEY || "";
if (!key) { if (!key) {
@ -201,9 +205,9 @@ async function speakCartesia(
sample_rate: 22050, sample_rate: 22050,
bit_rate: 64000, bit_rate: 64000,
}, },
// Sonic-3 unterstützt 42 Sprachen inkl. ar — language aus User-Locale // Sonic-3 unterstützt 42 Sprachen — wenn Detection greift, language
// statt hardcoded "de", sonst klingt arabischer Text deutsch-phonetisch. // explizit setzen, sonst Provider auto-detecten lassen.
language: lang, ...(lang ? { language: lang } : {}),
}), }),
}); });
@ -231,7 +235,7 @@ async function speakElevenLabs(
userId: string, userId: string,
plan: string, plan: string,
userLyraVoiceId: string | null = null, userLyraVoiceId: string | null = null,
lang: string = "de", lang: string | null = null,
) { ) {
const key = const key =
(config.elevenlabsApiKey as string) || process.env.ELEVENLABS_API_KEY || ""; (config.elevenlabsApiKey as string) || process.env.ELEVENLABS_API_KEY || "";
@ -263,9 +267,10 @@ async function speakElevenLabs(
text, text,
model_id: modelId, model_id: modelId,
// Turbo v2.5 ist multilingual (32 Sprachen inkl. ar) — dieselbe Stimme // Turbo v2.5 ist multilingual (32 Sprachen inkl. ar) — dieselbe Stimme
// spricht die Zielsprache. language_code explizit setzen statt nur // spricht die Zielsprache. language_code nur explizit setzen wenn wir
// Auto-Detect, damit kurze Texte zuverlässig in der User-Sprache landen. // ihn aus Detection/Locale ableiten konnten; sonst ElevenLabs
language_code: lang, // auto-detecten lassen statt einen falschen Code aufzuzwingen.
...(lang ? { language_code: lang } : {}),
voice_settings: { voice_settings: {
stability: 0.5, stability: 0.5,
similarity_boost: 0.75, similarity_boost: 0.75,

View File

@ -0,0 +1,36 @@
/**
* Detect language from text using Unicode script ranges.
*
* Non-Latin scripts are detected reliably from a single character. For Latin
* scripts (de/en/fr/tr/es/it/pt ) we fall back to the supplied locale-hint,
* since distinguishing them needs a real NLP library and the user-facing
* App-Sprache is a perfectly good signal.
*
* Returns a 2-letter ISO code, or null if neither detection nor hint apply.
*/
export function detectLang(
text: string,
localeHint?: string | null,
): string | null {
if (text) {
// Sample a window — first 300 chars is plenty; counting script hits is
// cheaper than scanning multi-KB Lyra-Antworten.
const sample = text.slice(0, 300);
if (/[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]/.test(sample)) return "ar"; // Arabic
if (/[\u0400-\u04FF]/.test(sample)) return "ru"; // Cyrillic
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(sample)) return "ja"; // Hiragana/Katakana
if (/[\uAC00-\uD7AF]/.test(sample)) return "ko"; // Hangul
if (/[\u4E00-\u9FFF]/.test(sample)) return "zh"; // CJK Unified Ideographs
if (/[\u0590-\u05FF]/.test(sample)) return "he"; // Hebrew
if (/[\u0E00-\u0E7F]/.test(sample)) return "th"; // Thai
// Turkish-specific Latin letters — strong hint without an NLP lib.
if (/[ğĞıİşŞ]/.test(sample)) return "tr";
}
if (localeHint) {
const base = localeHint.split("-")[0].toLowerCase();
if (base) return base;
}
return null;
}