Skip to content

Commit f17f7ca

Browse files
Sonra0claude
andcommitted
fix: make all TTS audio pre-generation non-fatal
ElevenLabs rate limiting was crashing the entire call setup. Now every TTS call falls back to Twilio <Say> independently, so the call always goes through even if ElevenLabs is unavailable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e40d759 commit f17f7ca

1 file changed

Lines changed: 64 additions & 67 deletions

File tree

src/lib/assessment-call.ts

Lines changed: 64 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@ import { generateAssessmentGreeting, generateAssessmentQuestionAudio } from "./g
55
import { writeFile, mkdir } from "fs/promises";
66
import path from "path";
77

8+
function escapeXml(str: string) {
9+
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
10+
}
11+
12+
// Try TTS, return Play tag on success or Say tag on failure
13+
async function generateAudioTag(
14+
text: string,
15+
fileName: string,
16+
audioDir: string,
17+
baseUrl: string,
18+
voiceId?: string
19+
): Promise<string> {
20+
try {
21+
const buf = await textToSpeech(text, voiceId);
22+
await writeFile(path.join(audioDir, fileName), buf);
23+
return `<Play>${baseUrl}/api/audio/${fileName}</Play>`;
24+
} catch (err) {
25+
console.warn(`TTS failed for ${fileName}, using <Say> fallback:`, err);
26+
return `<Say>${escapeXml(text)}</Say>`;
27+
}
28+
}
29+
830
export async function executeAssessmentCall(sessionId: string) {
931
const session = await prisma.assessmentSession.findUnique({
1032
where: { id: sessionId },
@@ -25,40 +47,40 @@ export async function executeAssessmentCall(sessionId: string) {
2547
try {
2648
const profile = session.elderlyProfile;
2749
const voiceId = profile.voiceId || undefined;
50+
const baseUrl = process.env.NEXT_PUBLIC_APP_URL!;
51+
const audioDir = path.join(process.cwd(), "public", "audio");
52+
await mkdir(audioDir, { recursive: true });
2853

2954
const hour = new Date().getHours();
3055
const timeOfDay = hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening";
56+
const isArabic = profile.language === "ar";
3157

58+
// Generate greeting script (Gemini text generation — not TTS, so unlikely to rate-limit)
3259
const greetingScript = await generateAssessmentGreeting({
3360
elderlyName: profile.name,
3461
language: profile.language,
3562
timeOfDay,
3663
});
3764

38-
const greetingBuffer = await textToSpeech(greetingScript, voiceId);
39-
const audioDir = path.join(process.cwd(), "public", "audio");
40-
await mkdir(audioDir, { recursive: true });
41-
const greetingFileName = `assessment-greeting-${sessionId}.mp3`;
42-
await writeFile(path.join(audioDir, greetingFileName), greetingBuffer);
43-
const greetingUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/audio/${greetingFileName}`;
44-
45-
// Pre-generate ALL question audio in parallel (eliminates per-question latency during call)
46-
const questionAudioPromises = session.answers.map(async (answer: { id: string; questionText: string }, idx: number) => {
47-
const qScript = await generateAssessmentQuestionAudio({
48-
elderlyName: profile.name,
49-
questionText: answer.questionText,
50-
questionNumber: idx + 1,
51-
totalQuestions: session.answers.length,
52-
language: profile.language,
53-
});
54-
const qBuffer = await textToSpeech(qScript, voiceId);
55-
const qFileName = `assessment-q-${answer.id}.mp3`;
56-
await writeFile(path.join(audioDir, qFileName), qBuffer);
57-
return `${process.env.NEXT_PUBLIC_APP_URL}/api/audio/${qFileName}`;
65+
// Pre-generate ALL audio in parallel — each one falls back to <Say> independently
66+
const greetingPromise = generateAudioTag(
67+
greetingScript, `assessment-greeting-${sessionId}.mp3`, audioDir, baseUrl, voiceId
68+
);
69+
70+
const questionPromises = session.answers.map(async (answer: { id: string; questionText: string }, idx: number) => {
71+
let qScript = `Question ${idx + 1}: ${answer.questionText}`;
72+
try {
73+
qScript = await generateAssessmentQuestionAudio({
74+
elderlyName: profile.name,
75+
questionText: answer.questionText,
76+
questionNumber: idx + 1,
77+
totalQuestions: session.answers.length,
78+
language: profile.language,
79+
});
80+
} catch { /* use plain question text */ }
81+
return generateAudioTag(qScript, `assessment-q-${answer.id}.mp3`, audioDir, baseUrl, voiceId);
5882
});
5983

60-
// Pre-generate emotional question, "didn't hear" fallback, and "goodbye" audio in parallel with questions
61-
const isArabic = profile.language === "ar";
6284
const emotionalQText = isArabic
6385
? "أحسنت! الآن أخبرني، كيف حالك اليوم؟ كيف تشعر؟"
6486
: "Great job with those! Now tell me, how are you doing today? How are you feeling?";
@@ -68,58 +90,33 @@ export async function executeAssessmentCall(sessionId: string) {
6890
const didntHearShortText = isArabic
6991
? "لم أسمع شيئاً."
7092
: "I didn't hear anything.";
93+
const emergencyAskText = isArabic
94+
? "أنا آسف لسماع ذلك. هل تريد أن أتصل بشخص من عائلتك أو طبيبك الآن؟"
95+
: "I'm sorry to hear that. Would you like me to call someone from your family or your doctor right now?";
7196

72-
const emotionalAudioPromise = (async () => {
73-
const buf = await textToSpeech(emotionalQText, voiceId);
74-
const fileName = `assessment-emotional-${sessionId}.mp3`;
75-
await writeFile(path.join(audioDir, fileName), buf);
76-
return fileName;
77-
})();
78-
const didntHearAudioPromise = (async () => {
79-
const buf = await textToSpeech(didntHearText, voiceId);
80-
const fileName = `assessment-noanswer-${sessionId}.mp3`;
81-
await writeFile(path.join(audioDir, fileName), buf);
82-
return fileName;
83-
})();
84-
const didntHearShortAudioPromise = (async () => {
85-
const buf = await textToSpeech(didntHearShortText, voiceId);
86-
const fileName = `assessment-noanswer-short-${sessionId}.mp3`;
87-
await writeFile(path.join(audioDir, fileName), buf);
88-
return fileName;
89-
})();
90-
const emergencyAskAudioPromise = (async () => {
91-
try {
92-
const emergencyAskText = isArabic
93-
? "أنا آسف لسماع ذلك. هل تريد أن أتصل بشخص من عائلتك أو طبيبك الآن؟"
94-
: "I'm sorry to hear that. Would you like me to call someone from your family or your doctor right now?";
95-
const buf = await textToSpeech(emergencyAskText, voiceId);
96-
const fileName = `assessment-emergency-ask-${sessionId}.mp3`;
97-
await writeFile(path.join(audioDir, fileName), buf);
98-
return fileName;
99-
} catch (err) {
100-
console.warn("Emergency-ask audio pre-generation failed (will use fallback):", err);
101-
return null;
102-
}
103-
})();
104-
105-
const [questionUrls, emotionalFileName, didntHearFileName, didntHearShortFileName, emergencyAskFileName] = await Promise.all([
106-
Promise.all(questionAudioPromises),
107-
emotionalAudioPromise,
108-
didntHearAudioPromise,
109-
didntHearShortAudioPromise,
110-
emergencyAskAudioPromise,
97+
const emotionalPromise = generateAudioTag(emotionalQText, `assessment-emotional-${sessionId}.mp3`, audioDir, baseUrl, voiceId);
98+
const didntHearPromise = generateAudioTag(didntHearText, `assessment-noanswer-${sessionId}.mp3`, audioDir, baseUrl, voiceId);
99+
const didntHearShortPromise = generateAudioTag(didntHearShortText, `assessment-noanswer-short-${sessionId}.mp3`, audioDir, baseUrl, voiceId);
100+
const emergencyAskPromise = generateAudioTag(emergencyAskText, `assessment-emergency-ask-${sessionId}.mp3`, audioDir, baseUrl, voiceId);
101+
102+
const [greetingTag, questionTags, , , ,] = await Promise.all([
103+
greetingPromise,
104+
Promise.all(questionPromises),
105+
emotionalPromise,
106+
didntHearPromise,
107+
didntHearShortPromise,
108+
emergencyAskPromise,
111109
]);
112-
const q1Url = questionUrls[0];
113110

114-
const baseUrl = process.env.NEXT_PUBLIC_APP_URL;
115-
const didntHearUrl = `${baseUrl}/api/audio/${didntHearFileName}`;
111+
const q1Tag = questionTags[0];
112+
const didntHearTag = await didntHearPromise;
116113

117114
const twiml = `<Response>
118-
<Play>${greetingUrl}</Play>
115+
${greetingTag}
119116
<Pause length="1"/>
120-
<Play>${q1Url}</Play>
117+
${q1Tag}
121118
<Record maxLength="15" playBeep="false" timeout="3" action="${baseUrl}/api/webhooks/assessment/next?sessionId=${sessionId}&amp;answerIndex=0" method="POST"/>
122-
<Play>${didntHearUrl}</Play>
119+
${didntHearTag}
123120
<Redirect method="POST">${baseUrl}/api/webhooks/assessment/next?sessionId=${sessionId}&amp;answerIndex=0</Redirect>
124121
</Response>`;
125122

0 commit comments

Comments
 (0)