Spaces:
Running
Running
| // src/App.jsx (or similar path) | |
| import { useEffect, useState, useRef } from "react"; | |
| import { Mic, PhoneOff, ChevronDown } from "lucide-react"; | |
| import { INPUT_SAMPLE_RATE } from "./constants"; | |
| import WORKLET from "./play-worklet.js"; // Assuming this is correctly resolved | |
| export default function App() { | |
| // States and refs remain the same | |
| const [callStartTime, setCallStartTime] = useState(null); | |
| const [callStarted, setCallStarted] = useState(false); | |
| const [playing, setPlaying] = useState(false); | |
| const [voice, setVoice] = useState("af_heart"); | |
| const [voices, setVoices] = useState([]); | |
| const [isListening, setIsListening] = useState(false); | |
| const [isSpeaking, setIsSpeaking] = useState(false); | |
| const [listeningScale, setListeningScale] = useState(1); | |
| const [speakingScale, setSpeakingScale] = useState(1); | |
| const [ripples, setRipples] = useState([]); | |
| const [ready, setReady] = useState(false); | |
| const [error, setError] = useState(null); | |
| const [elapsedTime, setElapsedTime] = useState("00:00"); | |
| const worker = useRef(null); | |
| const micStreamRef = useRef(null); | |
| const node = useRef(null); | |
| // useEffect hooks remain the same | |
| useEffect(() => { | |
| worker.current?.postMessage({ | |
| type: "set_voice", | |
| voice, | |
| }); | |
| }, [voice]); | |
| useEffect(() => { | |
| if (!callStarted) { | |
| worker.current?.postMessage({ | |
| type: "end_call", | |
| }); | |
| } | |
| }, [callStarted]); | |
| useEffect(() => { | |
| if (callStarted && callStartTime) { | |
| const interval = setInterval(() => { | |
| const diff = Math.floor((Date.now() - callStartTime) / 1000); | |
| const minutes = String(Math.floor(diff / 60)).padStart(2, "0"); | |
| const seconds = String(diff % 60).padStart(2, "0"); | |
| setElapsedTime(`${minutes}:${seconds}`); | |
| }, 1000); | |
| return () => clearInterval(interval); | |
| } else { | |
| setElapsedTime("00:00"); | |
| } | |
| }, [callStarted, callStartTime]); | |
| useEffect(() => { | |
| worker.current ??= new Worker(new URL("./worker.js", import.meta.url), { // Ensure worker.js is present | |
| type: "module", | |
| }); | |
| const onMessage = ({ data }) => { | |
| if (data.error) { | |
| return onError(data.error); | |
| } | |
| switch (data.type) { | |
| case "status": | |
| if (data.status === "recording_start") { | |
| setIsListening(true); | |
| setIsSpeaking(false); | |
| } else if (data.status === "recording_end") { | |
| setIsListening(false); | |
| } else if (data.status === "ready") { | |
| setVoices(data.voices); | |
| setReady(true); | |
| } | |
| break; | |
| case "output": | |
| if (!playing) { | |
| node.current?.port.postMessage(data.result.audio); | |
| setPlaying(true); | |
| setIsSpeaking(true); | |
| setIsListening(false); | |
| } | |
| break; | |
| } | |
| }; | |
| const onError = (err) => setError(err.message); | |
| worker.current.addEventListener("message", onMessage); | |
| worker.current.addEventListener("error", onError); | |
| return () => { | |
| worker.current.removeEventListener("message", onMessage); | |
| worker.current.removeEventListener("error", onError); | |
| }; | |
| }, []); // Removed 'playing' from dependencies as it causes re-subscriptions | |
| useEffect(() => { | |
| if (!callStarted) return; | |
| let worklet; | |
| let inputAudioContext; | |
| let source; | |
| let ignore = false; | |
| let outputAudioContext; | |
| const audioStreamPromise = Promise.resolve(micStreamRef.current); | |
| audioStreamPromise | |
| .then(async (stream) => { | |
| if (ignore) return; | |
| inputAudioContext = new (window.AudioContext || | |
| window.webkitAudioContext)({ | |
| sampleRate: INPUT_SAMPLE_RATE, | |
| }); | |
| const analyser = inputAudioContext.createAnalyser(); | |
| analyser.fftSize = 256; | |
| source = inputAudioContext.createMediaStreamSource(stream); | |
| source.connect(analyser); | |
| const inputDataArray = new Uint8Array(analyser.frequencyBinCount); | |
| function calculateRMS(array) { | |
| let sum = 0; | |
| for (let i = 0; i < array.length; ++i) { | |
| const normalized = array[i] / 128 - 1; | |
| sum += normalized * normalized; | |
| } | |
| const rms = Math.sqrt(sum / array.length); | |
| return rms; | |
| } | |
| // Ensure vad-processor.js is present | |
| await inputAudioContext.audioWorklet.addModule( | |
| new URL("./vad-processor.js", import.meta.url), | |
| ); | |
| worklet = new AudioWorkletNode(inputAudioContext, "vad-processor", { | |
| numberOfInputs: 1, | |
| numberOfOutputs: 0, | |
| channelCount: 1, | |
| channelCountMode: "explicit", | |
| channelInterpretation: "discrete", | |
| }); | |
| source.connect(worklet); | |
| worklet.port.onmessage = (event) => { | |
| const { buffer } = event.data; | |
| worker.current?.postMessage({ type: "audio", buffer }); | |
| }; | |
| outputAudioContext = new AudioContext({ | |
| sampleRate: 24000, | |
| }); | |
| outputAudioContext.resume(); | |
| const blob = new Blob([`(${WORKLET.toString()})()`], { | |
| type: "application/javascript", | |
| }); | |
| const url = URL.createObjectURL(blob); | |
| await outputAudioContext.audioWorklet.addModule(url); | |
| URL.revokeObjectURL(url); | |
| node.current = new AudioWorkletNode( | |
| outputAudioContext, | |
| "buffered-audio-worklet-processor", | |
| ); | |
| node.current.port.onmessage = (event) => { | |
| if (event.data.type === "playback_ended") { | |
| setPlaying(false); | |
| setIsSpeaking(false); | |
| worker.current?.postMessage({ type: "playback_ended" }); | |
| } | |
| }; | |
| const outputAnalyser = outputAudioContext.createAnalyser(); | |
| outputAnalyser.fftSize = 256; | |
| node.current.connect(outputAnalyser); | |
| outputAnalyser.connect(outputAudioContext.destination); | |
| const outputDataArray = new Uint8Array( | |
| outputAnalyser.frequencyBinCount, | |
| ); | |
| function updateVisualizers() { | |
| if (!inputAudioContext || inputAudioContext.state === 'closed') return; // Prevent errors if context is closed | |
| analyser.getByteTimeDomainData(inputDataArray); | |
| const rms = calculateRMS(inputDataArray); | |
| const targetScale = 1 + Math.min(1.25 * rms, 0.25); | |
| setListeningScale((prev) => prev + (targetScale - prev) * 0.25); | |
| if (!outputAudioContext || outputAudioContext.state === 'closed') return; // Prevent errors | |
| outputAnalyser.getByteTimeDomainData(outputDataArray); | |
| const outputRMS = calculateRMS(outputDataArray); | |
| const targetOutputScale = 1 + Math.min(1.25 * outputRMS, 0.25); | |
| setSpeakingScale((prev) => prev + (targetOutputScale - prev) * 0.25); | |
| requestAnimationFrame(updateVisualizers); | |
| } | |
| updateVisualizers(); | |
| }) | |
| .catch((err) => { | |
| setError(err.message); | |
| console.error(err); | |
| }); | |
| return () => { | |
| ignore = true; | |
| audioStreamPromise.then((s) => s?.getTracks().forEach((t) => t.stop())); // Optional chaining for s | |
| source?.disconnect(); | |
| worklet?.disconnect(); | |
| inputAudioContext?.close().catch(console.error); // Catch potential errors on close | |
| outputAudioContext?.close().catch(console.error); // Catch potential errors on close | |
| }; | |
| }, [callStarted]); // Removed dependencies that might cause frequent re-runs like 'playing' | |
| useEffect(() => { | |
| if (!callStarted) return; | |
| const interval = setInterval(() => { | |
| const id = Date.now(); | |
| setRipples((prev) => [...prev, id]); | |
| setTimeout(() => { | |
| setRipples((prev) => prev.filter((r) => r !== id)); | |
| }, 1500); // Duration of the ripple animation | |
| }, 1000); // Interval for adding new ripples | |
| return () => clearInterval(interval); | |
| }, [callStarted]); | |
| const handleStartCall = async () => { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| channelCount: 1, | |
| echoCancellation: true, | |
| autoGainControl: true, | |
| noiseSuppression: true, | |
| sampleRate: INPUT_SAMPLE_RATE, | |
| }, | |
| }); | |
| micStreamRef.current = stream; | |
| setCallStartTime(Date.now()); | |
| setCallStarted(true); | |
| setError(null); // Clear previous errors | |
| worker.current?.postMessage({ type: "start_call" }); | |
| } catch (err) { | |
| setError(err.message); | |
| console.error(err); | |
| } | |
| }; | |
| // Main component rendering | |
| return ( | |
| // Overall page container | |
| // Light: gray-50 background | |
| // Dark: slate-900 background | |
| <div className="h-screen min-h-[240px] flex items-center justify-center bg-gray-50 dark:bg-slate-900 p-4 relative"> | |
| {/* Main content card */} | |
| {/* Light: white background */} | |
| {/* Dark: slate-800 background */} | |
| <div className="h-full max-h-[320px] w-[640px] bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 flex items-center justify-between space-x-16"> | |
| {/* Left section: Voice selection and timer */} | |
| {/* Light: green-700 text */} | |
| {/* Dark: green-400 text */} | |
| <div className="text-green-700 dark:text-green-400 w-[140px]"> | |
| <div className="text-xl font-bold flex justify-between"> | |
| {voices?.[voice]?.name} | |
| {/* Elapsed time */} | |
| {/* Light: gray-500 text */} | |
| {/* Dark: slate-400 text */} | |
| <span className="font-normal text-gray-500 dark:text-slate-400">{elapsedTime}</span> | |
| </div> | |
| <div className="text-base relative"> | |
| {/* Custom select button */} | |
| <button | |
| type="button" | |
| disabled={!ready} | |
| // Conditional styling based on 'ready' state | |
| className={`w-full flex items-center justify-between border rounded-md transition-colors | |
| ${ ready | |
| ? "bg-transparent border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 text-gray-700 dark:text-gray-300" // Text color for button | |
| : "bg-gray-100 dark:bg-slate-700 border-gray-300 dark:border-gray-600 opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-500" // Text color for disabled | |
| }` | |
| } | |
| > | |
| <span className="px-2 py-1">Select voice</span> | |
| {/* Chevron icon, color will be inherited or can be set explicitly if needed */} | |
| <ChevronDown className="absolute right-2" /> | |
| </button> | |
| {/* Hidden native select element */} | |
| {/* Basic styling for accessibility, actual appearance handled by custom button */} | |
| <select | |
| value={voice} | |
| onChange={(e) => setVoice(e.target.value)} | |
| className="absolute inset-0 opacity-0 cursor-pointer" | |
| disabled={!ready} | |
| > | |
| {Object.entries(voices).map(([key, v]) => ( | |
| // Option text color in dropdown might be browser-dependent for native select. | |
| // For fully styled dropdown, custom component is needed. | |
| <option key={key} value={key}> | |
| {`${v.name} (${ | |
| v.language === "en-us" ? "American" : v.language | |
| } ${v.gender})`} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </div> | |
| {/* Center section: Visualizer */} | |
| <div className="relative flex items-center justify-center w-32 h-32 flex-shrink-0 aspect-square"> | |
| {callStarted && | |
| ripples.map((id) => ( | |
| <div | |
| key={id} | |
| // Ripple animation border | |
| // Light: border-green-200 | |
| // Dark: border-green-600/700 (adjust for visibility) | |
| className="absolute inset-0 rounded-full border-2 border-green-200 dark:border-green-700 pointer-events-none" | |
| style={{ animation: "ripple 1.5s ease-out forwards" }} | |
| /> | |
| ))} | |
| {/* Pulsing loader while initializing */} | |
| <div | |
| // Background for pulsing loader | |
| // Light: green-200 (normal), red-200 (error) | |
| // Dark: green-700 (normal), red-700 (error) | |
| className={`absolute w-32 h-32 rounded-full ${ | |
| error ? "bg-red-200 dark:bg-red-700" : "bg-green-200 dark:bg-green-700" | |
| } ${!ready ? "animate-ping opacity-75" : ""}`} | |
| style={{ animationDuration: "1.5s" }} | |
| /> | |
| {/* Main rings for visualizer */} | |
| <div | |
| // Speaking scale ring | |
| // Light: green-300 (normal), red-300 (error) | |
| // Dark: green-600 (normal), red-600 (error) | |
| className={`absolute w-32 h-32 rounded-full shadow-inner transition-transform duration-300 ease-out ${ | |
| error ? "bg-red-300 dark:bg-red-600" : "bg-green-300 dark:bg-green-600" | |
| } ${!ready ? "opacity-0" : ""}`} | |
| style={{ transform: `scale(${speakingScale})` }} | |
| /> | |
| <div | |
| // Listening scale ring | |
| // Light: green-200 (normal), red-200 (error) | |
| // Dark: green-700 (normal), red-700 (error) - same as pulse for consistency | |
| className={`absolute w-32 h-32 rounded-full shadow-inner transition-transform duration-300 ease-out ${ | |
| error ? "bg-red-200 dark:bg-red-700" : "bg-green-200 dark:bg-green-700" | |
| } ${!ready ? "opacity-0" : ""}`} | |
| style={{ transform: `scale(${listeningScale})` }} | |
| /> | |
| {/* Center text: status or error message */} | |
| <div | |
| // Text color for status messages | |
| // Light: gray-700 (normal), red-700 (error) | |
| // Dark: slate-300 (normal), red-400 (error) | |
| className={`absolute z-10 text-lg text-center ${ | |
| error ? "text-red-700 dark:text-red-400" : "text-gray-700 dark:text-slate-300" | |
| }`} | |
| > | |
| {error ? ( | |
| error | |
| ) : ( | |
| <> | |
| {!ready && "Loading..."} | |
| {isListening && "Listening..."} | |
| {isSpeaking && "Speaking..."} | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {/* Right section: Call control buttons */} | |
| <div className="space-y-4 w-[140px]"> | |
| {callStarted ? ( | |
| // End call button | |
| <button | |
| // Light: bg-red-100, text-red-700, hover:bg-red-200 | |
| // Dark: bg-red-700, text-red-100, hover:bg-red-600 | |
| className="flex items-center space-x-2 px-4 py-2 bg-red-100 text-red-700 rounded-md hover:bg-red-200 dark:bg-red-700 dark:text-red-100 dark:hover:bg-red-600" | |
| onClick={() => { | |
| setCallStarted(false); | |
| setCallStartTime(null); | |
| setPlaying(false); | |
| setIsListening(false); | |
| setIsSpeaking(false); | |
| // Consider stopping mic tracks here explicitly if not handled by useEffect cleanup quickly enough | |
| micStreamRef.current?.getTracks().forEach(track => track.stop()); | |
| micStreamRef.current = null; | |
| }} | |
| > | |
| <PhoneOff className="w-5 h-5" /> | |
| <span>End call</span> | |
| </button> | |
| ) : ( | |
| // Start call button | |
| <button | |
| // Conditional styling for 'ready' state | |
| className={`flex items-center space-x-2 px-4 py-2 rounded-md ${ | |
| ready | |
| ? "bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-600 dark:text-blue-100 dark:hover:bg-blue-500" // Enabled state | |
| : "bg-blue-100 text-blue-700 opacity-50 cursor-not-allowed dark:bg-blue-800 dark:text-blue-400" // Disabled state | |
| }`} | |
| onClick={handleStartCall} | |
| disabled={!ready} | |
| > | |
| <Mic className="w-5 h-5" /> {/* Added Mic icon for Start Call */} | |
| <span>Start call</span> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Footer link */} | |
| <div className="absolute bottom-4 text-sm text-gray-600 dark:text-gray-400"> {/* Text color for footer */} | |
| Built with{" "} | |
| <a | |
| href="https://github.com/huggingface/transformers.js" | |
| rel="noopener noreferrer" | |
| target="_blank" | |
| // Light: text-blue-600 | |
| // Dark: text-blue-400 | |
| className="text-blue-600 hover:underline dark:text-blue-400" | |
| > | |
| 🤗 Transformers.js | |
| </a> | |
| </div> | |
| </div> | |
| ); | |
| } |