Skip to content

Commit 95d8073

Browse files
committed
perf: reduce assessment call delay by pre-generating question audio
Pre-generate all question audio in parallel at call setup instead of generating each question on-the-fly during the call. Also parallelize DB updates with TTS generation and reduce recording retry delays.
1 parent 985676f commit 95d8073

2 files changed

Lines changed: 69 additions & 42 deletions

File tree

src/app/api/webhooks/assessment/next/route.ts

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
generateAssessmentClosing,
88
generateAssessmentSummary,
99
} from "@/lib/gemini";
10-
import { writeFile, mkdir } from "fs/promises";
10+
import { writeFile, mkdir, access } from "fs/promises";
1111
import path from "path";
1212

1313
function sleep(ms: number) {
@@ -20,7 +20,7 @@ async function fetchRecordingWithRetry(recordingUrl: string, maxRetries = 3): Pr
2020
).toString("base64")}`;
2121

2222
for (let attempt = 0; attempt < maxRetries; attempt++) {
23-
if (attempt > 0) await sleep(2000);
23+
if (attempt > 0) await sleep(1000);
2424

2525
// Try .mp3 format first, then raw URL
2626
for (const url of [`${recordingUrl}.mp3`, recordingUrl]) {
@@ -107,6 +107,24 @@ export async function POST(req: NextRequest) {
107107
const audioDir = path.join(process.cwd(), "public", "audio");
108108
await mkdir(audioDir, { recursive: true });
109109

110+
const nextIndex = answerIndex + 1;
111+
const isLastQuestion = nextIndex >= session.answers.length;
112+
113+
// Check if next question audio is already pre-generated
114+
const nextAnswer = !isLastQuestion ? session.answers[nextIndex] : null;
115+
const pregenFileName = nextAnswer ? `assessment-q-${nextAnswer.id}.mp3` : null;
116+
const pregenPath = pregenFileName ? path.join(audioDir, pregenFileName) : null;
117+
let nextQPregenerated = false;
118+
if (pregenPath) {
119+
try {
120+
await access(pregenPath);
121+
nextQPregenerated = true;
122+
} catch {
123+
nextQPregenerated = false;
124+
}
125+
}
126+
127+
// Transcribe recording
110128
let elderAnswer = "";
111129
if (recordingUrl) {
112130
try {
@@ -118,6 +136,7 @@ export async function POST(req: NextRequest) {
118136
}
119137
}
120138

139+
// Evaluate answer + generate response TTS in parallel where possible
121140
let result: "CORRECT" | "WRONG" | "UNCLEAR" = "UNCLEAR";
122141
let responseText = "I didn't quite catch that, but that's okay. Let's continue.";
123142

@@ -136,18 +155,17 @@ export async function POST(req: NextRequest) {
136155
}
137156
}
138157

139-
await prisma.assessmentAnswer.update({
140-
where: { id: currentAnswer.id },
141-
data: { elderAnswer: elderAnswer || null, result },
142-
});
143-
144-
const responseAudio = await generateAudioOrFallback(
145-
responseText, voiceId,
146-
`assessment-resp-${currentAnswer.id}.mp3`, audioDir, baseUrl!
147-
);
148-
149-
const nextIndex = answerIndex + 1;
150-
const isLastQuestion = nextIndex >= session.answers.length;
158+
// Run DB update and response TTS in parallel
159+
const [, responseAudio] = await Promise.all([
160+
prisma.assessmentAnswer.update({
161+
where: { id: currentAnswer.id },
162+
data: { elderAnswer: elderAnswer || null, result },
163+
}),
164+
generateAudioOrFallback(
165+
responseText, voiceId,
166+
`assessment-resp-${currentAnswer.id}.mp3`, audioDir, baseUrl!
167+
),
168+
]);
151169

152170
if (isLastQuestion) {
153171
const allAnswers = await prisma.assessmentAnswer.findMany({
@@ -221,24 +239,29 @@ export async function POST(req: NextRequest) {
221239
});
222240
}
223241

224-
const nextAnswer = session.answers[nextIndex];
225-
let nextQText = `Question ${nextIndex + 1}: ${nextAnswer.questionText}`;
226-
try {
227-
nextQText = await generateAssessmentQuestionAudio({
228-
elderlyName: profile.name,
229-
questionText: nextAnswer.questionText,
230-
questionNumber: nextIndex + 1,
231-
totalQuestions: session.answers.length,
232-
language: profile.language,
233-
});
234-
} catch (err) {
235-
console.error("Question audio generation failed:", err);
236-
}
242+
// Use pre-generated question audio if available, otherwise generate on-the-fly
243+
let nextQAudio: { url: string; usedTts: boolean };
244+
let nextQText = `Question ${nextIndex + 1}: ${nextAnswer!.questionText}`;
237245

238-
const nextQAudio = await generateAudioOrFallback(
239-
nextQText, voiceId,
240-
`assessment-q-${nextAnswer.id}.mp3`, audioDir, baseUrl!
241-
);
246+
if (nextQPregenerated) {
247+
nextQAudio = { url: `${baseUrl}/api/audio/${pregenFileName}`, usedTts: true };
248+
} else {
249+
try {
250+
nextQText = await generateAssessmentQuestionAudio({
251+
elderlyName: profile.name,
252+
questionText: nextAnswer!.questionText,
253+
questionNumber: nextIndex + 1,
254+
totalQuestions: session.answers.length,
255+
language: profile.language,
256+
});
257+
} catch (err) {
258+
console.error("Question audio generation failed:", err);
259+
}
260+
nextQAudio = await generateAudioOrFallback(
261+
nextQText, voiceId,
262+
`assessment-q-${nextAnswer!.id}.mp3`, audioDir, baseUrl!
263+
);
264+
}
242265

243266
const fillerIndex = Math.floor(Math.random() * 5);
244267
const fillerVoice = (voiceId || "21m00Tcm4TlvDq8ikWAM").slice(0, 8);

src/lib/assessment-call.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,19 +75,23 @@ export async function executeAssessmentCall(sessionId: string) {
7575
await writeFile(path.join(audioDir, greetingFileName), greetingBuffer);
7676
const greetingUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/audio/${greetingFileName}`;
7777

78-
const firstAnswer = session.answers[0];
79-
const q1Script = await generateAssessmentQuestionAudio({
80-
elderlyName: profile.name,
81-
questionText: firstAnswer.questionText,
82-
questionNumber: 1,
83-
totalQuestions: session.answers.length,
84-
language: profile.language,
78+
// Pre-generate ALL question audio in parallel (eliminates per-question latency during call)
79+
const questionAudioPromises = session.answers.map(async (answer, idx) => {
80+
const qScript = await generateAssessmentQuestionAudio({
81+
elderlyName: profile.name,
82+
questionText: answer.questionText,
83+
questionNumber: idx + 1,
84+
totalQuestions: session.answers.length,
85+
language: profile.language,
86+
});
87+
const qBuffer = await textToSpeech(qScript, voiceId);
88+
const qFileName = `assessment-q-${answer.id}.mp3`;
89+
await writeFile(path.join(audioDir, qFileName), qBuffer);
90+
return `${process.env.NEXT_PUBLIC_APP_URL}/api/audio/${qFileName}`;
8591
});
8692

87-
const q1Buffer = await textToSpeech(q1Script, voiceId);
88-
const q1FileName = `assessment-q-${firstAnswer.id}.mp3`;
89-
await writeFile(path.join(audioDir, q1FileName), q1Buffer);
90-
const q1Url = `${process.env.NEXT_PUBLIC_APP_URL}/api/audio/${q1FileName}`;
93+
const questionUrls = await Promise.all(questionAudioPromises);
94+
const q1Url = questionUrls[0];
9195

9296
const baseUrl = process.env.NEXT_PUBLIC_APP_URL;
9397
const randomFiller = fillerUrls[Math.floor(Math.random() * fillerUrls.length)];

0 commit comments

Comments
 (0)