diff --git a/vite/public/default.json b/vite/public/default.json index 3756385..513c4ef 100644 --- a/vite/public/default.json +++ b/vite/public/default.json @@ -13,5 +13,6 @@ "vad_threshold": "0.5", "vad_silence_ms": "500", "stt_max_segment_ms": "8000", - "speech_flush_lead": "3000" + "speech_flush_lead": "3000", + "stt_warmup_lead": "4000" } diff --git a/vite/src/pages/flow_free.jsx b/vite/src/pages/flow_free.jsx index c9785af..f5804ef 100644 --- a/vite/src/pages/flow_free.jsx +++ b/vite/src/pages/flow_free.jsx @@ -85,6 +85,9 @@ export function FreeFlow(){ const refHintTimeout=useRef(); const refInputTimeout=useRef(); + const refFlushTimeout=useRef(); // user_input cue 結束前逼出尾巴用 + const refWarmupTimeout=useRef(); // 下一個 cue 要收音時提前暖連線用 + const refPrevAudioUrl=useRef(); // 上一則 TTS 的 blob URL,等著被回收 const refFadeOutInterval=useRef(); const refVolDownInterval=useRef(); @@ -107,6 +110,7 @@ export function FreeFlow(){ start: startRecognition, stop: stopRecognition, flush: flushSpeech, + setMuted: setSpeechMuted, }=useSpeechInput(data, { onSpeechStart: handleSpeechStart, onSpeechStop: handleSpeechStop, @@ -169,12 +173,14 @@ export function FreeFlow(){ updatePrompt(prompt); sendOsc(OSC_ADDRESS.PROMPT, prompt); - // play audio for prompt - refAudioPrompt.current?.play().catch(error => { - console.error("Audio prompt playback error:", error); - }); - refAudioPrompt.current.onended = () => { + // 收尾抽成具名函式:play() 被 autoplay policy 拒絕時 onended 永遠不會觸發, + // chatStatus 會卡在 Processing 直到 cue duration 到期。catch 裡要走同一套。 + let promptDone=false; + const promptEnded=()=>{ + if(promptDone) return; + promptDone=true; + console.log('Audio prompt ended, setting chat status to User'); setChatStatus(ChatStatus.User); // Set chat status to User after audio ends @@ -182,8 +188,14 @@ export function FreeFlow(){ if(user_input && user_input.content.trim() !== '') { sendOsc(OSC_ADDRESS.STATUS, 'go'); // Send OSC status message } + }; - } + // 先掛 onended 再 play,免得極短音檔在指派前就播完了 + refAudioPrompt.current.onended = promptEnded; + refAudioPrompt.current?.play().catch(error => { + console.error("Audio prompt playback error:", error); + promptEnded(); + }); }else{ setChatStatus(()=>ChatStatus.User); // Reset chat status to User after audio ends @@ -192,6 +204,36 @@ export function FreeFlow(){ } + // 下一個 cue 要不要收音?cue 邊界會關掉 STT 連線,重建要換 ephemeral token、 + // 開 WebSocket、再要一次麥克風,實測 1.2-2.0 秒,這段音訊沒有緩衝、是真的消失。 + function nextCueNeedsAudio(cue){ + if(!cue?.nextcue) return false; + const next=cuelist.find(c => c.id === cue.nextcue); + return next?.type=='chat' || next?.type=='user_input'; + } + + // 在目前這個 cue 的音檔快播完時先把連線開起來,讓下一個 cue 一開始就聽得到。 + // 不整段開著是因為 Realtime 按分鐘計費,phone cue 可以長達 60 秒。 + function scheduleWarmup(durationMs){ + if(refWarmupTimeout.current) clearTimeout(refWarmupTimeout.current); + if(!audioInput) return; + // duration 可能是 NaN 或 Infinity(串流、metadata 殘缺),setTimeout 會把它當成 0 + // 馬上觸發,等於整個 cue 都開著連線在燒錢。 + if(!Number.isFinite(durationMs)) return; + if(!nextCueNeedsAudio(refCurrentCue.current)) return; + + const lead=Number(data?.stt_warmup_lead) || 4000; + refWarmupTimeout.current=setTimeout(()=>{ + console.log('~~~ next cue needs audio, warm up STT connection (muted)'); + // 靜音暖機:這個 cue 還在播旁白,連線先開好但不要把旁白錄進去。 + // 不靜音的話,旁白會被 VAD 斷句,而 completed 要等 1.2 秒才落地—— + // 那時已經進入下一個 cue,playCue 的 resetTranscript() 擋不到, + // 旁白文字就會變成留言的開頭。 + setSpeechMuted(true); + startRecognition(); + }, Math.max(0, durationMs - lead)); + } + function playAudio(url){ if(!url) return; @@ -259,14 +301,49 @@ export function FreeFlow(){ audio.loop=refCurrentCue.current?.loop || false; // Set loop if defined in cue - + + // 播完的收尾。play() 被拒絕時 onended 不會觸發,而 phone cue 沒有 duration、 + // Countdown 也沒帶 callback,等於沒有任何人會把 cue 收掉——所以 catch 要走同一套。 + // AbortError 例外:那是被 pause() 打斷的,收尾自有呼叫 pause 的人負責, + // 在這裡再收一次會變成重複 onCueEnd。 + let ended=false; + function handleEnded(){ + if(ended) return; + ended=true; + + // TTS 每則都是新的 object URL,播完就回收(cue 的檔案路徑不是 blob,呼叫也無害) + if(typeof audioUrl=='string' && audioUrl.startsWith('blob:')) URL.revokeObjectURL(audioUrl); + + if(refCurrentCue.current?.type!='chat'){ + setChatStatus(ChatStatus.End); + onCueEnd(); + console.log('Audio ended, ending current cue'); + }else{ + + // if history contains user input, send it + const last_user_input = history.slice().reverse().find(msg => msg.role === 'user'); + console.log('Audio ended, checking for user input in history:', last_user_input); + // history 裡可能一個 user 訊息都沒有(例如 playCue 的 reset() 把 history 清掉, + // 但在途的 GPT/TTS 不會被取消,assistant 訊息仍會 append 回來),這裡不防會拋 TypeError。 + if(last_user_input?.content!='...'){ + sendPrompt(); + }else{ + setChatStatus(ChatStatus.User); // Reset chat status to Clear + } + } + } + + function onPlayRejected(error){ + console.error("Audio playback error:", error); + if(error?.name=='AbortError') return; // 被 pause() 打斷,不是播不出來 + handleEnded(); + } audio.addEventListener("loadedmetadata", () => { if(refCurrentCue.current?.type!='chat' && refCurrentCue.current?.type!='user_input') { refTimer.current?.restart(audio.duration*1000 || 0); - audio.play().catch(error => { - console.error("Audio playback error:", error); - }); + scheduleWarmup(audio.duration*1000); + audio.play().catch(onPlayRejected); }else{ if(refCurrentCue.current?.type=='chat'){ @@ -278,40 +355,18 @@ export function FreeFlow(){ return; }else{ setChatStatus(()=>ChatStatus.System); - audio.play().catch(error => { - console.error("Audio playback error:", error); - }); + audio.play().catch(onPlayRejected); } }else{ setChatStatus(()=>ChatStatus.Playing); - - audio.play().catch(error => { - console.error("Audio playback error:", error); - }); + audio.play().catch(onPlayRejected); } } }); - audio.onended = () => { - if(refCurrentCue.current?.type!='chat'){ - setChatStatus(ChatStatus.End); - onCueEnd(); - console.log('Audio ended, ending current cue'); - }else{ - - // if history contains user input, send it - const last_user_input = history.slice().reverse().find(msg => msg.role === 'user'); - console.log('Audio ended, checking for user input in history:', last_user_input); - if(last_user_input.content!='...'){ - sendPrompt(); - }else{ - setChatStatus(ChatStatus.User); // Reset chat status to Clear - } - - } - } + audio.onended = handleEnded; refAudio.current = audio; // Store the new audio reference @@ -369,6 +424,9 @@ export function FreeFlow(){ if(!cue) return; console.log('Playing cue:', cue); + + if(refFlushTimeout.current) clearTimeout(refFlushTimeout.current); + if(refWarmupTimeout.current) clearTimeout(refWarmupTimeout.current); // stop audio // if(refAudio.current) refAudio.current.pause(); @@ -480,6 +538,18 @@ export function FreeFlow(){ refTimer.current.restart(cue.duration*1000, ()=>{ onCueEnd(cue); }); + + // 留言(user_input)時間到就直接進 summary cue,而 summary 讀的是 textarea(見上面 'summary' case), + // 等於「時間到就自動送出」。所以這裡要跟 chat 的硬上限一樣先把還沒斷句的尾巴逼出來, + // 否則觀眾講到最後一刻的那一段會整段消失。 + // 這裡不必像 startChatTimer 那樣多扣 0.9 秒:Countdown 是精準在 0 觸發的。 + if(cue.type=='user_input'){ + const flushLead=Number(data?.speech_flush_lead) || 3000; + refFlushTimeout.current=setTimeout(()=>{ + console.log('~~~ user_input cue near end, flush speech tail'); + flushSpeech(); + }, Math.max(0, cue.duration*1000 - flushLead)); + } } switch(cue.callback){ @@ -559,6 +629,13 @@ export function FreeFlow(){ function onCueEnd() { refTimer.current?.stop(); // Stop the timer when cue ends + if(refFlushTimeout.current) clearTimeout(refFlushTimeout.current); + if(refWarmupTimeout.current) clearTimeout(refWarmupTimeout.current); + + // chat 計時器必須跟著 cue 一起結束。它沒被清的話會活到下一個 cue, + // 然後用凍結在 arm 當下的閉包送出一則 GPT 訊息並 resetTranscript()—— + // 打進 Q5.1 的話會把留言前半段從 textarea 抹掉。 + if(refChatTimer.current) clearInterval(refChatTimer.current); if(!refCurrentCue.current) return; const cue= refCurrentCue.current; // Get the current cue from ref @@ -604,6 +681,9 @@ export function FreeFlow(){ function onStop(){ console.log('Stopping current cue'); + if(refFlushTimeout.current) clearTimeout(refFlushTimeout.current); + if(refWarmupTimeout.current) clearTimeout(refWarmupTimeout.current); + if(refChatTimer.current) clearInterval(refChatTimer.current); if(refAudio.current) { refAudio.current.pause(); refAudio.current = null; @@ -876,11 +956,12 @@ export function FreeFlow(){ if(wantsAudio) { startRecognition(); + setSpeechMuted(false); // 真的要收音了,把暖機時關掉的 track 打開 }else{ stopRecognition(); } - },[audioInput, currentCue, startRecognition, stopRecognition]); + },[audioInput, currentCue, startRecognition, stopRecognition, setSpeechMuted]); useEffect(()=>{ @@ -916,6 +997,15 @@ export function FreeFlow(){ useEffect(()=>{ + // TTS 每則都產生一個新的 object URL,不收會一路累積到重開 app。 + // 收上一則而不是當下這則,播不播得出來都涵蓋到,也不會擋住日後改成「延後再播」。 + // 要放在下面的早退之前:非 chat cue 期間落地的 TTS(例如 Q5.1 期間按 Send) + // 根本不會播,早退之後就兩邊都收不到了。 + if(refPrevAudioUrl.current && refPrevAudioUrl.current!=audioUrl){ + URL.revokeObjectURL(refPrevAudioUrl.current); + } + refPrevAudioUrl.current=audioUrl; + if(refCurrentCue.current?.type!='chat') return; if(audioUrl){ diff --git a/vite/src/pages/settings.jsx b/vite/src/pages/settings.jsx index 1a9c788..0a10cab 100644 --- a/vite/src/pages/settings.jsx +++ b/vite/src/pages/settings.jsx @@ -34,7 +34,7 @@ export function Settings(){ {data && Object.entries(data).map(([key, value], index) => (