|
|
|
|
@ -0,0 +1,393 @@ |
|
|
|
|
import { useEffect, useRef, useState } from "react"; |
|
|
|
|
import SpeechRecognition, { useSpeechRecognition } from 'react-speech-recognition'; |
|
|
|
|
|
|
|
|
|
import { Countdown } from "../comps/timer"; |
|
|
|
|
|
|
|
|
|
import { Status, useChat } from "../util/useChat"; |
|
|
|
|
import { getSummary } from "../util/chat"; |
|
|
|
|
import { saveHistory } from "../util/output"; |
|
|
|
|
import NumPad from "../comps/numpad"; |
|
|
|
|
import { Light } from "../comps/light"; |
|
|
|
|
import { useData } from "../util/useData"; |
|
|
|
|
import VoiceAnalysis from "../comps/voiceanalysis"; |
|
|
|
|
import { sendOsc } from "../util/osc"; |
|
|
|
|
import { set } from "zod/v4"; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const EmojiType={ |
|
|
|
|
phone: '📞', |
|
|
|
|
headphone: '🎧', |
|
|
|
|
speaker: '🔊', |
|
|
|
|
chat: '🤖', |
|
|
|
|
user_input: '💬', |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const ChatStatus={ |
|
|
|
|
System: 'system', |
|
|
|
|
User: 'user', |
|
|
|
|
Processing: 'processing', |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
export function FreeFlow(){ |
|
|
|
|
|
|
|
|
|
const { data }=useData(); |
|
|
|
|
|
|
|
|
|
const [cuelist, setCuelist] = useState([]); |
|
|
|
|
const [currentCue, setCurrentCue] = useState(null); |
|
|
|
|
const [chatWelcome, setChatWelcome] = useState(null); |
|
|
|
|
const [audioInput, setAudioInput] = useState(true); |
|
|
|
|
const [autoSend, setAutoSend] = useState(true); |
|
|
|
|
const [userId, setUserId] = useState(); |
|
|
|
|
const [summary, setSummary] = useState(null); |
|
|
|
|
|
|
|
|
|
const [chatStatus, setChatStatus] = useState(ChatStatus.System); // System, User, Processing |
|
|
|
|
|
|
|
|
|
const refTimer=useRef(); |
|
|
|
|
const refAudio=useRef(); |
|
|
|
|
const refInput=useRef(); |
|
|
|
|
const refLight=useRef(); |
|
|
|
|
|
|
|
|
|
const refContainer=useRef(); |
|
|
|
|
|
|
|
|
|
const refCurrentCue= useRef(null); |
|
|
|
|
|
|
|
|
|
const { history, status, reset, sendMessage, setStatus, audioOutput, setAudioOutput, stop:stopChat, audioUrl, }=useChat(); |
|
|
|
|
|
|
|
|
|
const { |
|
|
|
|
transcript, |
|
|
|
|
finalTranscript, |
|
|
|
|
listening, |
|
|
|
|
resetTranscript, |
|
|
|
|
browserSupportsSpeechRecognition, |
|
|
|
|
isMicrophoneAvailable, |
|
|
|
|
}=useSpeechRecognition(); |
|
|
|
|
|
|
|
|
|
function playAudio(url){ |
|
|
|
|
if(!url) return; |
|
|
|
|
|
|
|
|
|
console.log('Playing audio:', url); |
|
|
|
|
|
|
|
|
|
if(refAudio.current) { |
|
|
|
|
refAudio.current.pause(); // Stop any currently playing audio |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const audio = new Audio(url); |
|
|
|
|
audio.loop=refCurrentCue.current?.loop || false; // Set loop if defined in cue |
|
|
|
|
audio.play().catch(error => { |
|
|
|
|
console.error("Audio playback error:", error); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
audio.onended = () => { |
|
|
|
|
if(refCurrentCue.current?.type!='chat') onCueEnd(); |
|
|
|
|
else{ |
|
|
|
|
setChatStatus(ChatStatus.User); // Reset chat status to User after audio ends |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
refAudio.current = audio; // Store the new audio reference |
|
|
|
|
audio.addEventListener("loadedmetadata", () => { |
|
|
|
|
if(refCurrentCue.current?.type!='chat') |
|
|
|
|
refTimer.current?.restart(audio.duration*1000 || 0); |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
function playCue(cue) { |
|
|
|
|
|
|
|
|
|
if(!cue) return; |
|
|
|
|
console.log('Playing cue:', cue); |
|
|
|
|
|
|
|
|
|
setCurrentCue(cue); |
|
|
|
|
refCurrentCue.current = cue; // Store the current cue in ref |
|
|
|
|
|
|
|
|
|
if(parseFloat(cue.id)<=4.2){ |
|
|
|
|
// Special case for starting a conversation |
|
|
|
|
console.log('clear conversation...'); |
|
|
|
|
reset(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
switch(cue.type){ |
|
|
|
|
case 'chat': |
|
|
|
|
// Special case for starting a conversation |
|
|
|
|
resetTranscript(); |
|
|
|
|
console.log('Starting conversation...'); |
|
|
|
|
sendMessage(); |
|
|
|
|
setChatWelcome(true); |
|
|
|
|
break; |
|
|
|
|
case 'chat_end': |
|
|
|
|
const message= refInput.current?.value?.trim(); |
|
|
|
|
console.log('Ending conversation with message:', message); |
|
|
|
|
sendMessage(message, false, true); |
|
|
|
|
setChatWelcome(false); |
|
|
|
|
|
|
|
|
|
break; |
|
|
|
|
case 'summary': |
|
|
|
|
console.log('Getting summary...'); |
|
|
|
|
getSummary(history.map(el=>`${el.role}:${el.content}`).join('\n'), data).then(summary => { |
|
|
|
|
|
|
|
|
|
console.log('Summary:', summary); |
|
|
|
|
onCueEnd(); // End the current cue after getting summary |
|
|
|
|
|
|
|
|
|
setSummary(summary); |
|
|
|
|
refContainer.current.scrollTop = refContainer.current.scrollHeight; // Scroll to bottom |
|
|
|
|
|
|
|
|
|
}).catch(error => { |
|
|
|
|
console.error('Error getting summary:', error); |
|
|
|
|
}); |
|
|
|
|
break; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if(cue.audioFile){ |
|
|
|
|
playAudio(cue.audioFile); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if(cue.duration){ |
|
|
|
|
refTimer.current.restart(cue.duration*1000, ()=>{ |
|
|
|
|
onCueEnd(cue); |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function onCueEnd() { |
|
|
|
|
|
|
|
|
|
refTimer.current?.stop(); // Stop the timer when cue ends |
|
|
|
|
refAudio.current?.pause(); // Pause any playing audio |
|
|
|
|
|
|
|
|
|
if(!refCurrentCue.current) return; |
|
|
|
|
const cue= refCurrentCue.current; // Get the current cue from ref |
|
|
|
|
|
|
|
|
|
console.log('onCueEnd:', cue.id); |
|
|
|
|
|
|
|
|
|
if(cue.callback=='start_conversation') refLight.current.fadeOut(); // Fade in light for conversation start |
|
|
|
|
if(cue.callback=='summary') refLight.current.fadeIn(); // Fade out light for conversation end |
|
|
|
|
|
|
|
|
|
resetTranscript(); // Reset transcript after cue ends |
|
|
|
|
|
|
|
|
|
if(cue.auto) { |
|
|
|
|
playCue(cuelist.find(c => c.id === cue.nextcue)); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function onStop(){ |
|
|
|
|
console.log('Stopping current cue'); |
|
|
|
|
if(refAudio.current) { |
|
|
|
|
refAudio.current.pause(); |
|
|
|
|
refAudio.current = null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
setCurrentCue(null); |
|
|
|
|
refCurrentCue.current = null; // Clear the current cue reference |
|
|
|
|
|
|
|
|
|
refTimer.current.restart(0); |
|
|
|
|
|
|
|
|
|
stopChat(); // Stop chat processing |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function onNumpad(mess){ |
|
|
|
|
if(refCurrentCue.current?.callback!='numpad') return; |
|
|
|
|
|
|
|
|
|
console.log('Numpad input:', mess); |
|
|
|
|
setUserId(()=>mess); |
|
|
|
|
} |
|
|
|
|
function saveImage(){ |
|
|
|
|
sendOsc('/export', 'output/test.png'); // Send OSC message to save image |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
useEffect(()=>{ |
|
|
|
|
|
|
|
|
|
if(userId>=1 && userId<=24) { |
|
|
|
|
console.log('User ID set:', userId); |
|
|
|
|
playCue(cuelist.find(c => c.id === currentCue.nextcue)); // Play cue 5 when userId is set |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
},[userId]); |
|
|
|
|
|
|
|
|
|
function onSpeechEnd(){ |
|
|
|
|
console.log('onSpeechEnd:', finalTranscript); |
|
|
|
|
if(currentCue?.type!='chat') return; // Only process if current cue is user input |
|
|
|
|
|
|
|
|
|
if(autoSend && transcript.trim().length > 0) { |
|
|
|
|
console.log('Auto sending transcript:', transcript); |
|
|
|
|
// onCueEnd(); |
|
|
|
|
|
|
|
|
|
const message= refInput.current?.value?.trim(); |
|
|
|
|
if(message && message.length>0) { |
|
|
|
|
console.log('Ending conversation with message:', message); |
|
|
|
|
sendMessage(message, false, false); |
|
|
|
|
setChatWelcome(false); |
|
|
|
|
|
|
|
|
|
setChatStatus(ChatStatus.Processing); // Set chat status to Processing |
|
|
|
|
} |
|
|
|
|
resetTranscript(); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
useEffect(()=>{ |
|
|
|
|
|
|
|
|
|
onSpeechEnd(); // Call onSpeechEnd when finalTranscript changes |
|
|
|
|
|
|
|
|
|
},[finalTranscript]); |
|
|
|
|
|
|
|
|
|
useEffect(()=>{ |
|
|
|
|
if(audioInput && isMicrophoneAvailable) { |
|
|
|
|
|
|
|
|
|
SpeechRecognition.startListening({ continuous: true, language: 'zh-TW' }).then(() => { |
|
|
|
|
console.log("Speech recognition started."); |
|
|
|
|
}).catch(error => { |
|
|
|
|
console.error("Error starting speech recognition:", error); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
const recognition= SpeechRecognition.getRecognition(); |
|
|
|
|
recognition.onspeechstart=(e)=>{ |
|
|
|
|
console.log('Sound start:', e); |
|
|
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
}else{ |
|
|
|
|
console.log('Stopping speech recognition...'); |
|
|
|
|
SpeechRecognition.stopListening(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
},[audioInput]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(()=>{ |
|
|
|
|
|
|
|
|
|
// if(listening){ |
|
|
|
|
if((currentCue?.type=='chat' && chatStatus==ChatStatus.User) || currentCue?.type=='user_input') { |
|
|
|
|
refInput.current.value = transcript; |
|
|
|
|
} |
|
|
|
|
// } |
|
|
|
|
|
|
|
|
|
},[transcript]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(()=>{ |
|
|
|
|
|
|
|
|
|
if(audioUrl) playAudio(audioUrl); |
|
|
|
|
|
|
|
|
|
},[audioUrl]); |
|
|
|
|
useEffect(()=>{ |
|
|
|
|
|
|
|
|
|
resetTranscript(); |
|
|
|
|
|
|
|
|
|
},[chatStatus]); |
|
|
|
|
|
|
|
|
|
useEffect(()=>{ |
|
|
|
|
switch(status) { |
|
|
|
|
case Status.SUCCESS: |
|
|
|
|
console.log('Success!'); |
|
|
|
|
|
|
|
|
|
setStatus(Status.IDLE); |
|
|
|
|
refInput.current.value = ''; |
|
|
|
|
resetTranscript(); |
|
|
|
|
|
|
|
|
|
refContainer.current.scrollTop = refContainer.current.scrollHeight; |
|
|
|
|
|
|
|
|
|
break; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
},[status]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(()=>{ |
|
|
|
|
fetch('/cuelist_free.json') |
|
|
|
|
.then(response => response.json()) |
|
|
|
|
.then(data => { |
|
|
|
|
console.log('Cuelist data:', data); |
|
|
|
|
setCuelist(data.cuelist); |
|
|
|
|
}) |
|
|
|
|
.catch(error => { |
|
|
|
|
console.error('Error fetching cuelist:', error); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
},[]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<main className="items-center"> |
|
|
|
|
|
|
|
|
|
<div className="w-full p-2 flex flex-row justify-center gap-2 *:w-[12vw] *:h-[12vw]"> |
|
|
|
|
<div className="bg-gray-100 text-4xl font-bold mb-4 flex justify-center items-center"> |
|
|
|
|
{refCurrentCue.current?.name} |
|
|
|
|
</div> |
|
|
|
|
<Countdown ref={refTimer} /> |
|
|
|
|
<button className="!bg-red-300" onClick={onStop}>Stop</button> |
|
|
|
|
<button className="!bg-yellow-300" onClick={()=>{ |
|
|
|
|
saveHistory(history); |
|
|
|
|
}}>Save Log</button> |
|
|
|
|
<button onClick={saveImage}>Save image</button> |
|
|
|
|
<NumPad onSend={onNumpad} /> |
|
|
|
|
<Light ref={refLight} /> |
|
|
|
|
<VoiceAnalysis/> |
|
|
|
|
</div> |
|
|
|
|
<div className=" max-h-[33vh] overflow-y-auto"> |
|
|
|
|
<table className="border-collapse **:border-y w-full **:p-2"> |
|
|
|
|
<thead> |
|
|
|
|
<tr className="text-left"> |
|
|
|
|
{/* <th>ID</th> */} |
|
|
|
|
<th>Name</th> |
|
|
|
|
<th>Description</th> |
|
|
|
|
<th>Type</th> |
|
|
|
|
<th>Auto</th> |
|
|
|
|
<th>Audio / Due</th> |
|
|
|
|
<th></th> |
|
|
|
|
</tr> |
|
|
|
|
</thead> |
|
|
|
|
<tbody> |
|
|
|
|
{cuelist?.map(({id, name, description, type, auto, audioFile,...props}, index) => ( |
|
|
|
|
<tr key={id}> |
|
|
|
|
{/* <td>{id}</td> */} |
|
|
|
|
<td>{name}</td> |
|
|
|
|
<td>{description}</td> |
|
|
|
|
<td>{EmojiType[type]}</td> |
|
|
|
|
<td>{auto ? '⤵️' : ''}</td> |
|
|
|
|
<td>{audioFile || props.duration} {props.callback && `<${props.callback}>`}</td> |
|
|
|
|
<td> |
|
|
|
|
<button className="rounded-full !bg-green-200" |
|
|
|
|
onClick={()=>{ |
|
|
|
|
playCue({id, name, description, type, auto, audioFile, ...props}); |
|
|
|
|
}}>go</button> |
|
|
|
|
</td> |
|
|
|
|
</tr> |
|
|
|
|
))} |
|
|
|
|
</tbody> |
|
|
|
|
</table> |
|
|
|
|
</div> |
|
|
|
|
<div className="flex-1 w-full overflow-y-auto flex flex-col gap-2"> |
|
|
|
|
<div ref={refContainer} className="flex-1 flex flex-col overflow-y-auto gap-2"> |
|
|
|
|
{history?.map((msg, index) => ( |
|
|
|
|
<div key={index} className={`w-5/6 ${msg.role=='user'? 'self-end':''}`}> |
|
|
|
|
<div className={`${msg.role=='user'? 'bg-green-300':'bg-pink-300'} px-2`}>{msg.content}</div> |
|
|
|
|
{msg.prompt && <div className="text-xs bg-gray-200">{msg.prompt}</div>} |
|
|
|
|
</div> |
|
|
|
|
))} |
|
|
|
|
{summary && <div className="w-full self-center bg-blue-200 px-2">{summary}</div>} |
|
|
|
|
</div> |
|
|
|
|
<textarea ref={refInput} name="message" rows={2} |
|
|
|
|
className={`w-full border-1 resize-none p-2 disabled:bg-gray-500`} |
|
|
|
|
disabled={chatStatus!=ChatStatus.User}></textarea> |
|
|
|
|
<div className="flex flex-row justify-end gap-2 flex-wrap"> |
|
|
|
|
<span className="flex flex-row gap-1"> |
|
|
|
|
<label>audio_output</label> |
|
|
|
|
<input type='checkbox' checked={audioOutput} onChange={(e) => setAudioOutput(e.target.checked)} /> |
|
|
|
|
</span> |
|
|
|
|
<span className="flex flex-row gap-1"> |
|
|
|
|
<label>audio_input</label> |
|
|
|
|
<input type='checkbox' checked={audioInput} onChange={(e) => setAudioInput(e.target.checked)} /> |
|
|
|
|
</span> |
|
|
|
|
<span className="flex flex-row gap-1"> |
|
|
|
|
<label>auto_send</label> |
|
|
|
|
<input type='checkbox' checked={autoSend} onChange={(e) => setAutoSend(e.target.checked)} /> |
|
|
|
|
</span> |
|
|
|
|
<button onClick={resetTranscript}>reset transcript</button> |
|
|
|
|
|
|
|
|
|
<div className="rounded-2xl bg-gray-300 self-end px-4 tracking-widest">api_status= {status}</div> |
|
|
|
|
<div className="rounded-2xl bg-gray-300 self-end px-4 tracking-widest">chat_status= {chatStatus}</div> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
</main> |
|
|
|
|
); |
|
|
|
|
} |