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,
|
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: {
|
||||||
|
|||||||
@ -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 ||
|
||||||
|
|||||||
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 ────────────────────────────────────────────────
|
// ─── Banner / Install-Event ────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function dismissDigaBanner(userId: string) {
|
export async function dismissDigaBanner(userId: string) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user