sinsintro-ai-assistant-dh
Version:
A single npm package providing a React-based voice assistant with OpenAI function-calling, purely client-side.
222 lines (193 loc) • 6.19 kB
JSX
// src/components/VoiceAssistant.jsx
import React, { useState, useRef, useEffect } from "react";
import { createAssistant } from "../backend/index.js";
import "./VoiceAssistant.css";
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
const BASE_URL = "https://digital-human-server-2yshz.ondigitalocean.app/digital-human";
export default function VoiceAssistant({ apiKey, functions = [], dbId }) {
const [isListening, setIsListening] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [interimText, setInterimText] = useState("");
const assistantRef = useRef(null);
const recognitionRef = useRef(null);
const pendingSpeechRef = useRef("");
const finalDebounceRef = useRef(null);
const manualStopRef = useRef(false);
const counterRef = useRef(true);
const firstRunRef = useRef(true);
// 1) On mount, init assistant and do first poll
useEffect(() => {
(async () => {
assistantRef.current = await createAssistant({ apiKey, functions });
// if (counterRef.current) {
// pollReady();
// counterRef.current = false;
// }
})();
return () => {
stopListening();
clearTimeout(finalDebounceRef.current);
};
}, []);
useEffect(() => {
}, [isListening]);
// 2) Poll until digital-human says ready, then start listening
async function pollReady() {
try {
const { ready } = await fetchReady();
if (ready) {
startListening();
} else {
if(isListening || recognitionRef.current) {
stopListening();
}
setTimeout(pollReady, 1000);
}
} catch (error) {
if(!error.message.includes("recognition has already started")) {
setTimeout(pollReady, 1000);
}
}
}
// 3) Recognition setup
function initRecognition() {
const rec = new SpeechRecognition();
rec.continuous = true;
rec.interimResults = true;
rec.lang = "ar-SA";
rec.onresult = handleResult;
rec.onerror = (e) => {console.warn("Speech error:", e.error);};
rec.onend = () => {handleError()};
return rec;
}
function startListening() {
if (isListening || isProcessing || manualStopRef.current) return;
if (!recognitionRef.current) {
recognitionRef.current = initRecognition();
}
recognitionRef.current.start();
setIsListening(true);
setInterimText("");
}
function stopListening() {
if (recognitionRef.current) {
recognitionRef.current.stop();
}
setIsListening(false);
pendingSpeechRef.current = "";
counterRef.current = true;
clearTimeout(finalDebounceRef.current);
}
function handleError() {
stopListening();
setTimeout(()=>{
pollReady();
}, 1000);
}
// 4) Debounce final chunks at 200 ms after you pause
function handleResult(event) {
let interim = "";
for (let i = event.resultIndex; i < event.results.length; i++) {
const res = event.results[i];
if (res.isFinal) {
pendingSpeechRef.current += res[0].transcript + " ";
} else {
interim += res[0].transcript;
}
}
setInterimText(interim.trim());
if (pendingSpeechRef.current) {
clearTimeout(finalDebounceRef.current);
finalDebounceRef.current = setTimeout(() => {
const phrase = pendingSpeechRef.current.trim();
pendingSpeechRef.current = "";
if (phrase) commitSpeech(phrase);
}, 5);
}
}
// 5) Send to AI + Digital-Human, then poll again
async function commitSpeech(text) {
stopListening();
setIsProcessing(true);
try {
const aiReply = await assistantRef.current.chatWithFunctions(text);
// send to digital‐human
await postText(aiReply);
await setFlag(true);
await setReady(false);
// only now do we start polling again
if (counterRef.current) {
counterRef.current = false;
setTimeout(() => {
pollReady();
}, 1000);
}
} catch (err) {
console.error("Pipeline error:", err);
if (counterRef.current) {
counterRef.current = false;
setTimeout(() => {
pollReady();
}, 1000);
}
} finally {
setIsProcessing(false);
}
}
// 6) Manual toggle
function toggleCircle() {
if(firstRunRef.current){
firstRunRef.current = false;
pollReady();
}
}
// 7) Helpers
async function fetchReady() {
const res = await fetch(`${BASE_URL}/${dbId}/ready`);
if (!res.ok) throw new Error(res.statusText);
return res.json();
}
async function postText(text) {
const res = await fetch(`${BASE_URL}/${dbId}/text`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error(res.statusText);
}
async function setFlag(flag) {
const res = await fetch(`${BASE_URL}/${dbId}/flag`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ flag }),
});
if (!res.ok) throw new Error(res.statusText);
}
async function setReady(ready) {
const res = await fetch(`${BASE_URL}/${dbId}/ready`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ready }),
});
if (!res.ok) throw new Error(res.statusText);
}
// 8) Render
const circleClass = isProcessing
? "voice-assistant-circle processing"
: isListening
? "voice-assistant-circle active"
: "voice-assistant-circle idle";
return (
<div className="voice-assistant-container">
{/* <div className="transcript">
{interimText || (isProcessing ? "…processing" : "Say something")}
</div> */}
<div
className={circleClass}
onClick={toggleCircle}
title={isListening ? "Click to stop" : "Click to start"}
/>
</div>
);
}