@@ -5,6 +5,28 @@ import { generateAssessmentGreeting, generateAssessmentQuestionAudio } from "./g
55import { writeFile , mkdir } from "fs/promises" ;
66import path from "path" ;
77
8+ function escapeXml ( str : string ) {
9+ return str . replace ( / & / g, "&" ) . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) . replace ( / " / g, """ ) ;
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+
830export 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 } &answerIndex=0" method="POST"/>
122- <Play> ${ didntHearUrl } </Play>
119+ ${ didntHearTag }
123120 <Redirect method="POST">${ baseUrl } /api/webhooks/assessment/next?sessionId=${ sessionId } &answerIndex=0</Redirect>
124121 </Response>` ;
125122
0 commit comments