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:
parent
f2e3c00943
commit
76f8595a4f
@ -22,6 +22,7 @@ export default defineEventHandler(async (event) => {
|
||||
plan,
|
||||
foundingMember: dbProfile?.foundingMember ?? false,
|
||||
streak: dbProfile?.streak ?? 0,
|
||||
lyraVoiceId: dbProfile?.lyraVoiceId ?? null,
|
||||
created_at: dbProfile?.createdAt?.toISOString() ?? user.created_at,
|
||||
// Für useUserPlan im Frontend — Key-Subset der PlanLimits
|
||||
planLimits: {
|
||||
|
||||
@ -46,9 +46,11 @@ export default defineEventHandler(async (event) => {
|
||||
const db = usePrisma();
|
||||
const profile = await db.profile.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { plan: true },
|
||||
select: { plan: true, lyraVoiceId: true },
|
||||
});
|
||||
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 voiceCfg = limits.voice;
|
||||
@ -78,7 +80,7 @@ export default defineEventHandler(async (event) => {
|
||||
case "cartesia":
|
||||
return await speakCartesia(event, trimmed, config, voiceCfg, user.id, plan);
|
||||
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: {
|
||||
// Unknown provider in config — fallback to Google with warning
|
||||
console.warn("[speak] unknown provider in plan-features:", voiceCfg.provider, "→ falling back to google");
|
||||
@ -211,6 +213,7 @@ async function speakElevenLabs(
|
||||
voiceCfg: VoiceConfig,
|
||||
userId: string,
|
||||
plan: string,
|
||||
userLyraVoiceId: string | null = null,
|
||||
) {
|
||||
const key =
|
||||
(config.elevenlabsApiKey as string) || process.env.ELEVENLABS_API_KEY || "";
|
||||
@ -219,7 +222,9 @@ async function speakElevenLabs(
|
||||
}
|
||||
|
||||
const ELEVENLABS_FALLBACK_VOICE = "kdmDKE6EkgrWrrykO9Qt"; // Alexandra
|
||||
// User-Voice hat höchste Priorität (bereits plan-gefiltert vom Caller)
|
||||
const voiceId =
|
||||
userLyraVoiceId ||
|
||||
voiceCfg.voiceId ||
|
||||
(config.elevenlabsVoiceId as string) ||
|
||||
process.env.ELEVENLABS_VOICE_ID ||
|
||||
|
||||
43
backend/server/api/profile/me/lyra-voice.patch.ts
Normal file
43
backend/server/api/profile/me/lyra-voice.patch.ts
Normal 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 } };
|
||||
});
|
||||
@ -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 ────────────────────────────────────────────────
|
||||
|
||||
export async function dismissDigaBanner(userId: string) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user