| 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); |
|
|
| |
| 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); |
| 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]); |
|
|
| |
| 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> |
| ); |
| } |
|
|