feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { toNextJsHandler } from "better-auth/next-js"
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth)
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const parent = await prisma.parent.findUnique({
|
||||
where: { id: session.user.id },
|
||||
include: {
|
||||
family: {
|
||||
include: { children: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!parent || !parent.family) {
|
||||
return NextResponse.json({ error: "Parent or family not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(parent.family.children)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { name, birthdate, profileNotes } = await request.json()
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "name required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parent = await prisma.parent.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true },
|
||||
})
|
||||
|
||||
if (!parent || !parent.familyId) {
|
||||
return NextResponse.json({ error: "Parent has no family" }, { status: 400 })
|
||||
}
|
||||
|
||||
const child = await prisma.child.create({
|
||||
data: {
|
||||
name,
|
||||
birthdate: birthdate ? new Date(birthdate) : undefined,
|
||||
profileNotes,
|
||||
familyId: parent.familyId,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(child, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { childId, exerciseId, skillCode, correct, promptLevel, responseMs, errorType, stage } = await request.json()
|
||||
|
||||
if (!childId || !exerciseId || !skillCode) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 })
|
||||
}
|
||||
|
||||
const sessionLog = await prisma.sessionLog.findFirst({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
})
|
||||
|
||||
if (!sessionLog) {
|
||||
return NextResponse.json({ error: "No active session" }, { status: 400 })
|
||||
}
|
||||
|
||||
await prisma.skillAttempt.create({
|
||||
data: {
|
||||
sessionLogId: sessionLog.id,
|
||||
skillCode,
|
||||
promptLevel: promptLevel ?? 0,
|
||||
correct,
|
||||
responseMs: responseMs ?? null,
|
||||
errorType: errorType ?? null,
|
||||
exerciseId,
|
||||
stage: stage ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Curriculum grade error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getNextExercise } from "@/lib/curriculum/engine"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
const topic = (request.nextUrl.searchParams.get("topic") || "lectura") as "lectura" | "numeros"
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const exercise = await getNextExercise(childId, topic)
|
||||
return NextResponse.json(exercise || { id: null, done: true })
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { initCurriculum, getLecturaStages, getNumerosStages } from "@/curriculum/index"
|
||||
import { getCurrentStage, getNextExercise } from "@/lib/curriculum/engine"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
const topic = (request.nextUrl.searchParams.get("topic") || "lectura") as "lectura" | "numeros"
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
await initCurriculum()
|
||||
|
||||
const stages = topic === "lectura" ? getLecturaStages() : getNumerosStages()
|
||||
const stageKeys = Object.keys(stages).map(Number).sort((a, b) => a - b)
|
||||
|
||||
const progress = prisma.curriculumProgress.findMany({
|
||||
where: { childId, topic },
|
||||
orderBy: { stage: "asc" },
|
||||
})
|
||||
|
||||
const allAttempts = prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
exerciseId: { not: null },
|
||||
stage: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { exerciseId: true, correct: true },
|
||||
})
|
||||
|
||||
const [progressRows, attempts] = await Promise.all([progress, allAttempts])
|
||||
|
||||
const mastered = new Set(
|
||||
attempts.filter((a) => a.correct).map((a) => a.exerciseId),
|
||||
)
|
||||
|
||||
const stagesInfo = stageKeys.map((sk) => {
|
||||
const s = stages[sk]
|
||||
const total = s?.exercises?.length || 0
|
||||
const completed = s?.exercises?.filter((e) => mastered.has(e.id)).length || 0
|
||||
const prog = progressRows.find((p) => p.stage === sk)
|
||||
return {
|
||||
stage: sk,
|
||||
name: s?.name || `Etapa ${sk}`,
|
||||
total,
|
||||
completed,
|
||||
unlocked: !!prog,
|
||||
completedAt: prog?.completedAt || null,
|
||||
percent: total > 0 ? Math.round((completed / total) * 100) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
const errorTypes = await prisma.skillAttempt.groupBy({
|
||||
by: ["errorType"],
|
||||
where: {
|
||||
errorType: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
_count: { errorType: true },
|
||||
})
|
||||
|
||||
const errorPatterns = errorTypes.map((e) => ({
|
||||
errorType: e.errorType,
|
||||
count: e._count.errorType,
|
||||
}))
|
||||
|
||||
const activeProgress = progressRows.find((p) => !p.completedAt)
|
||||
const currentStage = activeProgress?.stage || 1
|
||||
|
||||
return NextResponse.json({
|
||||
currentStage,
|
||||
stages: stagesInfo,
|
||||
errorPatterns,
|
||||
hasActiveSession: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const [totalSessions, todaySessions, milestones, fsrsCards, lastSession, weeklySessions, milestoneRecords, curriculumProgress, errorPatterns, allAttempts] =
|
||||
await Promise.all([
|
||||
prisma.sessionLog.count({ where: { childId } }),
|
||||
prisma.sessionLog.count({
|
||||
where: { childId, startedAt: { gte: today } },
|
||||
}),
|
||||
prisma.milestone.count({ where: { childId } }),
|
||||
prisma.fsrsCard.findMany({ where: { childId } }),
|
||||
prisma.sessionLog.findFirst({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
include: { skillAttempts: true },
|
||||
}),
|
||||
prisma.sessionLog.findMany({
|
||||
where: { childId, startedAt: { gte: new Date(Date.now() - 7 * 86400000) } },
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: { startedAt: true, durationSec: true },
|
||||
}),
|
||||
prisma.milestone.findMany({
|
||||
where: { childId },
|
||||
orderBy: { reachedAt: "desc" },
|
||||
select: { skillCode: true, reachedAt: true },
|
||||
}),
|
||||
prisma.curriculumProgress.findMany({
|
||||
where: { childId },
|
||||
orderBy: { stage: "asc" },
|
||||
}),
|
||||
prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
errorType: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { errorType: true },
|
||||
}),
|
||||
prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
correct: false,
|
||||
exerciseId: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { exerciseId: true, errorType: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const todayMinutes = todaySessions > 0
|
||||
? await prisma.sessionLog.aggregate({
|
||||
where: { childId, startedAt: { gte: today } },
|
||||
_sum: { durationSec: true },
|
||||
}).then((r) => Math.round((r._sum.durationSec ?? 0) / 60))
|
||||
: 0
|
||||
|
||||
const totalSkills = fsrsCards.length
|
||||
const masteredSkills = fsrsCards.filter((c) => c.reps >= 5 && c.stability > 30).length
|
||||
const masteryPercent = totalSkills > 0 ? Math.round((masteredSkills / totalSkills) * 100) : 0
|
||||
|
||||
const weeklyData = weeklySessions.map((s) => ({
|
||||
date: s.startedAt.toISOString().slice(0, 10),
|
||||
minutes: Math.round((s.durationSec ?? 0) / 60),
|
||||
}))
|
||||
|
||||
const skillBreakdown = fsrsCards.map((c) => ({
|
||||
skillCode: c.skillCode,
|
||||
stability: c.stability,
|
||||
difficulty: c.difficulty,
|
||||
reps: c.reps,
|
||||
due: c.dueAt.toISOString(),
|
||||
}))
|
||||
|
||||
const streak = await calculateStreak(childId)
|
||||
|
||||
const totalErrors = allAttempts.length
|
||||
const errorTypeCount = errorPatterns.reduce<Record<string, number>>((acc, e) => {
|
||||
const key = e.errorType || "sin-clasificar"
|
||||
acc[key] = (acc[key] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const stageProgress = curriculumProgress.map((cp) => ({
|
||||
topic: cp.topic,
|
||||
stage: cp.stage,
|
||||
completedAt: cp.completedAt?.toISOString() || null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
totalSessions,
|
||||
todaySessions,
|
||||
todayMinutes,
|
||||
masteryPercent,
|
||||
masteredSkills,
|
||||
totalSkills,
|
||||
milestones,
|
||||
streak,
|
||||
weeklyData,
|
||||
skillBreakdown,
|
||||
milestoneData: milestoneRecords,
|
||||
stageProgress,
|
||||
errorTypeCount,
|
||||
totalErrors,
|
||||
lastSession: lastSession
|
||||
? {
|
||||
startedAt: lastSession.startedAt.toISOString(),
|
||||
durationSec: lastSession.durationSec,
|
||||
skillsPracticed: lastSession.skillsPracticed,
|
||||
correctCount: lastSession.skillAttempts.filter((a) => a.correct).length,
|
||||
totalAttempts: lastSession.skillAttempts.length,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
async function calculateStreak(childId: string): Promise<number> {
|
||||
const logs = await prisma.sessionLog.findMany({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
select: { startedAt: true },
|
||||
})
|
||||
|
||||
if (logs.length === 0) return 0
|
||||
|
||||
const days = [
|
||||
...new Set(logs.map((l) => l.startedAt.toISOString().slice(0, 10))),
|
||||
].sort()
|
||||
.reverse()
|
||||
|
||||
let streak = 1
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
if (days[0] !== today && days[0] !== getYesterday()) return 0
|
||||
|
||||
for (let i = 1; i < days.length; i++) {
|
||||
const prev = new Date(days[i - 1])
|
||||
const curr = new Date(days[i])
|
||||
const diff = (prev.getTime() - curr.getTime()) / 86400000
|
||||
if (Math.round(diff) === 1) {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
function getYesterday(): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { buildFsrsCard, gradeCard, skillCodeFromGrade } from "@/lib/fsrs"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { childId, skillCode, correct, responseMs } = await request.json()
|
||||
if (!childId || !skillCode) {
|
||||
return NextResponse.json({ error: "childId and skillCode required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const existingCard = await prisma.fsrsCard.findFirst({
|
||||
where: { childId, skillCode },
|
||||
})
|
||||
|
||||
const grade = skillCodeFromGrade(correct, responseMs ?? 5000)
|
||||
const baseCard = buildFsrsCard({
|
||||
stability: existingCard?.stability,
|
||||
difficulty: existingCard?.difficulty,
|
||||
reps: existingCard?.reps,
|
||||
lapses: existingCard?.lapses,
|
||||
due: existingCard?.dueAt,
|
||||
})
|
||||
|
||||
const { card: updatedCard } = gradeCard(baseCard, grade)
|
||||
|
||||
if (existingCard) {
|
||||
await prisma.fsrsCard.update({
|
||||
where: { id: existingCard.id },
|
||||
data: {
|
||||
dueAt: updatedCard.due,
|
||||
stability: updatedCard.stability,
|
||||
difficulty: updatedCard.difficulty,
|
||||
reps: updatedCard.reps,
|
||||
lapses: updatedCard.lapses,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.fsrsCard.create({
|
||||
data: {
|
||||
childId,
|
||||
skillCode,
|
||||
dueAt: updatedCard.due,
|
||||
stability: updatedCard.stability,
|
||||
difficulty: updatedCard.difficulty,
|
||||
reps: updatedCard.reps,
|
||||
lapses: updatedCard.lapses,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("FSRS grade error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { buildFsrsCard, getRetrievability } from "@/lib/fsrs"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const dueCard = await prisma.fsrsCard.findFirst({
|
||||
where: {
|
||||
childId,
|
||||
dueAt: { lte: new Date() },
|
||||
},
|
||||
orderBy: { dueAt: "asc" },
|
||||
})
|
||||
|
||||
if (dueCard) {
|
||||
const card = buildFsrsCard({
|
||||
stability: dueCard.stability,
|
||||
difficulty: dueCard.difficulty,
|
||||
reps: dueCard.reps,
|
||||
lapses: dueCard.lapses,
|
||||
due: dueCard.dueAt,
|
||||
})
|
||||
return NextResponse.json({
|
||||
...dueCard,
|
||||
cardId: dueCard.id,
|
||||
retrievability: getRetrievability(card),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ skillCode: null })
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
const milestones = await prisma.milestone.findMany({
|
||||
where: { childId },
|
||||
orderBy: { reachedAt: "desc" },
|
||||
})
|
||||
return NextResponse.json(milestones)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { childId, skillCode } = await request.json()
|
||||
if (!childId || !skillCode) {
|
||||
return NextResponse.json({ error: "childId and skillCode required" }, { status: 400 })
|
||||
}
|
||||
const existing = await prisma.milestone.findUnique({
|
||||
where: { childId_skillCode: { childId, skillCode } },
|
||||
})
|
||||
if (existing) {
|
||||
return NextResponse.json(existing)
|
||||
}
|
||||
const milestone = await prisma.milestone.create({
|
||||
data: { childId, skillCode },
|
||||
})
|
||||
return NextResponse.json(milestone, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Milestone error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { randomUUID } from "crypto"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { deviceFingerprint, pairingCode, childId, action } = await request.json()
|
||||
|
||||
if (action === "generate") {
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
const code = randomUUID().slice(0, 8).toUpperCase()
|
||||
await prisma.pendingPairing.create({
|
||||
data: {
|
||||
code,
|
||||
childId,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ pairingCode: code })
|
||||
}
|
||||
|
||||
if (pairingCode) {
|
||||
const pending = await prisma.pendingPairing.findUnique({
|
||||
where: { code: pairingCode },
|
||||
})
|
||||
if (!pending || pending.expiresAt < new Date()) {
|
||||
return NextResponse.json({ error: "Código inválido o expirado" }, { status: 404 })
|
||||
}
|
||||
|
||||
const targetChildId = childId || pending.childId
|
||||
if (!targetChildId) {
|
||||
return NextResponse.json({ error: "Sin niño asociado" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (childId && !pending.childId) {
|
||||
await prisma.pendingPairing.update({
|
||||
where: { id: pending.id },
|
||||
data: { childId },
|
||||
})
|
||||
}
|
||||
|
||||
const fp = deviceFingerprint || pending.deviceFingerprint
|
||||
if (!fp) {
|
||||
return NextResponse.json({ error: "Se requiere deviceFingerprint" }, { status: 400 })
|
||||
}
|
||||
|
||||
const device = await prisma.device.create({
|
||||
data: {
|
||||
childId: targetChildId,
|
||||
deviceFingerprint: fp,
|
||||
role: "child",
|
||||
},
|
||||
include: { child: true },
|
||||
})
|
||||
|
||||
await prisma.pendingPairing.delete({ where: { id: pending.id } })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
childId: device.childId,
|
||||
childName: device.child.name,
|
||||
})
|
||||
}
|
||||
|
||||
if (deviceFingerprint) {
|
||||
const existingDevice = await prisma.device.findFirst({
|
||||
where: { deviceFingerprint },
|
||||
})
|
||||
if (existingDevice) {
|
||||
return NextResponse.json({
|
||||
paired: true,
|
||||
childId: existingDevice.childId,
|
||||
role: existingDevice.role,
|
||||
})
|
||||
}
|
||||
|
||||
const code = randomUUID().slice(0, 8).toUpperCase()
|
||||
await prisma.pendingPairing.create({
|
||||
data: {
|
||||
code,
|
||||
deviceFingerprint,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({
|
||||
paired: false,
|
||||
pairingCode: code,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error("Pairing error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { childId, session, attempts } = body
|
||||
|
||||
if (!childId || !session) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 })
|
||||
}
|
||||
|
||||
const sessionLog = await prisma.sessionLog.create({
|
||||
data: {
|
||||
childId,
|
||||
startedAt: new Date(session.startedAt),
|
||||
endedAt: session.endedAt ? new Date(session.endedAt) : undefined,
|
||||
durationSec: session.durationSec,
|
||||
skillsPracticed: session.skillsPracticed,
|
||||
skillAttempts: {
|
||||
create: (attempts || []).map((a: any) => ({
|
||||
skillCode: a.skillCode,
|
||||
promptLevel: a.promptLevel ?? 0,
|
||||
correct: a.correct,
|
||||
responseMs: a.responseMs,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, sessionId: sessionLog.id })
|
||||
} catch (error) {
|
||||
console.error("Sync error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { execSync } from "child_process"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { createHash } from "crypto"
|
||||
import { readFile, mkdir } from "fs/promises"
|
||||
import { join } from "path"
|
||||
|
||||
const CACHE_DIR = join(process.cwd(), "public", "tts-cache")
|
||||
|
||||
async function ensureCacheDir() {
|
||||
if (!existsSync(CACHE_DIR)) {
|
||||
await mkdir(CACHE_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function getCachePath(text: string): string {
|
||||
const hash = createHash("md5").update(text).digest("hex")
|
||||
return join(CACHE_DIR, `${hash}.wav`)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const text = request.nextUrl.searchParams.get("text")
|
||||
if (!text || text.length > 200) {
|
||||
return NextResponse.json({ error: "text param required (max 200 chars)" }, { status: 400 })
|
||||
}
|
||||
|
||||
await ensureCacheDir()
|
||||
const cachePath = getCachePath(text)
|
||||
|
||||
if (!existsSync(cachePath)) {
|
||||
try {
|
||||
execSync(
|
||||
`espeak-ng -v es-mx -s 140 -p 60 "${text.replace(/"/g, '\\"')}" -w "${cachePath}"`,
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "TTS generation failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const audioBuffer = await readFile(cachePath)
|
||||
return new NextResponse(audioBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
"Content-Length": audioBuffer.length.toString(),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user