feat(backend): lyra voice picker for legend (mvp)

- profile.ts: Whitelist (null | iFSsEDGbm0FiEd2IVH4w | Gt7OshJCH7MuzX96wFHi) + setLyraVoiceId()
- profile/me/lyra-voice.patch.ts: neuer Endpoint, Legend-Gate (403 legend_only),
  Validation gegen Whitelist (400 invalid_voice_id). DB-Wert bleibt bei Plan-Downgrade.
- coach/speak.post.ts: ElevenLabs-Voice-Prioritätskette nimmt userLyraVoiceId
  zuerst (nur wenn plan === legend), sonst voiceCfg / config / env / FALLBACK.
- auth/me.get.ts: lyraVoiceId in der Profile-Response damit Frontend hydriert.

Schema-Feld lyraVoiceId existiert bereits aus migration
20260507_profile_demographics_and_trial — keine neue Migration nötig.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-14 22:15:42 +02:00
parent f2e3c00943
commit 76f8595a4f
4 changed files with 74 additions and 2 deletions

View File

@ -22,6 +22,7 @@ export default defineEventHandler(async (event) => {
plan, plan,
foundingMember: dbProfile?.foundingMember ?? false, foundingMember: dbProfile?.foundingMember ?? false,
streak: dbProfile?.streak ?? 0, streak: dbProfile?.streak ?? 0,
lyraVoiceId: dbProfile?.lyraVoiceId ?? null,
created_at: dbProfile?.createdAt?.toISOString() ?? user.created_at, created_at: dbProfile?.createdAt?.toISOString() ?? user.created_at,
// Für useUserPlan im Frontend — Key-Subset der PlanLimits // Für useUserPlan im Frontend — Key-Subset der PlanLimits
planLimits: { planLimits: {

View File

@ -46,9 +46,11 @@ export default defineEventHandler(async (event) => {
const db = usePrisma(); const db = usePrisma();
const profile = await db.profile.findUnique({ const profile = await db.profile.findUnique({
where: { id: user.id }, where: { id: user.id },
select: { plan: true }, select: { plan: true, lyraVoiceId: true },
}); });
const plan = (profile?.plan ?? "free").toLowerCase(); const plan = (profile?.plan ?? "free").toLowerCase();
// lyraVoiceId nur für legend wirksam — plan-check im speakElevenLabs
const userLyraVoiceId = plan === "legend" ? (profile?.lyraVoiceId ?? null) : null;
const limits = getPlanLimits(plan); const limits = getPlanLimits(plan);
const voiceCfg = limits.voice; const voiceCfg = limits.voice;
@ -78,7 +80,7 @@ export default defineEventHandler(async (event) => {
case "cartesia": case "cartesia":
return await speakCartesia(event, trimmed, config, voiceCfg, user.id, plan); return await speakCartesia(event, trimmed, config, voiceCfg, user.id, plan);
case "elevenlabs": case "elevenlabs":
return await speakElevenLabs(event, trimmed, mode, config, voiceCfg, user.id, plan); return await speakElevenLabs(event, trimmed, mode, config, voiceCfg, user.id, plan, userLyraVoiceId);
default: { default: {
// Unknown provider in config — fallback to Google with warning // Unknown provider in config — fallback to Google with warning
console.warn("[speak] unknown provider in plan-features:", voiceCfg.provider, "→ falling back to google"); console.warn("[speak] unknown provider in plan-features:", voiceCfg.provider, "→ falling back to google");
@ -211,6 +213,7 @@ async function speakElevenLabs(
voiceCfg: VoiceConfig, voiceCfg: VoiceConfig,
userId: string, userId: string,
plan: string, plan: string,
userLyraVoiceId: 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 || "";
@ -219,7 +222,9 @@ async function speakElevenLabs(
} }
const ELEVENLABS_FALLBACK_VOICE = "kdmDKE6EkgrWrrykO9Qt"; // Alexandra const ELEVENLABS_FALLBACK_VOICE = "kdmDKE6EkgrWrrykO9Qt"; // Alexandra
// User-Voice hat höchste Priorität (bereits plan-gefiltert vom Caller)
const voiceId = const voiceId =
userLyraVoiceId ||
voiceCfg.voiceId || voiceCfg.voiceId ||
(config.elevenlabsVoiceId as string) || (config.elevenlabsVoiceId as string) ||
process.env.ELEVENLABS_VOICE_ID || process.env.ELEVENLABS_VOICE_ID ||

View File

@ -0,0 +1,43 @@
import { setLyraVoiceId, isAllowedLyraVoiceId } from "../../../db/profile";
/**
* PATCH /api/profile/me/lyra-voice
*
* Legend-only. Setzt die ElevenLabs-Voice-ID für Lyra-TTS.
*
* Body: { lyraVoiceId: string | null }
* null Default-Voice (kdmDKE6EkgrWrrykO9Qt, Alexandra)
* iFSsEDGbm0FiEd2IVH4w Voice 1
* Gt7OshJCH7MuzX96wFHi Voice 2
*
* Plan-Downgrade-Verhalten: DB-Wert wird NICHT gelöscht. speak.post.ts
* prüft plan zur Laufzeit non-legend User erhalten Default-Voice.
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const db = usePrisma();
const profile = await db.profile.findUnique({
where: { id: user.id },
select: { plan: true },
});
if (!profile) {
throw createError({ statusCode: 404, statusMessage: "profile_not_found" });
}
if ((profile.plan ?? "free").toLowerCase() !== "legend") {
throw createError({ statusCode: 403, statusMessage: "legend_only" });
}
const body = await readBody(event);
const { lyraVoiceId } = body as { lyraVoiceId?: unknown };
if (!isAllowedLyraVoiceId(lyraVoiceId)) {
throw createError({ statusCode: 400, statusMessage: "invalid_voice_id" });
}
await setLyraVoiceId(user.id, lyraVoiceId);
return { success: true, data: { lyraVoiceId } };
});

View File

@ -204,6 +204,29 @@ export async function tryAwardProTrial(
}); });
} }
// ─── Lyra Voice-Picker ────────────────────────────────────────────────────
const ALLOWED_LYRA_VOICE_IDS = [
null,
"iFSsEDGbm0FiEd2IVH4w", // Voice 1
"Gt7OshJCH7MuzX96wFHi", // Voice 2
] as const;
export type LyraVoiceId = (typeof ALLOWED_LYRA_VOICE_IDS)[number];
export function isAllowedLyraVoiceId(value: unknown): value is LyraVoiceId {
return (ALLOWED_LYRA_VOICE_IDS as readonly unknown[]).includes(value);
}
export async function setLyraVoiceId(userId: string, voiceId: LyraVoiceId) {
const db = usePrisma();
return db.profile.update({
where: { id: userId },
data: { lyraVoiceId: voiceId },
select: { lyraVoiceId: true },
});
}
// ─── Banner / Install-Event ──────────────────────────────────────────────── // ─── Banner / Install-Event ────────────────────────────────────────────────
export async function dismissDigaBanner(userId: string) { export async function dismissDigaBanner(userId: string) {