main
reng 2 hours ago
parent 0f32d2a473
commit c4d77c304b
  1. 3
      vite/public/default.json
  2. 162
      vite/src/pages/flow_free.jsx
  3. 2
      vite/src/pages/settings.jsx
  4. 5
      vite/src/util/stt.js
  5. 1
      vite/src/util/system_prompt.js
  6. 13
      vite/src/util/useSpeechInput.jsx

@ -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"
}

@ -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 Infinitymetadata 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
// cueplayCue 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 URLcue 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){

@ -34,7 +34,7 @@ export function Settings(){
{data && Object.entries(data).map(([key, value], index) => (
<div key={index} className='flex flex-col gap-1 flex-1'>
<label className='bg-gray-200 self-start px-2'>{key}</label>
{["speech_idle_time","vad_threshold","vad_silence_ms","stt_max_segment_ms","speech_flush_lead"].includes(key) ? (
{["speech_idle_time","vad_threshold","vad_silence_ms","stt_max_segment_ms","speech_flush_lead","stt_warmup_lead"].includes(key) ? (
<input name={key} type='number' step='any' defaultValue={value} className='border'></input>
):(
<textarea name={key} defaultValue={value} className='border flex-1'></textarea>

@ -197,6 +197,11 @@ export async function connectRealtimeTranscription(stream, data, on = {}) {
on.onCompleted?.(message.transcript ?? '');
break;
case 'error':
// 觀眾已經講完時 flush,buffer 是空的,這是預期中的 no-op 不是故障
if (message.error?.code === 'input_audio_buffer_commit_empty') {
console.log('[stt] nothing to flush, buffer empty');
break;
}
console.error('[stt] realtime error:', message.error);
on.onError?.(new Error(message.error?.message || 'realtime error'));
break;

@ -19,6 +19,7 @@ export const DefaultParams={
vad_silence_ms:"500", // 一句講完的靜音長度
stt_max_segment_ms:"8000", // 講不停時每隔這麼久強制切一段(batch)
speech_flush_lead:"3000", // chatInterval 剩這麼多時把尾巴逼出來(實際視窗要再扣 0.9 秒)
stt_warmup_lead:"4000", // 前一個 cue 剩這麼多時先把 STT 連線暖起來
summary_prompt:`幫我把以下一段話整理成一段文字,以第一人稱視角作為當事人的文字紀念,文字內容 50 字以內:`,
}

@ -34,6 +34,7 @@ export default function useSpeechInput(data, { onSpeechStart, onSpeechStop } = {
const refInterim = useRef(''); //
const refStarting = useRef(false); // start()
const refGeneration = useRef(0); // stop() await start()
const refMuted = useRef(false); //
// batch
const refRecorder = useRef();
@ -313,6 +314,9 @@ export default function useSpeechInput(data, { onSpeechStart, onSpeechStop } = {
stream.getTracks().forEach((track) => track.stop());
return;
}
//
stream.getAudioTracks().forEach((track) => { track.enabled = !refMuted.current; });
refStream.current = stream;
try {
@ -350,6 +354,14 @@ export default function useSpeechInput(data, { onSpeechStart, onSpeechStop } = {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stop]);
//
// disabled track server VAD completed
// cue cue textarea
const setMuted = useCallback((muted) => {
refMuted.current = muted;
refStream.current?.getAudioTracks().forEach((track) => { track.enabled = !muted; });
}, []);
//
// processSpeech textarea
const flush = useCallback(() => {
@ -380,5 +392,6 @@ export default function useSpeechInput(data, { onSpeechStart, onSpeechStop } = {
start,
stop,
flush,
setMuted,
};
}

Loading…
Cancel
Save