|
|
|
@ -85,6 +85,9 @@ export function FreeFlow(){ |
|
|
|
|
|
|
|
|
|
|
|
const refHintTimeout=useRef(); |
|
|
|
const refHintTimeout=useRef(); |
|
|
|
const refInputTimeout=useRef(); |
|
|
|
const refInputTimeout=useRef(); |
|
|
|
|
|
|
|
const refFlushTimeout=useRef(); // user_input cue 結束前逼出尾巴用 |
|
|
|
|
|
|
|
const refWarmupTimeout=useRef(); // 下一個 cue 要收音時提前暖連線用 |
|
|
|
|
|
|
|
const refPrevAudioUrl=useRef(); // 上一則 TTS 的 blob URL,等著被回收 |
|
|
|
|
|
|
|
|
|
|
|
const refFadeOutInterval=useRef(); |
|
|
|
const refFadeOutInterval=useRef(); |
|
|
|
const refVolDownInterval=useRef(); |
|
|
|
const refVolDownInterval=useRef(); |
|
|
|
@ -107,6 +110,7 @@ export function FreeFlow(){ |
|
|
|
start: startRecognition, |
|
|
|
start: startRecognition, |
|
|
|
stop: stopRecognition, |
|
|
|
stop: stopRecognition, |
|
|
|
flush: flushSpeech, |
|
|
|
flush: flushSpeech, |
|
|
|
|
|
|
|
setMuted: setSpeechMuted, |
|
|
|
}=useSpeechInput(data, { |
|
|
|
}=useSpeechInput(data, { |
|
|
|
onSpeechStart: handleSpeechStart, |
|
|
|
onSpeechStart: handleSpeechStart, |
|
|
|
onSpeechStop: handleSpeechStop, |
|
|
|
onSpeechStop: handleSpeechStop, |
|
|
|
@ -169,12 +173,14 @@ export function FreeFlow(){ |
|
|
|
|
|
|
|
|
|
|
|
updatePrompt(prompt); |
|
|
|
updatePrompt(prompt); |
|
|
|
sendOsc(OSC_ADDRESS.PROMPT, 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'); |
|
|
|
console.log('Audio prompt ended, setting chat status to User'); |
|
|
|
setChatStatus(ChatStatus.User); // Set chat status to User after audio ends |
|
|
|
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() !== '') { |
|
|
|
if(user_input && user_input.content.trim() !== '') { |
|
|
|
sendOsc(OSC_ADDRESS.STATUS, 'go'); // Send OSC status message |
|
|
|
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{ |
|
|
|
}else{ |
|
|
|
setChatStatus(()=>ChatStatus.User); // Reset chat status to User after audio ends |
|
|
|
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){ |
|
|
|
function playAudio(url){ |
|
|
|
if(!url) return; |
|
|
|
if(!url) return; |
|
|
|
|
|
|
|
|
|
|
|
@ -259,14 +301,49 @@ export function FreeFlow(){ |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
audio.loop=refCurrentCue.current?.loop || false; // Set loop if defined in cue |
|
|
|
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", () => { |
|
|
|
audio.addEventListener("loadedmetadata", () => { |
|
|
|
if(refCurrentCue.current?.type!='chat' && refCurrentCue.current?.type!='user_input') { |
|
|
|
if(refCurrentCue.current?.type!='chat' && refCurrentCue.current?.type!='user_input') { |
|
|
|
refTimer.current?.restart(audio.duration*1000 || 0); |
|
|
|
refTimer.current?.restart(audio.duration*1000 || 0); |
|
|
|
audio.play().catch(error => { |
|
|
|
scheduleWarmup(audio.duration*1000); |
|
|
|
console.error("Audio playback error:", error); |
|
|
|
audio.play().catch(onPlayRejected); |
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}else{ |
|
|
|
}else{ |
|
|
|
if(refCurrentCue.current?.type=='chat'){ |
|
|
|
if(refCurrentCue.current?.type=='chat'){ |
|
|
|
@ -278,40 +355,18 @@ export function FreeFlow(){ |
|
|
|
return; |
|
|
|
return; |
|
|
|
}else{ |
|
|
|
}else{ |
|
|
|
setChatStatus(()=>ChatStatus.System); |
|
|
|
setChatStatus(()=>ChatStatus.System); |
|
|
|
audio.play().catch(error => { |
|
|
|
audio.play().catch(onPlayRejected); |
|
|
|
console.error("Audio playback error:", error); |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
}else{ |
|
|
|
}else{ |
|
|
|
setChatStatus(()=>ChatStatus.Playing); |
|
|
|
setChatStatus(()=>ChatStatus.Playing); |
|
|
|
|
|
|
|
audio.play().catch(onPlayRejected); |
|
|
|
audio.play().catch(error => { |
|
|
|
|
|
|
|
console.error("Audio playback error:", error); |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
audio.onended = () => { |
|
|
|
audio.onended = handleEnded; |
|
|
|
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 |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
refAudio.current = audio; // Store the new audio reference |
|
|
|
refAudio.current = audio; // Store the new audio reference |
|
|
|
|
|
|
|
|
|
|
|
@ -369,6 +424,9 @@ export function FreeFlow(){ |
|
|
|
|
|
|
|
|
|
|
|
if(!cue) return; |
|
|
|
if(!cue) return; |
|
|
|
console.log('Playing cue:', cue); |
|
|
|
console.log('Playing cue:', cue); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if(refFlushTimeout.current) clearTimeout(refFlushTimeout.current); |
|
|
|
|
|
|
|
if(refWarmupTimeout.current) clearTimeout(refWarmupTimeout.current); |
|
|
|
|
|
|
|
|
|
|
|
// stop audio |
|
|
|
// stop audio |
|
|
|
// if(refAudio.current) refAudio.current.pause(); |
|
|
|
// if(refAudio.current) refAudio.current.pause(); |
|
|
|
@ -480,6 +538,18 @@ export function FreeFlow(){ |
|
|
|
refTimer.current.restart(cue.duration*1000, ()=>{ |
|
|
|
refTimer.current.restart(cue.duration*1000, ()=>{ |
|
|
|
onCueEnd(cue); |
|
|
|
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){ |
|
|
|
switch(cue.callback){ |
|
|
|
@ -559,6 +629,13 @@ export function FreeFlow(){ |
|
|
|
function onCueEnd() { |
|
|
|
function onCueEnd() { |
|
|
|
|
|
|
|
|
|
|
|
refTimer.current?.stop(); // Stop the timer when cue ends |
|
|
|
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; |
|
|
|
if(!refCurrentCue.current) return; |
|
|
|
const cue= refCurrentCue.current; // Get the current cue from ref |
|
|
|
const cue= refCurrentCue.current; // Get the current cue from ref |
|
|
|
@ -604,6 +681,9 @@ export function FreeFlow(){ |
|
|
|
|
|
|
|
|
|
|
|
function onStop(){ |
|
|
|
function onStop(){ |
|
|
|
console.log('Stopping current cue'); |
|
|
|
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) { |
|
|
|
if(refAudio.current) { |
|
|
|
refAudio.current.pause(); |
|
|
|
refAudio.current.pause(); |
|
|
|
refAudio.current = null; |
|
|
|
refAudio.current = null; |
|
|
|
@ -876,11 +956,12 @@ export function FreeFlow(){ |
|
|
|
|
|
|
|
|
|
|
|
if(wantsAudio) { |
|
|
|
if(wantsAudio) { |
|
|
|
startRecognition(); |
|
|
|
startRecognition(); |
|
|
|
|
|
|
|
setSpeechMuted(false); // 真的要收音了,把暖機時關掉的 track 打開 |
|
|
|
}else{ |
|
|
|
}else{ |
|
|
|
stopRecognition(); |
|
|
|
stopRecognition(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
},[audioInput, currentCue, startRecognition, stopRecognition]); |
|
|
|
},[audioInput, currentCue, startRecognition, stopRecognition, setSpeechMuted]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(()=>{ |
|
|
|
useEffect(()=>{ |
|
|
|
@ -916,6 +997,15 @@ export function FreeFlow(){ |
|
|
|
|
|
|
|
|
|
|
|
useEffect(()=>{ |
|
|
|
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(refCurrentCue.current?.type!='chat') return; |
|
|
|
|
|
|
|
|
|
|
|
if(audioUrl){ |
|
|
|
if(audioUrl){ |
|
|
|
|