Spaces:
Sleeping
Sleeping
| import { useState, useRef, useEffect } from "react"; | |
| import { createSocket } from "../api"; | |
| export default function Recorder({ onResult }) { | |
| const [recording, setRecording] = useState(false); | |
| const [status, setStatus] = useState("idle"); // idle | recording | analyzing | error | |
| const [bars, setBars] = useState(new Array(30).fill(3)); | |
| const mediaRecorderRef = useRef(null); | |
| const socketRef = useRef(null); | |
| const analyserRef = useRef(null); | |
| const animFrameRef = useRef(null); | |
| const chunksRef = useRef([]); | |
| const audioChunksRef = useRef([]); | |
| const processorRef = useRef(null); | |
| const streamRef = useRef(null); | |
| // Clean up on unmount | |
| useEffect(() => { | |
| return () => { | |
| stopVisualization(); | |
| if (socketRef.current) socketRef.current.close(); | |
| }; | |
| }, []); | |
| const startVisualization = (stream) => { | |
| const ctx = new AudioContext(); | |
| const source = ctx.createMediaStreamSource(stream); | |
| const analyser = ctx.createAnalyser(); | |
| analyser.fftSize = 64; | |
| source.connect(analyser); | |
| analyserRef.current = analyser; | |
| const data = new Uint8Array(analyser.frequencyBinCount); | |
| const tick = () => { | |
| analyser.getByteFrequencyData(data); | |
| const slice = Array.from(data).slice(0, 30); | |
| setBars(slice.map((v) => Math.max(3, (v / 255) * 60))); | |
| animFrameRef.current = requestAnimationFrame(tick); | |
| }; | |
| tick(); | |
| }; | |
| const stopVisualization = () => { | |
| if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); | |
| setBars(new Array(30).fill(3)); | |
| }; | |
| const startRecording = async () => { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| // Connect WebSocket | |
| socketRef.current = createSocket( | |
| (data) => { | |
| setStatus("idle"); | |
| onResult(data); | |
| }, | |
| () => setStatus("error") | |
| ); | |
| startVisualization(stream); | |
| //chunksRef.current = []; | |
| //const mr = new MediaRecorder(stream, { mimeType: "audio/webm" }); | |
| //mr.ondataavailable = (e) => { | |
| //if (e.data.size > 0) chunksRef.current.push(e.data); | |
| //}; | |
| //mr.onstop = () => { | |
| // const blob = new Blob(chunksRef.current, { type: "audio/webm" }); | |
| // setStatus("analyzing"); | |
| // socketRef.current.sendAudio(blob); | |
| // stream.getTracks().forEach((t) => t.stop()); | |
| // stopVisualization(); | |
| //}; | |
| //mediaRecorderRef.current = mr; | |
| //mr.start(); | |
| const audioContext = new AudioContext({ sampleRate: 16000 }); | |
| const source = audioContext.createMediaStreamSource(stream); | |
| const processor = audioContext.createScriptProcessor(4096, 1, 1); | |
| processorRef.current = processor; | |
| streamRef.current = stream; | |
| audioChunksRef.current = []; // reset buffer | |
| source.connect(processor); | |
| processor.connect(audioContext.destination); | |
| processor.onaudioprocess = (event) => { | |
| const input = event.inputBuffer.getChannelData(0); | |
| const buffer = new ArrayBuffer(input.length * 2); | |
| const view = new DataView(buffer); | |
| for (let i = 0; i < input.length; i++) { | |
| let s = Math.max(-1, Math.min(1, input[i])); | |
| view.setInt16(i * 2, s * 0x7fff, true); | |
| } | |
| //STORE instead of sending | |
| audioChunksRef.current.push(buffer); | |
| }; | |
| setRecording(true); | |
| setStatus("recording"); | |
| } catch (err) { | |
| console.error("Microphone access denied", err); | |
| setStatus("error"); | |
| } | |
| }; | |
| const stopRecording = () => { | |
| setRecording(false); | |
| setStatus("analyzing"); | |
| stopVisualization(); | |
| //Stop audio processing | |
| if (processorRef.current) { | |
| processorRef.current.disconnect(); | |
| } | |
| if (streamRef.current) { | |
| streamRef.current.getTracks().forEach((t) => t.stop()); | |
| } | |
| //Merge all chunks into ONE buffer | |
| const totalLength = audioChunksRef.current.reduce( | |
| (sum, buf) => sum + buf.byteLength, | |
| 0 | |
| ); | |
| const combined = new Uint8Array(totalLength); | |
| let offset = 0; | |
| audioChunksRef.current.forEach((buf) => { | |
| combined.set(new Uint8Array(buf), offset); | |
| offset += buf.byteLength; | |
| }); | |
| //Send once | |
| socketRef.current.sendAudio(combined.buffer); | |
| }; | |
| const statusText = { | |
| idle: "Press to start recording", | |
| recording: "Recording... press to stop", | |
| analyzing: "Analyzing emotion...", | |
| error: "Error — check mic & backend", | |
| }[status]; | |
| return ( | |
| <div style={styles.wrapper}> | |
| {/* Waveform */} | |
| <div style={styles.waveform}> | |
| {bars.map((h, i) => ( | |
| <div | |
| key={i} | |
| style={{ | |
| ...styles.bar, | |
| height: `${h}px`, | |
| backgroundColor: recording ? "#6366f1" : "#334155", | |
| transition: recording ? "height 0.05s" : "height 0.3s", | |
| }} | |
| /> | |
| ))} | |
| </div> | |
| {/* Mic button */} | |
| <button | |
| onClick={recording ? stopRecording : startRecording} | |
| disabled={status === "analyzing"} | |
| style={{ | |
| ...styles.btn, | |
| backgroundColor: recording ? "#ef4444" : "#6366f1", | |
| transform: recording ? "scale(1.08)" : "scale(1)", | |
| }} | |
| > | |
| {recording ? "⏹" : "🎤"} | |
| </button> | |
| <p style={styles.status}>{statusText}</p> | |
| </div> | |
| ); | |
| } | |
| const styles = { | |
| wrapper: { | |
| display: "flex", | |
| flexDirection: "column", | |
| alignItems: "center", | |
| gap: "16px", | |
| width: "100%", | |
| }, | |
| waveform: { | |
| display: "flex", | |
| alignItems: "center", | |
| gap: "3px", | |
| height: "64px", | |
| width: "100%", | |
| justifyContent: "center", | |
| }, | |
| bar: { | |
| width: "6px", | |
| borderRadius: "3px", | |
| minHeight: "3px", | |
| }, | |
| btn: { | |
| width: "72px", | |
| height: "72px", | |
| borderRadius: "50%", | |
| border: "none", | |
| fontSize: "28px", | |
| cursor: "pointer", | |
| transition: "background-color 0.2s, transform 0.2s", | |
| boxShadow: "0 4px 20px rgba(99,102,241,0.4)", | |
| }, | |
| status: { | |
| color: "#94a3b8", | |
| fontSize: "14px", | |
| margin: 0, | |
| }, | |
| }; |