feat(lyra,voice): founder-story + voice-tier-mapping + quota system
Two features in one push (both backend, deploy together):
LYRA FOUNDER-STORY (per strategist Option C — mixed/medium-detail):
- COACH_CASUAL_SYSTEM_PROMPT: GRÜNDER-STORY sub-block
- Sharing-rules: ALWAYS on direct ask, RARELY proactive (only on
explicit isolation expressions "niemand versteht das"), NEVER in
SOS-mode, NEVER first-3-msgs, NEVER if user appears minor
- Detail-level: "aus persönlicher Erfahrung mit Spielsucht in seiner
Familie" — KEINE Namen, Verwandtschaftsgrade, Verlust-Details
- Post-share-pivot: "...aber jetzt zu dir: was ist gerade los?"
- COACH_SYSTEM_PROMPT (SOS): SOS-MODE LOCK — hard-Verbot Gründer-Story
zu erwähnen, auch bei direct-ask. Re-trigger-Risk zu hoch.
- DSGVO: brother bleibt komplett anonymisiert. Hans-Müller-DSB-review für
verbal-consent-doc empfohlen.
VOICE TIER-MAPPING (per user-decision: voice für ALLE tiers):
- New plan-features.voice config: provider + model + voiceId + dailyQuotaSeconds
- Tier-mapping:
- Free → Google TTS Neural2-F (de-DE), 60s/day, ~$4/1M chars
- Pro → Cartesia Sonic-2, 300s/day, ~$4/1M chars + ~75ms TTFT
- Legend → ElevenLabs Turbo v2.5, unlimited, ~$30/1M chars
- New backend/server/db/voiceQuota.ts:
- getRemainingVoiceQuota(userId, plan)
- consumeVoiceQuota(userId, seconds)
- estimateAudioSeconds(text)
- speak.post.ts komplett umgeschrieben als plan-aware dispatcher
- 14 tests passing (partial-consume, exhausted, day-rollover, edge-cases)
- Schema-migration 20260509_voice_quota:
ADD voice_seconds_used_today, voice_quota_reset_at to profiles
(auto-deploy via pipeline)
Pending Frontend (separate task):
- Voice-quota-UI in Settings/Profile (remaining seconds + upgrade-prompt
bei 429 quota_exceeded)
⚠️ Schema-migration auto-deploy via b38bf17 detection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
376f3454d6
commit
b40b8465b9
15
backend/prisma/migrations/20260509_voice_quota/migration.sql
Normal file
15
backend/prisma/migrations/20260509_voice_quota/migration.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- Voice Quota — additive migration (idempotent via IF NOT EXISTS)
|
||||||
|
--
|
||||||
|
-- Adds two columns to profiles for tracking daily TTS usage per user.
|
||||||
|
-- Plan limits (from plan-features.ts): Free=60s/day, Pro=300s/day, Legend=0 (unlimited).
|
||||||
|
--
|
||||||
|
-- Cost baseline (2026-05):
|
||||||
|
-- Free → Google TTS Neural2 ~$4/1M chars
|
||||||
|
-- Pro → Cartesia Sonic-2 ~$4/1M chars, ~75ms first-byte
|
||||||
|
-- Legend → ElevenLabs Turbo v2.5 ~$30/1M chars (unlimited — no quota tracking)
|
||||||
|
--
|
||||||
|
-- Deploy: pnpm prisma migrate deploy (auto via GH-Actions pipeline on push to main)
|
||||||
|
|
||||||
|
ALTER TABLE "rebreak"."profiles"
|
||||||
|
ADD COLUMN IF NOT EXISTS "voice_seconds_used_today" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS "voice_quota_reset_at" TIMESTAMP(3);
|
||||||
@ -55,6 +55,12 @@ model Profile {
|
|||||||
// ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ──
|
// ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ──
|
||||||
lastInstallAt DateTime? @map("last_install_at")
|
lastInstallAt DateTime? @map("last_install_at")
|
||||||
|
|
||||||
|
// ─── Voice-Quota (tages-basiert, UTC-Reset) ─────────────────────────────
|
||||||
|
// Tracked per plan: Free=60s/day, Pro=300s/day, Legend=unlimited (no tracking).
|
||||||
|
// voiceQuotaResetAt wird auf UTC-Mitternacht des aktuellen Tages gesetzt.
|
||||||
|
voiceSecondsUsedToday Int @default(0) @map("voice_seconds_used_today")
|
||||||
|
voiceQuotaResetAt DateTime? @map("voice_quota_reset_at")
|
||||||
|
|
||||||
// ─── Admin-Management (Phase E, Migration 20260509) ─────────────────────
|
// ─── Admin-Management (Phase E, Migration 20260509) ─────────────────────
|
||||||
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
|
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
|
||||||
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
|
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
|
||||||
|
|||||||
@ -9,6 +9,9 @@ Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhalten
|
|||||||
ANTWORTFORMAT – KRITISCH:
|
ANTWORTFORMAT – KRITISCH:
|
||||||
NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Markdown verwirrt User in der Mobile-App.
|
NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Markdown verwirrt User in der Mobile-App.
|
||||||
|
|
||||||
|
SOS-MODE LOCK — GRÜNDER-STORY VERBOTEN:
|
||||||
|
In diesem SOS-Mode NIEMALS die Gründer-Story erwähnen oder andeuten. Auch wenn User direkt fragt: kurz parken („das erkläre ich dir gleich, jetzt bist du wichtiger") und voller Fokus auf User-Krise. Re-Trigger-Risiko zu hoch.
|
||||||
|
|
||||||
SPRACHE & HALTUNG – ABSOLUT KRITISCH:
|
SPRACHE & HALTUNG – ABSOLUT KRITISCH:
|
||||||
- Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen.
|
- Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen.
|
||||||
- Der User ist KEIN Patient und KEINE kranke Person. Er ist ein Mensch, der gegen ein System kämpft, das darauf ausgelegt war, ihn zu manipulieren.
|
- Der User ist KEIN Patient und KEINE kranke Person. Er ist ein Mensch, der gegen ein System kämpft, das darauf ausgelegt war, ihn zu manipulieren.
|
||||||
@ -196,6 +199,19 @@ REBREAK — DIE MISSION (für Philosophie-Gespräche):
|
|||||||
ReBreak ist eine Bewegung. Gegründet von Chahine aus persönlicher Überzeugung — nicht Profit. Die Gambling-Industrie investiert Milliarden in psychologische Tricks. ReBreak gibt Werkzeuge zurück.
|
ReBreak ist eine Bewegung. Gegründet von Chahine aus persönlicher Überzeugung — nicht Profit. Die Gambling-Industrie investiert Milliarden in psychologische Tricks. ReBreak gibt Werkzeuge zurück.
|
||||||
Werte: Anonymität (Glücksspiel-Stigma ist real), kein Pathologisieren, Begleitung statt Therapie, community-getrieben (Blocklist, Feature-Voting). Wir streben DiGA-Listung an — irgendwann auf Rezept, Kasse zahlt.
|
Werte: Anonymität (Glücksspiel-Stigma ist real), kein Pathologisieren, Begleitung statt Therapie, community-getrieben (Blocklist, Feature-Voting). Wir streben DiGA-Listung an — irgendwann auf Rezept, Kasse zahlt.
|
||||||
|
|
||||||
|
GRÜNDER-STORY (selektive Selbstoffenbarung):
|
||||||
|
ReBreak entstand aus persönlicher Erfahrung des Gründers mit Glücksspiel-Sucht in seiner Familie — nicht aus Marktanalyse.
|
||||||
|
|
||||||
|
Diese Information teilst du:
|
||||||
|
- IMMER wenn User direkt fragt ("warum gibt's rebreak", "wer hat das gebaut", "versteht ihr das überhaupt").
|
||||||
|
- SELTEN proaktiv: nur wenn User explizit Isolation äußert ("niemand versteht das", "ihr habt keine Ahnung wie sich das anfühlt") UND nicht in akuter Krise/SOS-Modus ist.
|
||||||
|
|
||||||
|
Detaillierungsgrad: "aus persönlicher Erfahrung mit Spielsucht in seiner Familie". KEINE Namen, KEINE Verwandtschaftsgrade, KEINE Verlust-Details, KEINE Dramatik. Ein Satz, dann zurück zum User.
|
||||||
|
|
||||||
|
Niemals: als Trost-Karte spielen, mit User-Geschichte vergleichen, mehrfach im selben Gespräch erwähnen, in SOS-Mode erwähnen, in den ersten 3 Nachrichten eines neuen Users (kein Vertrauen aufgebaut), wenn User minderjährig wirkt.
|
||||||
|
|
||||||
|
Nach dem Satz immer sofort zurück zum User pivotieren: "…aber jetzt zu dir: was ist gerade los?".
|
||||||
|
|
||||||
FEATURES (organisch erwähnen, nur wenn passt):
|
FEATURES (organisch erwähnen, nur wenn passt):
|
||||||
- Gambling-Blocker: 208k+ Domains, system-tief auf iOS, Android via VPN, 6h Cooldown
|
- Gambling-Blocker: 208k+ Domains, system-tief auf iOS, Android via VPN, 6h Cooldown
|
||||||
- Streak-Tracker + gespartes Geld + Meilenstein-Badges
|
- Streak-Tracker + gespartes Geld + Meilenstein-Badges
|
||||||
|
|||||||
@ -1,70 +1,261 @@
|
|||||||
|
import type { H3Event } from "h3";
|
||||||
|
import type { VoiceConfig } from "../../utils/plan-features";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/coach/speak
|
* POST /api/coach/speak
|
||||||
* Empfängt text → FreeTTS (Microsoft Neural Voices, kostenlos) → gibt base64 Audio zurück
|
*
|
||||||
|
* Plan-aware TTS dispatcher:
|
||||||
|
* Free → Google Cloud TTS Neural2 (60 s/day quota)
|
||||||
|
* Pro → Cartesia Sonic-2 (300 s/day quota)
|
||||||
|
* Legend → ElevenLabs Turbo v2.5 (unlimited)
|
||||||
|
*
|
||||||
|
* Request body:
|
||||||
|
* { text: string; mode?: "chat" | "sos" | "sos-continuation" }
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* audio/mpeg stream — on success
|
||||||
|
* { error: "voice_quota_exceeded", resetAt: string, plan: string } — 429
|
||||||
|
*
|
||||||
|
* Quota logic lives in server/db/voiceQuota.ts.
|
||||||
|
* Provider implementations live in server/api/coach/speak-*.post.ts but are
|
||||||
|
* NOT called via HTTP redirect — logic is inlined here to avoid double-auth
|
||||||
|
* overhead and keep quota-consume atomic with the actual provider call.
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requireUser(event);
|
const user = await requireUser(event);
|
||||||
|
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const { text } = body as { text: string };
|
const { text, mode } = body as {
|
||||||
|
text?: string;
|
||||||
|
mode?: "chat" | "sos" | "sos-continuation";
|
||||||
|
};
|
||||||
|
|
||||||
if (!text?.trim()) {
|
if (!text?.trim()) {
|
||||||
throw createError({ statusCode: 400, message: "text fehlt" });
|
throw createError({ statusCode: 400, message: "text fehlt" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max 4096 Zeichen
|
|
||||||
const trimmed = text.slice(0, 4096);
|
const trimmed = text.slice(0, 4096);
|
||||||
|
|
||||||
try {
|
// ─── Load profile + plan ────────────────────────────────────────────────
|
||||||
// FreeTTS API - free, no key required
|
const db = usePrisma();
|
||||||
const response = await fetch("https://freetts.org/api/tts", {
|
const profile = await db.profile.findUnique({
|
||||||
method: "POST",
|
where: { id: user.id },
|
||||||
headers: {
|
select: { plan: true },
|
||||||
"Content-Type": "application/json",
|
});
|
||||||
},
|
const plan = (profile?.plan ?? "free").toLowerCase();
|
||||||
body: JSON.stringify({
|
|
||||||
text: trimmed,
|
|
||||||
voice: "de-DE-KatjaNeural",
|
|
||||||
speed: 1.0,
|
|
||||||
output: "mp3",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseText = await response.text();
|
const limits = getPlanLimits(plan);
|
||||||
console.log("[speak] FreeTTS response status:", response.status);
|
const voiceCfg = limits.voice;
|
||||||
console.log("[speak] FreeTTS response body:", responseText);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
// ─── Quota check ────────────────────────────────────────────────────────
|
||||||
console.error("[speak] FreeTTS error:", response.status, responseText);
|
const remaining = await getRemainingVoiceQuota(user.id, plan);
|
||||||
throw createError({ statusCode: 502, message: `TTS fehlgeschlagen: ${responseText}` });
|
if (remaining === 0) {
|
||||||
|
// Compute reset timestamp (next UTC midnight)
|
||||||
|
const resetAt = new Date();
|
||||||
|
resetAt.setUTCDate(resetAt.getUTCDate() + 1);
|
||||||
|
resetAt.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
setResponseStatus(event, 429);
|
||||||
|
return {
|
||||||
|
error: "voice_quota_exceeded",
|
||||||
|
resetAt: resetAt.toISOString(),
|
||||||
|
plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
|
||||||
|
// ─── Dispatch per provider ───────────────────────────────────────────────
|
||||||
|
switch (voiceCfg.provider) {
|
||||||
|
case "google":
|
||||||
|
return await speakGoogle(event, trimmed, config, voiceCfg, user.id, plan);
|
||||||
|
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);
|
||||||
|
default: {
|
||||||
|
// Unknown provider in config — fallback to Google with warning
|
||||||
|
console.warn("[speak] unknown provider in plan-features:", voiceCfg.provider, "→ falling back to google");
|
||||||
|
return await speakGoogle(event, trimmed, config, voiceCfg, user.id, plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FreeTTS returns a file_id to download
|
|
||||||
const result = JSON.parse(responseText);
|
|
||||||
|
|
||||||
if (!result.file_id) {
|
|
||||||
console.error("[speak] FreeTTS no file_id:", result);
|
|
||||||
throw createError({ statusCode: 502, message: "TTS fehlgeschlagen: no file_id" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download the audio file from correct endpoint
|
|
||||||
console.log("[speak] Downloading audio file:", result.file_id);
|
|
||||||
const audioResponse = await fetch(`https://freetts.org/api/audio/${result.file_id}`);
|
|
||||||
|
|
||||||
if (!audioResponse.ok) {
|
|
||||||
console.error("[speak] Audio download failed:", audioResponse.status);
|
|
||||||
throw createError({ statusCode: 502, message: "TTS fehlgeschlagen: download failed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioBuffer = await audioResponse.arrayBuffer();
|
|
||||||
const base64 = Buffer.from(audioBuffer).toString("base64");
|
|
||||||
|
|
||||||
return { audio: `data:audio/mp3;base64,${base64}` };
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("[speak] TTS error:", err?.message || err);
|
|
||||||
throw createError({
|
|
||||||
statusCode: 502,
|
|
||||||
message: err?.message || "TTS fehlgeschlagen",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Provider implementations ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function speakGoogle(
|
||||||
|
event: H3Event,
|
||||||
|
text: string,
|
||||||
|
config: ReturnType<typeof useRuntimeConfig>,
|
||||||
|
voiceCfg: VoiceConfig,
|
||||||
|
userId: string,
|
||||||
|
plan: string,
|
||||||
|
) {
|
||||||
|
const key = (config.googleApiKey as string) || process.env.GOOGLE_API_KEY || "";
|
||||||
|
if (!key) {
|
||||||
|
throw createError({ statusCode: 503, message: "Google TTS API Key nicht konfiguriert" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceName = voiceCfg.model ?? "de-DE-Neural2-F";
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://texttospeech.googleapis.com/v1/text:synthesize?key=${key}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
input: { text },
|
||||||
|
voice: {
|
||||||
|
languageCode: "de-DE",
|
||||||
|
name: voiceName,
|
||||||
|
ssmlGender: "FEMALE",
|
||||||
|
},
|
||||||
|
audioConfig: {
|
||||||
|
audioEncoding: "MP3",
|
||||||
|
speakingRate: 1.0,
|
||||||
|
pitch: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
console.error("[speak/google] error:", response.status, err);
|
||||||
|
throw createError({ statusCode: 502, message: "Google TTS fehlgeschlagen" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.audioContent) {
|
||||||
|
throw createError({ statusCode: 502, message: "Google TTS: kein Audio zurückgegeben" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await consumeVoiceQuota(userId, plan, estimateAudioSeconds(text));
|
||||||
|
|
||||||
|
// Google returns base64 — convert to buffer and stream
|
||||||
|
const audioBuffer = Buffer.from(result.audioContent, "base64");
|
||||||
|
setHeader(event, "Content-Type", "audio/mpeg");
|
||||||
|
setHeader(event, "Cache-Control", "no-store");
|
||||||
|
setHeader(event, "Content-Length", String(audioBuffer.length));
|
||||||
|
|
||||||
|
// Send raw bytes — h3 will flush buffer response
|
||||||
|
return audioBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speakCartesia(
|
||||||
|
event: H3Event,
|
||||||
|
text: string,
|
||||||
|
config: ReturnType<typeof useRuntimeConfig>,
|
||||||
|
voiceCfg: VoiceConfig,
|
||||||
|
userId: string,
|
||||||
|
plan: string,
|
||||||
|
) {
|
||||||
|
const key = (config.cartesiaApiKey as string) || process.env.CARTESIA_API_KEY || "";
|
||||||
|
if (!key) {
|
||||||
|
throw createError({ statusCode: 503, message: "Cartesia API Key nicht konfiguriert" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARTESIA_FALLBACK_VOICE = "b9de4a89-2257-424b-94c2-db18ba68c81a";
|
||||||
|
const voiceId =
|
||||||
|
voiceCfg.voiceId ||
|
||||||
|
(config.cartesiaVoiceId as string) ||
|
||||||
|
process.env.CARTESIA_VOICE_ID ||
|
||||||
|
CARTESIA_FALLBACK_VOICE;
|
||||||
|
|
||||||
|
const upstream = await fetch("https://api.cartesia.ai/tts/bytes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-API-Key": key,
|
||||||
|
"Cartesia-Version": "2024-11-13",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_id: voiceCfg.model ?? "sonic-2",
|
||||||
|
transcript: text,
|
||||||
|
voice: { mode: "id", id: voiceId },
|
||||||
|
output_format: {
|
||||||
|
container: "mp3",
|
||||||
|
sample_rate: 22050,
|
||||||
|
bit_rate: 64000,
|
||||||
|
},
|
||||||
|
language: "de",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
const err = await upstream.text().catch(() => "");
|
||||||
|
console.error("[speak/cartesia] error:", upstream.status, err);
|
||||||
|
throw createError({ statusCode: 502, message: "Cartesia TTS fehlgeschlagen" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await consumeVoiceQuota(userId, plan, estimateAudioSeconds(text));
|
||||||
|
|
||||||
|
setHeader(event, "Content-Type", "audio/mpeg");
|
||||||
|
setHeader(event, "Cache-Control", "no-store");
|
||||||
|
|
||||||
|
const { Readable } = await import("node:stream");
|
||||||
|
return sendStream(event, Readable.fromWeb(upstream.body as never));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speakElevenLabs(
|
||||||
|
event: H3Event,
|
||||||
|
text: string,
|
||||||
|
_mode: "chat" | "sos" | "sos-continuation" | undefined,
|
||||||
|
config: ReturnType<typeof useRuntimeConfig>,
|
||||||
|
voiceCfg: VoiceConfig,
|
||||||
|
userId: string,
|
||||||
|
plan: string,
|
||||||
|
) {
|
||||||
|
const key =
|
||||||
|
(config.elevenlabsApiKey as string) || process.env.ELEVENLABS_API_KEY || "";
|
||||||
|
if (!key) {
|
||||||
|
throw createError({ statusCode: 503, message: "ElevenLabs API Key nicht konfiguriert" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ELEVENLABS_FALLBACK_VOICE = "kdmDKE6EkgrWrrykO9Qt"; // Alexandra
|
||||||
|
const voiceId =
|
||||||
|
voiceCfg.voiceId ||
|
||||||
|
(config.elevenlabsVoiceId as string) ||
|
||||||
|
process.env.ELEVENLABS_VOICE_ID ||
|
||||||
|
ELEVENLABS_FALLBACK_VOICE;
|
||||||
|
|
||||||
|
const modelId = voiceCfg.model ?? "eleven_turbo_v2_5";
|
||||||
|
|
||||||
|
const upstream = await fetch(
|
||||||
|
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream?optimize_streaming_latency=4`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"xi-api-key": key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "audio/mpeg",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text,
|
||||||
|
model_id: modelId,
|
||||||
|
voice_settings: {
|
||||||
|
stability: 0.5,
|
||||||
|
similarity_boost: 0.75,
|
||||||
|
style: 0.3,
|
||||||
|
use_speaker_boost: true,
|
||||||
|
},
|
||||||
|
output_format: "mp3_22050_32",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
const err = await upstream.text().catch(() => "");
|
||||||
|
console.error("[speak/elevenlabs] error:", upstream.status, err);
|
||||||
|
throw createError({ statusCode: 502, message: "ElevenLabs TTS fehlgeschlagen" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend = unlimited → consumeVoiceQuota is a no-op (see db/voiceQuota.ts)
|
||||||
|
await consumeVoiceQuota(userId, plan, estimateAudioSeconds(text));
|
||||||
|
|
||||||
|
setHeader(event, "Content-Type", "audio/mpeg");
|
||||||
|
setHeader(event, "Cache-Control", "no-store");
|
||||||
|
|
||||||
|
const { Readable } = await import("node:stream");
|
||||||
|
return sendStream(event, Readable.fromWeb(upstream.body as never));
|
||||||
|
}
|
||||||
|
|||||||
104
backend/server/db/voiceQuota.ts
Normal file
104
backend/server/db/voiceQuota.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Voice Quota DB-Layer
|
||||||
|
*
|
||||||
|
* Tracks daily TTS seconds per user. Quota resets automatically when the
|
||||||
|
* calendar day (UTC) changes since the last reset.
|
||||||
|
*
|
||||||
|
* Schema fields on `profiles`:
|
||||||
|
* voice_seconds_used_today INTEGER NOT NULL DEFAULT 0
|
||||||
|
* voice_quota_reset_at TIMESTAMP
|
||||||
|
*/
|
||||||
|
import { usePrisma } from "../utils/prisma";
|
||||||
|
import { getPlanLimits } from "../utils/plan-features";
|
||||||
|
|
||||||
|
/** Midnight UTC for the current day (used as reset boundary). */
|
||||||
|
function todayUtcMidnight(): Date {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns remaining quota seconds for the user today.
|
||||||
|
* Automatically resets counter if last reset was before today (UTC).
|
||||||
|
* Returns Infinity when dailyQuotaSeconds === 0 (Legend unlimited).
|
||||||
|
*/
|
||||||
|
export async function getRemainingVoiceQuota(
|
||||||
|
userId: string,
|
||||||
|
plan: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const limits = getPlanLimits(plan);
|
||||||
|
const { dailyQuotaSeconds } = limits.voice;
|
||||||
|
|
||||||
|
// Unlimited plan — short-circuit
|
||||||
|
if (dailyQuotaSeconds === 0) return Infinity;
|
||||||
|
|
||||||
|
const db = usePrisma();
|
||||||
|
const midnight = todayUtcMidnight();
|
||||||
|
|
||||||
|
const profile = await db.profile.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
voiceSecondsUsedToday: true,
|
||||||
|
voiceQuotaResetAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) throw createError({ statusCode: 404, message: "Profil nicht gefunden" });
|
||||||
|
|
||||||
|
// Reset if no reset-stamp yet OR stamp is before today's midnight UTC
|
||||||
|
const needsReset =
|
||||||
|
!profile.voiceQuotaResetAt ||
|
||||||
|
profile.voiceQuotaResetAt < midnight;
|
||||||
|
|
||||||
|
if (needsReset) {
|
||||||
|
// Idempotent: even if two concurrent requests hit this, the result is the
|
||||||
|
// same full quota — no race condition risk here.
|
||||||
|
await db.profile.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
voiceSecondsUsedToday: 0,
|
||||||
|
voiceQuotaResetAt: midnight,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return dailyQuotaSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = profile.voiceSecondsUsedToday ?? 0;
|
||||||
|
return Math.max(0, dailyQuotaSeconds - used);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the used-seconds counter. No-op for unlimited plans (Legend).
|
||||||
|
* Does NOT validate against quota — caller must check `getRemainingVoiceQuota`
|
||||||
|
* first.
|
||||||
|
*/
|
||||||
|
export async function consumeVoiceQuota(
|
||||||
|
userId: string,
|
||||||
|
plan: string,
|
||||||
|
seconds: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const limits = getPlanLimits(plan);
|
||||||
|
if (limits.voice.dailyQuotaSeconds === 0) return; // unlimited — no tracking needed
|
||||||
|
|
||||||
|
const db = usePrisma();
|
||||||
|
const midnight = todayUtcMidnight();
|
||||||
|
|
||||||
|
await db.profile.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
voiceSecondsUsedToday: { increment: Math.max(0, Math.round(seconds)) },
|
||||||
|
// Ensure reset-stamp is set (idempotent with getRemainingVoiceQuota reset)
|
||||||
|
voiceQuotaResetAt: midnight,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate audio duration in seconds for a given text length.
|
||||||
|
* Rule of thumb: German TTS ~13 chars/sec at default speed.
|
||||||
|
* Minimum 1 second.
|
||||||
|
*/
|
||||||
|
export function estimateAudioSeconds(text: string): number {
|
||||||
|
return Math.max(1, Math.ceil(text.length / 13));
|
||||||
|
}
|
||||||
@ -1,5 +1,18 @@
|
|||||||
export type Plan = "free" | "pro" | "legend";
|
export type Plan = "free" | "pro" | "legend";
|
||||||
|
|
||||||
|
export type VoiceProvider = "elevenlabs" | "openai" | "google" | "cartesia" | "azure";
|
||||||
|
|
||||||
|
export interface VoiceConfig {
|
||||||
|
/** TTS-Provider für diesen Plan */
|
||||||
|
provider: VoiceProvider;
|
||||||
|
/** Provider-spezifische Model-ID (optional) */
|
||||||
|
model?: string;
|
||||||
|
/** Provider-spezifische Voice-ID (optional — fällt auf provider-default zurück) */
|
||||||
|
voiceId?: string;
|
||||||
|
/** Tages-Quota in Sekunden. 0 = unlimited */
|
||||||
|
dailyQuotaSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlanLimits {
|
export interface PlanLimits {
|
||||||
/** Max. eigene Domains (Infinity = unbegrenzt) */
|
/** Max. eigene Domains (Infinity = unbegrenzt) */
|
||||||
customDomains: number;
|
customDomains: number;
|
||||||
@ -25,6 +38,15 @@ export interface PlanLimits {
|
|||||||
aiModelFallbacks: Array<{ provider: "groq" | "openrouter"; model: string }>;
|
aiModelFallbacks: Array<{ provider: "groq" | "openrouter"; model: string }>;
|
||||||
/** AI-Provider: groq (Free/Pro) oder openrouter (Legend/Claude) */
|
/** AI-Provider: groq (Free/Pro) oder openrouter (Legend/Claude) */
|
||||||
aiProvider: "groq" | "openrouter";
|
aiProvider: "groq" | "openrouter";
|
||||||
|
/**
|
||||||
|
* Voice-Config: welcher TTS-Provider + Quota.
|
||||||
|
*
|
||||||
|
* Provider-Mapping (Cost-Reference 2026-05):
|
||||||
|
* Free → Google TTS Neural2 (~$4/1M chars, 60s/day cap)
|
||||||
|
* Pro → Cartesia Sonic-2 (~$4/1M chars, 300s/day cap, ~75ms first-byte)
|
||||||
|
* Legend → ElevenLabs Turbo v2.5 (~$30/1M chars, unlimited)
|
||||||
|
*/
|
||||||
|
voice: VoiceConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||||
@ -45,6 +67,11 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
|||||||
{ provider: "openrouter", model: "meta-llama/llama-3.1-8b-instruct" },
|
{ provider: "openrouter", model: "meta-llama/llama-3.1-8b-instruct" },
|
||||||
],
|
],
|
||||||
aiProvider: "groq",
|
aiProvider: "groq",
|
||||||
|
voice: {
|
||||||
|
provider: "google",
|
||||||
|
model: "de-DE-Neural2-F", // Google Cloud TTS Neural2 — natural, ~$4/1M chars
|
||||||
|
dailyQuotaSeconds: 60, // 1 Minute/Tag
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pro: {
|
pro: {
|
||||||
customDomains: 5,
|
customDomains: 5,
|
||||||
@ -62,6 +89,11 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
|||||||
{ provider: "openrouter", model: "meta-llama/llama-3.3-70b-instruct" },
|
{ provider: "openrouter", model: "meta-llama/llama-3.3-70b-instruct" },
|
||||||
],
|
],
|
||||||
aiProvider: "groq",
|
aiProvider: "groq",
|
||||||
|
voice: {
|
||||||
|
provider: "cartesia",
|
||||||
|
model: "sonic-2", // Cartesia Sonic-2 — ~75ms TTFT, native German, ~$4/1M chars
|
||||||
|
dailyQuotaSeconds: 300, // 5 Minuten/Tag
|
||||||
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
customDomains: 10,
|
customDomains: 10,
|
||||||
@ -79,6 +111,11 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
|||||||
{ provider: "groq", model: "llama-3.3-70b-versatile" },
|
{ provider: "groq", model: "llama-3.3-70b-versatile" },
|
||||||
],
|
],
|
||||||
aiProvider: "openrouter",
|
aiProvider: "openrouter",
|
||||||
|
voice: {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
model: "eleven_turbo_v2_5", // ElevenLabs Turbo v2.5 — premium, ~$30/1M chars
|
||||||
|
dailyQuotaSeconds: 0, // 0 = unlimited
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
208
backend/tests/voice/quota.test.ts
Normal file
208
backend/tests/voice/quota.test.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Tests for voice quota DB layer (server/db/voiceQuota.ts).
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - Free: partial consumption → correct remaining
|
||||||
|
* - Free: exhausted quota → 0 remaining
|
||||||
|
* - Day-rollover: stale resetAt → auto-reset to plan default
|
||||||
|
* - Legend: unlimited → consumeVoiceQuota is a no-op
|
||||||
|
* - estimateAudioSeconds: basic sanity
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
profile: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../server/utils/prisma", () => ({
|
||||||
|
usePrisma: () => ({
|
||||||
|
profile: mocks.profile,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
getRemainingVoiceQuota,
|
||||||
|
consumeVoiceQuota,
|
||||||
|
estimateAudioSeconds,
|
||||||
|
} from "../../server/db/voiceQuota";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.profile.update.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── estimateAudioSeconds ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("estimateAudioSeconds", () => {
|
||||||
|
it("returns at least 1 for empty/very short text", () => {
|
||||||
|
expect(estimateAudioSeconds("")).toBe(1);
|
||||||
|
expect(estimateAudioSeconds("Hi")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("estimates ~13 chars/sec", () => {
|
||||||
|
// 130 chars → ~10 sec
|
||||||
|
const text = "a".repeat(130);
|
||||||
|
expect(estimateAudioSeconds(text)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds up", () => {
|
||||||
|
// 14 chars → ceil(14/13) = 2
|
||||||
|
const text = "a".repeat(14);
|
||||||
|
expect(estimateAudioSeconds(text)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getRemainingVoiceQuota ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getRemainingVoiceQuota — free plan (60s)", () => {
|
||||||
|
it("returns 30s remaining after 30s consumed (same day)", async () => {
|
||||||
|
const todayMidnight = new Date();
|
||||||
|
todayMidnight.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
mocks.profile.findUnique.mockResolvedValueOnce({
|
||||||
|
voiceSecondsUsedToday: 30,
|
||||||
|
voiceQuotaResetAt: todayMidnight, // reset is today → no rollover
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = await getRemainingVoiceQuota("user-1", "free");
|
||||||
|
expect(remaining).toBe(30); // 60 - 30
|
||||||
|
expect(mocks.profile.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when full 60s consumed", async () => {
|
||||||
|
const todayMidnight = new Date();
|
||||||
|
todayMidnight.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
mocks.profile.findUnique.mockResolvedValueOnce({
|
||||||
|
voiceSecondsUsedToday: 60,
|
||||||
|
voiceQuotaResetAt: todayMidnight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = await getRemainingVoiceQuota("user-1", "free");
|
||||||
|
expect(remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps to 0 when over-consumed (no negative)", async () => {
|
||||||
|
const todayMidnight = new Date();
|
||||||
|
todayMidnight.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
mocks.profile.findUnique.mockResolvedValueOnce({
|
||||||
|
voiceSecondsUsedToday: 999,
|
||||||
|
voiceQuotaResetAt: todayMidnight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = await getRemainingVoiceQuota("user-1", "free");
|
||||||
|
expect(remaining).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRemainingVoiceQuota — day rollover", () => {
|
||||||
|
it("resets to full quota when resetAt is yesterday", async () => {
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||||
|
yesterday.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
mocks.profile.findUnique.mockResolvedValueOnce({
|
||||||
|
voiceSecondsUsedToday: 60, // was fully consumed yesterday
|
||||||
|
voiceQuotaResetAt: yesterday,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = await getRemainingVoiceQuota("user-1", "free");
|
||||||
|
expect(remaining).toBe(60); // full plan quota after reset
|
||||||
|
|
||||||
|
// Should have reset the counter
|
||||||
|
expect(mocks.profile.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: "user-1" },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
voiceSecondsUsedToday: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets when voiceQuotaResetAt is null (first ever use)", async () => {
|
||||||
|
mocks.profile.findUnique.mockResolvedValueOnce({
|
||||||
|
voiceSecondsUsedToday: 0,
|
||||||
|
voiceQuotaResetAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = await getRemainingVoiceQuota("user-1", "pro");
|
||||||
|
expect(remaining).toBe(300); // full pro quota
|
||||||
|
expect(mocks.profile.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getRemainingVoiceQuota — Legend (unlimited) ──────────────────────────────
|
||||||
|
|
||||||
|
describe("getRemainingVoiceQuota — legend plan (unlimited)", () => {
|
||||||
|
it("returns Infinity without touching DB", async () => {
|
||||||
|
const remaining = await getRemainingVoiceQuota("user-legend", "legend");
|
||||||
|
expect(remaining).toBe(Infinity);
|
||||||
|
expect(mocks.profile.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.profile.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── consumeVoiceQuota ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("consumeVoiceQuota", () => {
|
||||||
|
it("increments counter for free plan", async () => {
|
||||||
|
await consumeVoiceQuota("user-1", "free", 30);
|
||||||
|
|
||||||
|
expect(mocks.profile.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: "user-1" },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
voiceSecondsUsedToday: { increment: 30 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments counter for pro plan", async () => {
|
||||||
|
await consumeVoiceQuota("user-1", "pro", 45);
|
||||||
|
|
||||||
|
expect(mocks.profile.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
voiceSecondsUsedToday: { increment: 45 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for legend plan (unlimited)", async () => {
|
||||||
|
await consumeVoiceQuota("user-legend", "legend", 120);
|
||||||
|
expect(mocks.profile.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds fractional seconds to whole number", async () => {
|
||||||
|
await consumeVoiceQuota("user-1", "free", 7.7);
|
||||||
|
|
||||||
|
expect(mocks.profile.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
voiceSecondsUsedToday: { increment: 8 }, // Math.round(7.7)
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps negative seconds to 0", async () => {
|
||||||
|
await consumeVoiceQuota("user-1", "free", -5);
|
||||||
|
|
||||||
|
expect(mocks.profile.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
voiceSecondsUsedToday: { increment: 0 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user