harden database interactions and improve error handling

This commit is contained in:
ImBenji
2026-04-28 17:05:48 +01:00
parent e1f168a302
commit b9f7d1ff25
16 changed files with 980 additions and 159 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => {
const pathStr = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
// prevent path traversal
const baseDir = resolve(process.cwd(), "private/audio");
const baseDir = resolve(process.cwd(), "data/audio");
const filePath = normalize(resolve(baseDir, pathStr));
if (!filePath.startsWith(baseDir)) {
+1 -9
View File
@@ -1,5 +1,5 @@
import { db } from "../../../db/index";
import { courses, topics, userProgress, lessons } from "../../../db/schema";
import { courses, topics, lessons } from "../../../db/schema";
import { eq, inArray } from "drizzle-orm";
export default defineEventHandler(async (event) => {
@@ -16,13 +16,6 @@ export default defineEventHandler(async (event) => {
orderBy: (t, { asc }) => asc(t.order),
});
const progressRows = await db.query.userProgress.findMany({
where: eq(userProgress.courseId, id),
});
const progressMap: Record<string, typeof progressRows[0]> = {};
for (const p of progressRows) progressMap[p.topicId] = p;
const topicIds = topicRows.map((t) => t.id);
const lessonRows = topicIds.length
@@ -37,7 +30,6 @@ export default defineEventHandler(async (event) => {
topics: topicRows.map((t) => ({
...t,
prerequisiteTopicIds: JSON.parse(t.prerequisiteTopicIds ?? "[]"),
progress: progressMap[t.id] ?? null,
hasLesson: !!lessonMap[t.id],
lessonCost: lessonMap[t.id]?.costTotal ?? null,
})),
+9 -18
View File
@@ -1,6 +1,6 @@
import { db } from "../../db/index";
import { courses, topics, userProgress } from "../../db/schema";
import { eq, inArray } from "drizzle-orm";
import { courses, topics } from "../../db/schema";
import { inArray } from "drizzle-orm";
export default defineEventHandler(async () => {
const allCourses = await db.query.courses.findMany({
@@ -11,27 +11,18 @@ export default defineEventHandler(async () => {
const courseIds = allCourses.map((c) => c.id);
const [allTopics, allProgress] = await Promise.all([
db.query.topics.findMany({ where: inArray(topics.courseId, courseIds) }),
db.query.userProgress.findMany({ where: inArray(userProgress.courseId, courseIds) }),
]);
const allTopics = await db.query.topics.findMany({ where: inArray(topics.courseId, courseIds) });
// group in memory
const topicsByCourse = new Map<string, number>();
const topicsByCourse = new Map<string, string[]>();
for (const t of allTopics) {
topicsByCourse.set(t.courseId, (topicsByCourse.get(t.courseId) ?? 0) + 1);
}
const completedByCourse = new Map<string, number>();
for (const p of allProgress) {
if (p.lessonComplete) {
completedByCourse.set(p.courseId, (completedByCourse.get(p.courseId) ?? 0) + 1);
}
const arr = topicsByCourse.get(t.courseId) ?? [];
arr.push(t.id);
topicsByCourse.set(t.courseId, arr);
}
return allCourses.map((course) => ({
...course,
topicCount: topicsByCourse.get(course.id) ?? 0,
completedCount: completedByCourse.get(course.id) ?? 0,
topicCount: topicsByCourse.get(course.id)?.length ?? 0,
topicIds: topicsByCourse.get(course.id) ?? [],
}));
});
@@ -0,0 +1,83 @@
import { db } from "../../../../db/index";
import { lessons } from "../../../../db/schema";
import { eq } from "drizzle-orm";
import {
generateStepTTS,
generateQuestionTTS,
generateOptionTTS,
} from "../../../../utils/generateTTS";
import { verifyLicenseKey } from "../../../../utils/license";
export default defineEventHandler(async (event) => {
const key = getHeader(event, "x-license-key") ?? "";
if (!verifyLicenseKey(key)) {
throw createError({ statusCode: 401, message: "Unauthorized" });
}
const topicId = getRouterParam(event, "id")!;
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.topicId, topicId) });
if (!lesson) throw createError({ statusCode: 404, message: "Lesson not found" });
const content = JSON.parse(lesson.content);
const steps: any[] = content.steps ?? [];
type Task = () => Promise<void>;
const tasks: Task[] = [];
for (let si = 0; si < steps.length; si++) {
const step = steps[si];
const siCopy = si;
if (step.type === "concept" || step.type === "example") {
const text = [step.body, step.callout].filter(Boolean).join(" ");
if (!text.trim()) continue;
tasks.push(async () => {
const r = await generateStepTTS(text, lesson.id, siCopy);
if (r) { step.audioPath = r.audioPath; step.audioChunks = r.audioChunks; }
});
} else if (step.type === "summary") {
const text = Array.isArray(step.bullets) ? step.bullets.join(". ") : "";
if (!text.trim()) continue;
tasks.push(async () => {
const r = await generateStepTTS(text, lesson.id, siCopy);
if (r) { step.audioPath = r.audioPath; step.audioChunks = r.audioChunks; }
});
} else if (step.type === "question") {
if (step.body?.trim()) {
tasks.push(async () => {
const r = await generateQuestionTTS(step.body, lesson.id, siCopy);
if (r) { step.questionAudioPath = r.audioPath; step.questionAudioChunks = r.audioChunks; }
});
}
if (Array.isArray(step.options)) {
step.optionAudioPaths = new Array(step.options.length).fill(null);
for (let oi = 0; oi < step.options.length; oi++) {
const optText = step.options[oi];
const oiCopy = oi;
if (optText?.trim()) {
tasks.push(async () => {
const r = await generateOptionTTS(optText, lesson.id, siCopy, oiCopy);
if (r) step.optionAudioPaths[oiCopy] = r.audioPath;
});
}
}
}
}
}
const BATCH = 3;
for (let i = 0; i < tasks.length; i += BATCH) {
await Promise.all(tasks.slice(i, i + BATCH).map((fn) => fn()));
}
content.steps = steps;
await db.update(lessons).set({ content: JSON.stringify(content) }).where(eq(lessons.id, lesson.id));
return { status: "ready" };
});
@@ -0,0 +1,31 @@
import { db } from "../../../../db/index";
import { topics, lessons } from "../../../../db/schema";
import { eq } from "drizzle-orm";
import { generateLesson } from "../../../../utils/generateLesson";
import { verifyLicenseKey } from "../../../../utils/license";
export default defineEventHandler(async (event) => {
const key = getHeader(event, "x-license-key") ?? "";
if (!verifyLicenseKey(key)) {
throw createError({ statusCode: 401, message: "Unauthorized" });
}
const id = getRouterParam(event, "id")!;
const topic = await db.query.topics.findFirst({ where: eq(topics.id, id) });
if (!topic) throw createError({ statusCode: 404, message: "Topic not found" });
// wipe existing lesson
await db.delete(lessons).where(eq(lessons.topicId, id));
// reset topic status so generateLesson doesnt bail
await db.update(topics).set({ status: "pending" }).where(eq(topics.id, id));
try {
await generateLesson(id);
return { status: "ready" };
} catch (err: any) {
console.error(`[dev/regenerate-lesson] topic ${id}: ${err?.message ?? err}`);
return { status: "error" };
}
});
+371
View File
@@ -0,0 +1,371 @@
import { db } from "../db/index";
import { topics, lessons } from "../db/schema";
import { eq, and, lt } from "drizzle-orm";
import { generateLesson } from "../utils/generateLesson";
import { generateBranches } from "../utils/generateBranches";
import {
generateStepTTS,
generateQuestionTTS,
generateOptionTTS,
generateTTSToPath,
} from "../utils/generateTTS";
const INTERVAL_MS = 5 * 60 * 1000;
const STALE_MINUTES = 15;
function staleCutoff() {
return new Date(Date.now() - STALE_MINUTES * 60 * 1000).toISOString();
}
async function repairCycle() {
// ── 1. failed topic generation ──────────────────────────────────────────
try {
const errored = await db
.update(topics)
.set({ status: "pending" })
.where(eq(topics.status, "error"))
.returning({ id: topics.id });
if (errored.length > 0) {
console.log(`[repair] resetting ${errored.length} errored topic(s) to pending`);
for (const t of errored) {
generateLesson(t.id).catch((err: any) => {
console.error(`[repair] generateLesson failed for ${t.id.slice(0, 8)}: ${err?.message ?? err}`);
});
}
}
} catch (err: any) {
console.error("[repair] failed to repair errored topics:", err?.message ?? err);
}
// ── 2. stale generating topics (server died mid-generation) ─────────────
try {
// topics dont have createdAt so we use a rough heuristic: if a lesson
// exists for this topic, generation got far enough; otherwise something
// died early. Either way reset to pending and let generateLesson figure it out.
const stuckGenerating = await db.query.topics.findMany({
where: eq(topics.status, "generating"),
});
const cutoff = staleCutoff();
for (const t of stuckGenerating) {
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.topicId, t.id) });
if (lesson) {
// lesson row exists — server died after insert but before topic status update
console.log(`[repair] topic ${t.id.slice(0, 8)} stuck generating but lesson exists — marking ready`);
await db.update(topics).set({ status: "ready" }).where(eq(topics.id, t.id));
} else {
// no lesson at all — server died before the insert, need to regenerate
console.log(`[repair] topic ${t.id.slice(0, 8)} stuck generating with no lesson — resetting to pending`);
await db.update(topics).set({ status: "pending" }).where(eq(topics.id, t.id));
generateLesson(t.id).catch((err: any) => {
console.error(`[repair] generateLesson (stuck) failed for ${t.id.slice(0, 8)}: ${err?.message ?? err}`);
});
}
}
} catch (err: any) {
console.error("[repair] failed to repair stuck generating topics:", err?.message ?? err);
}
// ── 3. failed branch generation ─────────────────────────────────────────
try {
const errored = await db
.update(lessons)
.set({ branchStatus: "pending" })
.where(eq(lessons.branchStatus, "error"))
.returning({ id: lessons.id, topicId: lessons.topicId });
if (errored.length > 0) {
console.log(`[repair] resetting ${errored.length} errored lesson branch(es) to pending`);
for (const l of errored) {
generateBranches(l.topicId, l.id).catch((err: any) => {
console.error(`[repair] generateBranches failed for ${l.id.slice(0, 8)}: ${err?.message ?? err}`);
});
}
}
} catch (err: any) {
console.error("[repair] failed to repair errored lesson branches:", err?.message ?? err);
}
// ── 4. stale generating branches (server died mid-branch) ────────────────
try {
const cutoff = staleCutoff();
const stale = await db
.update(lessons)
.set({ branchStatus: "pending" })
.where(
and(
eq(lessons.branchStatus, "generating"),
lt(lessons.createdAt, cutoff)
)
)
.returning({ id: lessons.id, topicId: lessons.topicId });
if (stale.length > 0) {
console.log(`[repair] resetting ${stale.length} stale generating branch(es) to pending`);
for (const l of stale) {
generateBranches(l.topicId, l.id).catch((err: any) => {
console.error(`[repair] generateBranches (stale generating) failed for ${l.id.slice(0, 8)}: ${err?.message ?? err}`);
});
}
}
} catch (err: any) {
console.error("[repair] failed to repair stale generating branches:", err?.message ?? err);
}
// ── 5. stale pending branches (never kicked off or abandoned) ────────────
try {
const cutoff = staleCutoff();
const stale = await db
.update(lessons)
.set({ branchStatus: "pending" })
.where(
and(
eq(lessons.branchStatus, "pending"),
lt(lessons.createdAt, cutoff)
)
)
.returning({ id: lessons.id, topicId: lessons.topicId });
if (stale.length > 0) {
console.log(`[repair] restarting ${stale.length} stale pending branch(es)`);
for (const l of stale) {
generateBranches(l.topicId, l.id).catch((err: any) => {
console.error(`[repair] generateBranches (stale pending) failed for ${l.id.slice(0, 8)}: ${err?.message ?? err}`);
});
}
}
} catch (err: any) {
console.error("[repair] failed to repair stale pending branches:", err?.message ?? err);
}
// ── 6. missing main lesson audio ─────────────────────────────────────────
try {
const readyTopics = await db.query.topics.findMany({ where: eq(topics.status, "ready") });
for (const topic of readyTopics) {
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.topicId, topic.id) });
if (!lesson) continue;
let content: { keyConcepts: string[]; analogiesUsed: string[]; steps: any[] };
try {
content = JSON.parse(lesson.content);
} catch {
continue;
}
if (!Array.isArray(content.steps)) continue;
type Task = () => Promise<void>;
const tasks: Task[] = [];
let dirty = false;
for (let si = 0; si < content.steps.length; si++) {
const step = content.steps[si];
if (step.type === "concept" || step.type === "example" || step.type === "summary") {
const text = step.type === "summary"
? (Array.isArray(step.bullets) ? step.bullets.join(". ") : "")
: [step.body, step.callout].filter(Boolean).join(" ");
if (!text.trim() || step.audioPath) continue;
const siCopy = si;
tasks.push(async () => {
const r = await generateStepTTS(text, lesson.id, siCopy);
if (r) {
content.steps[siCopy].audioPath = r.audioPath;
content.steps[siCopy].audioChunks = r.audioChunks;
dirty = true;
}
});
} else if (step.type === "question") {
if (step.body?.trim() && !step.questionAudioPath) {
const siCopy = si;
const body = step.body;
tasks.push(async () => {
const r = await generateQuestionTTS(body, lesson.id, siCopy);
if (r) {
content.steps[siCopy].questionAudioPath = r.audioPath;
content.steps[siCopy].questionAudioChunks = r.audioChunks;
dirty = true;
}
});
}
if (Array.isArray(step.options)) {
if (!step.optionAudioPaths) {
content.steps[si].optionAudioPaths = new Array(step.options.length).fill(null);
}
for (let oi = 0; oi < step.options.length; oi++) {
const optText = step.options[oi];
if (!optText?.trim()) continue;
if (content.steps[si].optionAudioPaths?.[oi]) continue;
const siCopy = si;
const oiCopy = oi;
tasks.push(async () => {
const r = await generateOptionTTS(optText, lesson.id, siCopy, oiCopy);
if (r) {
content.steps[siCopy].optionAudioPaths[oiCopy] = r.audioPath;
dirty = true;
}
});
}
}
}
}
if (tasks.length === 0) continue;
console.log(`[repair] lesson ${lesson.id.slice(0, 8)}${tasks.length} missing audio task(s)`);
const BATCH = 3;
for (let i = 0; i < tasks.length; i += BATCH) {
await Promise.all(tasks.slice(i, i + BATCH).map((fn) => fn()));
}
if (dirty) {
await db.update(lessons)
.set({ content: JSON.stringify(content) })
.where(eq(lessons.id, lesson.id));
console.log(`[repair] lesson ${lesson.id.slice(0, 8)} audio repaired`);
}
}
} catch (err: any) {
console.error("[repair] failed during lesson audio repair:", err?.message ?? err);
}
// ── 7. missing branch audio ───────────────────────────────────────────────
try {
const readyLessons = await db.query.lessons.findMany({
where: eq(lessons.branchStatus, "ready"),
});
for (const lesson of readyLessons) {
let content: { keyConcepts: string[]; analogiesUsed: string[]; steps: any[] };
try {
content = JSON.parse(lesson.content);
} catch {
continue;
}
if (!Array.isArray(content.steps)) continue;
type Task = () => Promise<void>;
const tasks: Task[] = [];
let dirty = false;
for (let si = 0; si < content.steps.length; si++) {
const step = content.steps[si];
if (step.type !== "question" || !step.branches) continue;
const wrongOptions = (step.options as string[]).filter((o: string) => o !== step.answer);
for (let bi = 0; bi < wrongOptions.length; bi++) {
const wrongOpt = wrongOptions[bi];
const branch = step.branches[wrongOpt];
if (!branch) continue;
for (let bsi = 0; bsi < (branch.steps ?? []).length; bsi++) {
const bStep = branch.steps[bsi];
const text = [bStep.body, bStep.callout].filter(Boolean).join(" ");
if (!text.trim() || bStep.audioPath) continue;
const siCopy = si; const biCopy = bi; const bsiCopy = bsi; const woCopy = wrongOpt;
tasks.push(async () => {
const r = await generateTTSToPath(text, lesson.id, `branch_${siCopy}_${biCopy}_step_${bsiCopy}.mp3`);
if (r) {
content.steps[siCopy].branches[woCopy].steps[bsiCopy].audioPath = r.audioPath;
content.steps[siCopy].branches[woCopy].steps[bsiCopy].audioChunks = r.audioChunks;
dirty = true;
}
});
}
const cq = branch.confirmQuestion;
if (cq?.body?.trim() && !cq.questionAudioPath) {
const siCopy = si; const biCopy = bi; const woCopy = wrongOpt;
tasks.push(async () => {
const r = await generateTTSToPath(cq.body, lesson.id, `branch_${siCopy}_${biCopy}_confirm_q.mp3`);
if (r) {
content.steps[siCopy].branches[woCopy].confirmQuestion.questionAudioPath = r.audioPath;
content.steps[siCopy].branches[woCopy].confirmQuestion.questionAudioChunks = r.audioChunks;
dirty = true;
}
});
}
if (Array.isArray(cq?.options)) {
if (!cq.optionAudioPaths) {
branch.confirmQuestion.optionAudioPaths = new Array(cq.options.length).fill(null);
}
for (let oi = 0; oi < cq.options.length; oi++) {
const optText = cq.options[oi];
if (!optText?.trim()) continue;
if (branch.confirmQuestion.optionAudioPaths?.[oi]) continue;
const siCopy = si; const biCopy = bi; const oiCopy = oi; const woCopy = wrongOpt;
tasks.push(async () => {
const r = await generateTTSToPath(optText, lesson.id, `branch_${siCopy}_${biCopy}_confirm_opt_${oiCopy}.mp3`);
if (r) {
content.steps[siCopy].branches[woCopy].confirmQuestion.optionAudioPaths[oiCopy] = r.audioPath;
dirty = true;
}
});
}
}
if (branch.hardStop?.trim() && !branch.hardStopAudioPath) {
const siCopy = si; const biCopy = bi; const woCopy = wrongOpt;
tasks.push(async () => {
const r = await generateTTSToPath(branch.hardStop, lesson.id, `branch_${siCopy}_${biCopy}_hardstop.mp3`);
if (r) {
content.steps[siCopy].branches[woCopy].hardStopAudioPath = r.audioPath;
dirty = true;
}
});
}
}
}
if (tasks.length === 0) continue;
console.log(`[repair] lesson ${lesson.id.slice(0, 8)}${tasks.length} missing branch audio task(s)`);
const BATCH = 3;
for (let i = 0; i < tasks.length; i += BATCH) {
await Promise.all(tasks.slice(i, i + BATCH).map((fn) => fn()));
}
if (dirty) {
await db.update(lessons)
.set({ content: JSON.stringify(content) })
.where(eq(lessons.id, lesson.id));
console.log(`[repair] lesson ${lesson.id.slice(0, 8)} branch audio repaired`);
}
}
} catch (err: any) {
console.error("[repair] failed during branch audio repair:", err?.message ?? err);
}
}
export default defineNitroPlugin(() => {
console.log("[repair] repairBrokenLessons plugin started");
repairCycle();
setInterval(repairCycle, INTERVAL_MS);
});
+4 -1
View File
@@ -4,7 +4,10 @@ import { eq } from "drizzle-orm";
import { askAI } from "./openrouter";
function parseJSON<T>(raw: string): T {
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
let text = raw
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/^\s*thought\s*\n/i, "")
.trim();
try {
return JSON.parse(text);
} catch {
+5 -5
View File
@@ -9,7 +9,10 @@ function log(lessonId: string, msg: string) {
}
function parseJSON<T>(raw: string): T {
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
let text = raw
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/^\s*thought\s*\n/i, "")
.trim();
try {
return JSON.parse(text);
} catch {
@@ -195,10 +198,7 @@ Only generate branches for the 3 wrong options. Do not generate a branch for the
}
}
const BATCH = 4;
for (let i = 0; i < ttsTasks.length; i += BATCH) {
await Promise.all(ttsTasks.slice(i, i + BATCH).map((fn) => fn()));
}
await Promise.all(ttsTasks.map((fn) => fn()));
log(lessonId, ` step ${si} branch TTS done`);
branchSuccesses++;
+4 -1
View File
@@ -20,7 +20,10 @@ async function setStage(courseId: string, stage: Stage) {
}
function parseJSON<T>(raw: string): T {
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
let text = raw
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/^\s*thought\s*\n/i, "")
.trim();
try {
return JSON.parse(text);
} catch {
+6 -7
View File
@@ -14,8 +14,11 @@ function log(topicId: string, msg: string) {
}
function parseJSON<T>(raw: string): T {
// strip <think>...</think> blocks from reasoning models (deepseek-r1 etc.)
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
// strip reasoning preambles — <think>...</think> tags and bare "thought\n" prefix
let text = raw
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/^\s*thought\s*\n/i, "")
.trim();
try {
return JSON.parse(text);
} catch {
@@ -103,11 +106,7 @@ async function generateLessonAudio(
}
}
// run in batches of 4
const BATCH = 4;
for (let i = 0; i < tasks.length; i += BATCH) {
await Promise.all(tasks.slice(i, i + BATCH).map((fn) => fn()));
}
await Promise.all(tasks.map((fn) => fn()));
return { steps, cost };
}
+92 -37
View File
@@ -1,17 +1,47 @@
import { mkdir, writeFile, access } from "fs/promises";
import { resolve } from "path";
import { askAI } from "./openrouter";
import { ttsLimiter } from "./limiter";
const NARRATION_SYSTEM_PROMPT = `You are a narration script editor for an AI voice actor. Your job is to take educational lesson text and prepare it to be read aloud naturally and engagingly.
const NARRATION_SYSTEM_PROMPT = `You are a narration script editor for an AI voice actor reading educational lesson content. Your job is to prepare text so it sounds natural, warm, and engaging when spoken aloud.
Rules:
- Do NOT change the meaning, facts, or structure of the content. You are not rewriting it.
- Fix anything that would sound awkward or robotic when spoken: remove markdown formatting (asterisks, backticks, hashes), spell out acronyms where helpful, rephrase code snippets or technical shorthand into speakable language.
- Add square bracket cues to give the voice character and pacing. These are the only ones you may use: [pause], [long pause], [sighs], [laughs], [clears throat], [hesitates].
- Use [pause] at natural breath points — after key ideas, before a new concept, or mid-sentence where a human would pause for effect. Don't overdo it; one every few sentences at most.
- Use [sighs] or [laughs] very sparingly — only where a human narrator genuinely would. A [sighs] before a tricky concept, a [laughs] when something is ironic or light. Maybe once or twice per lesson, if at all.
- Keep the tone warm, clear, and conversational — like a knowledgeable friend explaining something, not a textbook being read aloud.
- Return ONLY the modified narration text. No commentary, no explanation, no quotes around the output.`;
## Content rules
- Do NOT change the meaning, facts, or structure. You are not rewriting the lesson.
- Fix anything that sounds robotic or awkward when spoken: strip markdown (asterisks, backticks, hashes, bullet dashes), spell out acronyms where helpful, rephrase code snippets or URLs into speakable language (e.g. "the fetch function" not "\`fetch()\`").
## Voice control tags
You have a rich set of square bracket tags to shape how the voice sounds. Use them tastefully — a well-placed tag is powerful, overuse kills it.
**Pacing**
[pause] — a natural breath beat, use at transitions or after key ideas
[long pause] — a longer held silence, use for emphasis or before something important
[short pause] — a very brief beat
**Non-verbal sounds** (use sparingly, one or two per lesson max)
[breath] — a natural inhale, good at the start of a new thought or after a long sentence
[sighs] — before a tricky concept, or when something is a bit of a pain
[laughs] — when something is genuinely ironic, surprising, or lightly funny
[chuckles] — softer than laughs, more conversational
[exhales] — a quiet breath out, good for winding down a dense section
[clears throat] — before jumping into something more formal or detailed
[gasp] — for something genuinely surprising
**Delivery style** (can be chained, effect lasts until next tag or end of sentence)
[curious] — lean in, raise intrigue
[excited] — energy up, good for "here's the cool part"
[whispers] — draw the listener in for an aside
[nervous] — for content where a student might feel anxious (e.g. exams)
[calm] — reassuring, slows things down
[sarcastic] — very sparingly, only when the tone clearly calls for it
## Placement guidance
- [pause] can go mid-sentence before a key term, or at the end of a sentence before shifting topic
- Emotional tags go BEFORE the text they should affect, and return to neutral naturally after a sentence or two
- Don't open with a tag — let the voice settle first
- Avoid back-to-back tags with no words between them
## Output
Return ONLY the modified narration text. No commentary, no labels, no quotes.`;
async function humaniseTTSText(text: string): Promise<string> {
try {
@@ -47,26 +77,45 @@ async function callElevenLabs(
apiKey: string,
voiceId: string
): Promise<{ audio: Buffer; chunks: AudioChunk[]; cost: number } | null> {
const res = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/with-timestamps`,
{
method: "POST",
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
text,
model_id: "eleven_turbo_v2_5",
output_format: "mp3_44100_128",
}),
signal: AbortSignal.timeout(60_000),
const MAX_RETRIES = 5;
let delay = 2000;
let res!: Response;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
res = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/with-timestamps`,
{
method: "POST",
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
text,
model_id: "eleven_v3",
output_format: "mp3_44100_128",
}),
signal: AbortSignal.timeout(60_000),
}
);
if (res.ok) break;
if (res.status === 429 && attempt < MAX_RETRIES) {
console.warn(`[tts] ElevenLabs 429 — retry ${attempt + 1}/${MAX_RETRIES} in ${delay}ms`);
await new Promise((r) => setTimeout(r, delay));
delay *= 2;
continue;
}
);
const errText = await res.text().catch(() => "");
console.error(`[tts] ElevenLabs error ${res.status}: ${errText}`);
return null;
}
if (!res.ok) {
const errText = await res.text().catch(() => "");
console.error(`[tts] ElevenLabs error ${res.status}: ${errText}`);
console.error(`[tts] ElevenLabs failed after ${MAX_RETRIES} retries: ${res.status} ${errText}`);
return null;
}
@@ -140,6 +189,8 @@ async function callFishAudio(
format: "mp3",
mp3_bitrate: 128,
streaming: false,
normalize: false,
model: "s2",
}),
signal: AbortSignal.timeout(60_000),
});
@@ -172,13 +223,15 @@ async function callTTS(
const apiKey = config.fishAudioApiKey as string;
const voiceId = (config.public.fishAudioVoiceId || config.fishAudioVoiceId) as string;
if (!apiKey) return null;
return callFishAudio(text, apiKey, voiceId);
console.log(`[tts] queued (fish) — active: ${ttsLimiter.active}, queued: ${ttsLimiter.queued}`);
return ttsLimiter.run(() => callFishAudio(text, apiKey, voiceId));
}
const apiKey = config.elevenlabsApiKey as string;
const voiceId = (config.public.elevenlabsVoiceId || config.elevenlabsVoiceId) as string;
if (!apiKey) return null;
return callElevenLabs(text, apiKey, voiceId);
console.log(`[tts] queued (elevenlabs) — active: ${ttsLimiter.active}, queued: ${ttsLimiter.queued}`);
return ttsLimiter.run(() => callElevenLabs(text, apiKey, voiceId));
}
@@ -191,12 +244,12 @@ export async function generateStepTTS(
const result = await callTTS(text);
if (!result) return null;
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
const dir = resolve(process.cwd(), `data/audio/lessons/${lessonId}`);
await mkdir(dir, { recursive: true });
const filename = `step_${stepIndex}.mp3`;
await writeFile(`${dir}/${filename}`, result.audio);
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
const audioPath = `/api/audio/lessons/${lessonId}/${filename}`;
console.log(`[tts] step ${stepIndex} for lesson ${lessonId}${result.chunks.length} chunks | $${result.cost.toFixed(4)}`);
return { audioPath, audioChunks: result.chunks, cost: result.cost };
} catch (err: any) {
@@ -214,12 +267,12 @@ export async function generateQuestionTTS(
const result = await callTTS(text);
if (!result) return null;
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
const dir = resolve(process.cwd(), `data/audio/lessons/${lessonId}`);
await mkdir(dir, { recursive: true });
const filename = `step_${stepIndex}_question.mp3`;
await writeFile(`${dir}/${filename}`, result.audio);
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
const audioPath = `/api/audio/lessons/${lessonId}/${filename}`;
return { audioPath, audioChunks: result.chunks, cost: result.cost };
} catch (err: any) {
console.error(`[tts] question ${stepIndex} for lesson ${lessonId} failed: ${err?.message ?? err}`);
@@ -237,12 +290,12 @@ export async function generateOptionTTS(
const result = await callTTS(text);
if (!result) return null;
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
const dir = resolve(process.cwd(), `data/audio/lessons/${lessonId}`);
await mkdir(dir, { recursive: true });
const filename = `step_${stepIndex}_option_${optionIndex}.mp3`;
await writeFile(`${dir}/${filename}`, result.audio);
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
const audioPath = `/api/audio/lessons/${lessonId}/${filename}`;
return { audioPath, cost: result.cost };
} catch (err: any) {
console.error(`[tts] option ${stepIndex}/${optionIndex} for lesson ${lessonId} failed: ${err?.message ?? err}`);
@@ -282,6 +335,8 @@ export async function generateClip(
format: "mp3",
mp3_bitrate: 128,
streaming: false,
normalize: false,
model: "s2",
}),
signal: AbortSignal.timeout(60_000),
});
@@ -304,7 +359,7 @@ export async function generateClip(
},
body: JSON.stringify({
text,
model_id: opts?.model ?? "eleven_turbo_v2_5",
model_id: opts?.model ?? "eleven_v3",
output_format: "mp3_44100_128",
...(opts?.voice_settings ? { voice_settings: opts.voice_settings } : {}),
}),
@@ -323,7 +378,7 @@ export async function generateClip(
buffer = Buffer.from(await res.arrayBuffer());
}
await mkdir(resolve(process.cwd(), "public/audio/labels"), { recursive: true });
await mkdir(resolve(process.cwd(), "data/audio/labels"), { recursive: true });
await writeFile(outPath, buffer);
return { cost };
} catch (err: any) {
@@ -341,11 +396,11 @@ export async function generateTTSToPath(
const result = await callTTS(text);
if (!result) return null;
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
const dir = resolve(process.cwd(), `data/audio/lessons/${lessonId}`);
await mkdir(dir, { recursive: true });
await writeFile(`${dir}/${filename}`, result.audio);
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
const audioPath = `/api/audio/lessons/${lessonId}/${filename}`;
return { audioPath, audioChunks: result.chunks, cost: result.cost };
} catch (err: any) {
console.error(`[tts] ${filename} for lesson ${lessonId} failed: ${err?.message ?? err}`);
+41
View File
@@ -0,0 +1,41 @@
class Limiter {
private running = 0;
private queue: (() => void)[] = [];
constructor(private max: number) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
private acquire(): Promise<void> {
if (this.running < this.max) {
this.running++;
return Promise.resolve();
}
return new Promise(resolve => {
this.queue.push(() => { this.running++; resolve(); });
});
}
private release() {
this.running--;
const next = this.queue.shift();
if (next) next();
}
get active() { return this.running; }
get queued() { return this.queue.length; }
}
// ElevenLabs recommends max 2-3 concurrent requests
export const ttsLimiter = new Limiter(2);
// OpenRouter concurrent request cap
export const aiLimiter = new Limiter(4);
+5 -2
View File
@@ -1,3 +1,5 @@
import { aiLimiter } from "./limiter";
interface Message {
role: "system" | "user" | "assistant";
content: string;
@@ -39,7 +41,8 @@ export async function askAI(messages: Message[], options: AskAIOptions = {}): Pr
const t0 = Date.now();
try {
const res = await $fetch<{ id?: string; choices: { message: { content: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number; cost?: number } }>(
console.log(`[openrouter] queued — active: ${aiLimiter.active}, queued: ${aiLimiter.queued}`);
const res = await aiLimiter.run(() => $fetch<{ id?: string; choices: { message: { content: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number; cost?: number } }>(
"https://openrouter.ai/api/v1/chat/completions",
{
method: "POST",
@@ -57,7 +60,7 @@ export async function askAI(messages: Message[], options: AskAIOptions = {}): Pr
},
signal: AbortSignal.timeout(600_000),
}
);
));
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
const content = res.choices?.[0]?.message?.content;