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
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 218 KiB |
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -305,6 +305,7 @@ import { usePrisma } from "../../utils/prisma";
|
||||
import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory";
|
||||
import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract";
|
||||
import { stripMarkdown } from "../../utils/strip-markdown";
|
||||
import { detectLang } from "../../utils/detect-lang";
|
||||
|
||||
/**
|
||||
* Lyra-Plan-Beschreibung dynamisch aus PLAN_LIMITS generieren.
|
||||
@ -476,17 +477,27 @@ export default defineEventHandler(async (event) => {
|
||||
generatePlanDetails(),
|
||||
);
|
||||
|
||||
// Sprach-Instruktion
|
||||
const LANG_INSTRUCTIONS: Record<string, string> = {
|
||||
de: "Antworte IMMER auf Deutsch, egal in welcher Sprache der User schreibt.",
|
||||
en: "Always respond in English, regardless of what language the user writes in.",
|
||||
tr: "Her zaman Türkçe yanıt ver, kullanıcı hangi dilde yazarsa yazsın.",
|
||||
ar: "رد دائماً باللغة العربية، بغض النظر عن اللغة التي يكتب بها المستخدم.",
|
||||
fr: "Réponds toujours en français, quelle que soit la langue dans laquelle l'utilisateur écrit.",
|
||||
// Sprach-Instruktion: dynamisch nach (a) Sprache der letzten User-Message
|
||||
// und (b) App-Sprach-Einstellung als Fallback. Vorher war's hartcodiert
|
||||
// "antworte IMMER auf X" — wenn User mitten im Chat die Sprache wechselte,
|
||||
// antwortete Lyra weiter in der App-Sprache. Jetzt: match user.
|
||||
const LANG_NAMES: Record<string, string> = {
|
||||
de: "German", en: "English", tr: "Turkish", ar: "Arabic",
|
||||
fr: "French", es: "Spanish", it: "Italian", pt: "Portuguese",
|
||||
ru: "Russian", ja: "Japanese", ko: "Korean", zh: "Chinese",
|
||||
he: "Hebrew", th: "Thai",
|
||||
};
|
||||
const langInstruction =
|
||||
LANG_INSTRUCTIONS[locale ?? "de"] ?? LANG_INSTRUCTIONS.de;
|
||||
systemPrompt = `${langInstruction}\n\n${systemPrompt}`;
|
||||
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 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
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
|
||||
@ -23,6 +23,7 @@ import { COACH_SYSTEM_PROMPT } from "./message.post";
|
||||
import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory";
|
||||
import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract";
|
||||
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:
|
||||
- Antworte als REINER TEXT, KEINE JSON-Wrapper, KEIN Markdown, KEINE Aufzählungen.
|
||||
@ -89,15 +90,25 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
// System-Prompt: Coach-Basis + SOS-Streaming-Regeln
|
||||
const LANG: Record<string, string> = {
|
||||
de: "Antworte IMMER auf Deutsch.",
|
||||
en: "Always respond in English.",
|
||||
tr: "Her zaman Türkçe yanıt ver.",
|
||||
ar: "رد دائماً باللغة العربية.",
|
||||
fr: "Réponds toujours en français.",
|
||||
// Sprach-Instruktion: dynamisch nach User-Message-Sprache + App-Locale-
|
||||
// Fallback. Vorher hardcoded "antworte immer auf X" — ignorierte
|
||||
// Sprach-Wechsel mitten in der Session.
|
||||
const LANG_NAMES: Record<string, string> = {
|
||||
de: "German", en: "English", tr: "Turkish", ar: "Arabic",
|
||||
fr: "French", es: "Spanish", it: "Italian", pt: "Portuguese",
|
||||
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
|
||||
let memoryBlock = "";
|
||||
@ -189,7 +200,7 @@ export default defineEventHandler(async (event) => {
|
||||
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
|
||||
const firstUserIdx = messages.findIndex((m) => m.role === "user");
|
||||
|
||||
@ -1,22 +1,28 @@
|
||||
/**
|
||||
* 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
|
||||
* (Fallback `b9de4a89-2257-424b-94c2-db18ba68c81a` wenn unset).
|
||||
*/
|
||||
import { detectLang } from "../../utils/detect-lang";
|
||||
|
||||
const FALLBACK_VOICE_ID = "b9de4a89-2257-424b-94c2-db18ba68c81a";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireUser(event);
|
||||
|
||||
const body = await readBody(event);
|
||||
const { text } = body as { text: string };
|
||||
const { text, locale } = body as { text: string; locale?: string };
|
||||
|
||||
if (!text?.trim()) {
|
||||
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 key =
|
||||
(config.cartesiaApiKey as string) || process.env.CARTESIA_API_KEY || "";
|
||||
@ -40,7 +46,8 @@ export default defineEventHandler(async (event) => {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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),
|
||||
voice: { mode: "id", id: voiceId },
|
||||
output_format: {
|
||||
@ -48,7 +55,9 @@ export default defineEventHandler(async (event) => {
|
||||
sample_rate: 22050,
|
||||
bit_rate: 64000,
|
||||
},
|
||||
language: "de",
|
||||
// language nur setzen wenn aus Detection oder Locale ableitbar —
|
||||
// sonst Sonic-3 auto-detecten lassen.
|
||||
...(lang ? { language: lang } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -6,18 +6,24 @@
|
||||
* Returns audio/mpeg. Voice ist deterministisch konstant über mehrere Calls
|
||||
* — 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
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireUser(event);
|
||||
|
||||
const body = await readBody(event);
|
||||
const { text } = body as { text: string };
|
||||
const { text, locale } = body as { text: string; locale?: string };
|
||||
|
||||
if (!text?.trim()) {
|
||||
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();
|
||||
// Fallback chain: runtimeConfig (Nuxt build-time) → process.env (runtime injection
|
||||
// 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
|
||||
// Quality — Trade-off lohnt sich für SOS (latency > Studio-Polish).
|
||||
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: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.75,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { H3Event } from "h3";
|
||||
import type { VoiceConfig } from "../../utils/plan-features";
|
||||
import { getPlanLimits } from "../../utils/plan-features";
|
||||
import { detectLang } from "../../utils/detect-lang";
|
||||
import {
|
||||
getRemainingVoiceQuota,
|
||||
consumeVoiceQuota,
|
||||
@ -42,9 +43,10 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const trimmed = text.slice(0, 4096);
|
||||
// i18n-Locale (z.B. "ar", "de-DE") → 2-Buchstaben-Basis-Sprachcode für die
|
||||
// Provider. Ohne das sprach Lyra arabischen Text mit deutscher Stimme/Phonetik.
|
||||
const lang = (locale ?? "de").split("-")[0].toLowerCase();
|
||||
// Sprache dynamisch: erst Text-Script-Detection (Arabic/Cyrillic/CJK/Turkish
|
||||
// letters …), Fallback auf App-Locale aus body. Kein "de"-Default mehr —
|
||||
// wenn beides leer ist, lassen wir den Provider auto-detecten.
|
||||
const lang = detectLang(trimmed, locale);
|
||||
|
||||
// ─── Load profile + plan ────────────────────────────────────────────────
|
||||
const db = usePrisma();
|
||||
@ -102,7 +104,7 @@ async function speakGoogle(
|
||||
voiceCfg: VoiceConfig,
|
||||
userId: string,
|
||||
plan: string,
|
||||
lang: string,
|
||||
lang: string | null,
|
||||
) {
|
||||
const key = (config.googleApiKey as string) || process.env.GOOGLE_API_KEY || "";
|
||||
if (!key) {
|
||||
@ -117,7 +119,9 @@ async function speakGoogle(
|
||||
de: "de-DE", en: "en-US", fr: "fr-FR", ar: "ar-XA",
|
||||
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 response = await fetch(
|
||||
@ -171,7 +175,7 @@ async function speakCartesia(
|
||||
voiceCfg: VoiceConfig,
|
||||
userId: string,
|
||||
plan: string,
|
||||
lang: string,
|
||||
lang: string | null,
|
||||
) {
|
||||
const key = (config.cartesiaApiKey as string) || process.env.CARTESIA_API_KEY || "";
|
||||
if (!key) {
|
||||
@ -201,9 +205,9 @@ async function speakCartesia(
|
||||
sample_rate: 22050,
|
||||
bit_rate: 64000,
|
||||
},
|
||||
// Sonic-3 unterstützt 42 Sprachen inkl. ar — language aus User-Locale
|
||||
// statt hardcoded "de", sonst klingt arabischer Text deutsch-phonetisch.
|
||||
language: lang,
|
||||
// Sonic-3 unterstützt 42 Sprachen — wenn Detection greift, language
|
||||
// explizit setzen, sonst Provider auto-detecten lassen.
|
||||
...(lang ? { language: lang } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@ -231,7 +235,7 @@ async function speakElevenLabs(
|
||||
userId: string,
|
||||
plan: string,
|
||||
userLyraVoiceId: string | null = null,
|
||||
lang: string = "de",
|
||||
lang: string | null = null,
|
||||
) {
|
||||
const key =
|
||||
(config.elevenlabsApiKey as string) || process.env.ELEVENLABS_API_KEY || "";
|
||||
@ -263,9 +267,10 @@ async function speakElevenLabs(
|
||||
text,
|
||||
model_id: modelId,
|
||||
// Turbo v2.5 ist multilingual (32 Sprachen inkl. ar) — dieselbe Stimme
|
||||
// spricht die Zielsprache. language_code explizit setzen statt nur
|
||||
// Auto-Detect, damit kurze Texte zuverlässig in der User-Sprache landen.
|
||||
language_code: lang,
|
||||
// spricht die Zielsprache. language_code nur explizit setzen wenn wir
|
||||
// ihn aus Detection/Locale ableiten konnten; sonst ElevenLabs
|
||||
// auto-detecten lassen statt einen falschen Code aufzuzwingen.
|
||||
...(lang ? { language_code: lang } : {}),
|
||||
voice_settings: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.75,
|
||||
|
||||
36
backend/server/utils/detect-lang.ts
Normal 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;
|
||||
}
|
||||