Spaces:
Sleeping
Sleeping
File size: 4,223 Bytes
5eefa6a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | 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>
);
}
|