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