Spaces:
Sleeping
Sleeping
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import { Mic, MicOff, AlertCircle } from "lucide-react"; | |
| export interface AudioRecorderProps { | |
| onChunk: (chunk: ArrayBuffer) => void; | |
| onEndUtterance: () => void; | |
| disabled?: boolean; | |
| } | |
| type RecorderState = "idle" | "recording" | "unsupported"; | |
| const PREFERRED_MIME = [ | |
| "audio/webm;codecs=opus", | |
| "audio/webm", | |
| "audio/ogg;codecs=opus", | |
| "audio/mp4", | |
| ]; | |
| function getSupportedMimeType(): string | null { | |
| if (typeof MediaRecorder === "undefined") return null; | |
| for (const mime of PREFERRED_MIME) { | |
| if (MediaRecorder.isTypeSupported(mime)) return mime; | |
| } | |
| return null; | |
| } | |
| export default function AudioRecorder({ onChunk, onEndUtterance, disabled }: AudioRecorderProps) { | |
| const [state, setState] = useState<RecorderState>("idle"); | |
| const [permissionDenied, setPermissionDenied] = useState(false); | |
| const mediaRecorderRef = useRef<MediaRecorder | null>(null); | |
| const streamRef = useRef<MediaStream | null>(null); | |
| const mimeType = useRef<string | null>(null); | |
| // Check support on mount | |
| useEffect(() => { | |
| if ( | |
| typeof navigator.mediaDevices?.getUserMedia === "undefined" || | |
| getSupportedMimeType() === null | |
| ) { | |
| setState("unsupported"); | |
| } | |
| mimeType.current = getSupportedMimeType(); | |
| }, []); | |
| const startRecording = useCallback(async () => { | |
| if (state !== "idle" || disabled) return; | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| streamRef.current = stream; | |
| const recorder = new MediaRecorder(stream, { | |
| mimeType: mimeType.current ?? undefined, | |
| audioBitsPerSecond: 16000, | |
| }); | |
| recorder.ondataavailable = async (e) => { | |
| if (e.data.size > 0) { | |
| const buf = await e.data.arrayBuffer(); | |
| onChunk(buf); | |
| } | |
| }; | |
| recorder.start(100); // 100ms chunks | |
| mediaRecorderRef.current = recorder; | |
| setState("recording"); | |
| } catch (err: unknown) { | |
| if (err instanceof DOMException && err.name === "NotAllowedError") { | |
| setPermissionDenied(true); | |
| } | |
| } | |
| }, [state, disabled, onChunk]); | |
| const stopRecording = useCallback(() => { | |
| if (state !== "recording") return; | |
| const recorder = mediaRecorderRef.current; | |
| if (recorder && recorder.state !== "inactive") { | |
| recorder.onstop = () => { | |
| onEndUtterance(); | |
| }; | |
| recorder.stop(); | |
| } | |
| streamRef.current?.getTracks().forEach((t) => t.stop()); | |
| streamRef.current = null; | |
| mediaRecorderRef.current = null; | |
| setState("idle"); | |
| }, [state, onEndUtterance]); | |
| // Cleanup on unmount | |
| useEffect(() => { | |
| return () => { | |
| streamRef.current?.getTracks().forEach((t) => t.stop()); | |
| }; | |
| }, []); | |
| if (state === "unsupported") { | |
| return ( | |
| <div className="flex items-center gap-1.5 text-slate-400 text-xs px-2"> | |
| <AlertCircle className="w-3.5 h-3.5" /> | |
| <span>Audio tidak didukung browser ini</span> | |
| </div> | |
| ); | |
| } | |
| if (permissionDenied) { | |
| return ( | |
| <div className="flex items-center gap-1.5 text-amber-500 text-xs px-2"> | |
| <MicOff className="w-3.5 h-3.5" /> | |
| <span>Izin mikrofon ditolak</span> | |
| </div> | |
| ); | |
| } | |
| const isRecording = state === "recording"; | |
| return ( | |
| <button | |
| onPointerDown={startRecording} | |
| onPointerUp={stopRecording} | |
| onPointerLeave={stopRecording} | |
| disabled={disabled} | |
| title={isRecording ? "Lepas untuk kirim" : "Tahan untuk merekam"} | |
| className={`relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-200 flex-shrink-0 ${ | |
| isRecording | |
| ? "bg-red-500 text-white scale-110 shadow-lg shadow-red-200" | |
| : disabled | |
| ? "bg-slate-100 text-slate-300 cursor-not-allowed" | |
| : "bg-emerald-50 text-emerald-600 hover:bg-emerald-100 hover:scale-105" | |
| }`} | |
| > | |
| {isRecording ? ( | |
| <> | |
| {/* Pulse ring */} | |
| <span className="absolute inset-0 rounded-full bg-red-400 animate-ping opacity-50" /> | |
| <Mic className="w-4 h-4 relative z-10" /> | |
| </> | |
| ) : ( | |
| <Mic className="w-4 h-4" /> | |
| )} | |
| </button> | |
| ); | |
| } | |