import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Icon, IconButton, Icons, Text, config, toRem } from 'folds'; type RecorderState = 'idle' | 'recording' | 'preview'; interface VoiceRecorderProps { onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void; onError?: (err: string) => void; } function formatDuration(ms: number): string { const totalSec = Math.floor(ms / 1000); const m = Math.floor(totalSec / 60); const s = totalSec % 60; return `${m}:${s.toString().padStart(2, '0')}`; } function normalizeWaveform(samples: number[]): number[] { if (samples.length === 0) return Array(20).fill(0); const max = Math.max(...samples, 1); const count = Math.min(samples.length, 100); const step = samples.length / count; const result: number[] = []; for (let i = 0; i < count; i += 1) { const idx = Math.floor(i * step); result.push(Math.round((samples[idx] / max) * 1024)); } return result; } const WAVEFORM_BARS = 40; export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { const [state, setState] = useState('idle'); const [durationMs, setDurationMs] = useState(0); const [waveformBars, setWaveformBars] = useState(Array(WAVEFORM_BARS).fill(0)); const [previewBlob, setPreviewBlob] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const analyserRef = useRef(null); const audioCtxRef = useRef(null); const rawSamplesRef = useRef([]); const startTimeRef = useRef(0); const animFrameRef = useRef(0); const timerRef = useRef | null>(null); const previewMimeRef = useRef('audio/ogg;codecs=opus'); const previewDurationRef = useRef(0); const stopAll = useCallback(() => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); if (timerRef.current) clearInterval(timerRef.current); if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null; } analyserRef.current = null; }, []); useEffect( () => () => { stopAll(); if (previewUrl) URL.revokeObjectURL(previewUrl); }, // eslint-disable-next-line react-hooks/exhaustive-deps [], ); const startRecording = useCallback(async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mimeType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus') ? 'audio/ogg;codecs=opus' : 'audio/webm;codecs=opus'; previewMimeRef.current = mimeType; const mr = new MediaRecorder(stream, { mimeType }); mediaRecorderRef.current = mr; chunksRef.current = []; rawSamplesRef.current = []; startTimeRef.current = Date.now(); const audioCtx = new AudioContext(); audioCtxRef.current = audioCtx; const source = audioCtx.createMediaStreamSource(stream); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 256; source.connect(analyser); analyserRef.current = analyser; const buf = new Uint8Array(analyser.frequencyBinCount); const tick = () => { if (!analyserRef.current) return; analyserRef.current.getByteFrequencyData(buf); const avg = buf.reduce((a, b) => a + b, 0) / buf.length; rawSamplesRef.current.push(avg); setWaveformBars((prev) => { const next = [...prev.slice(1), Math.round((avg / 255) * 100)]; return next; }); animFrameRef.current = requestAnimationFrame(tick); }; animFrameRef.current = requestAnimationFrame(tick); timerRef.current = setInterval(() => { setDurationMs(Date.now() - startTimeRef.current); }, 100); mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; mr.onstop = () => { stream.getTracks().forEach((t) => t.stop()); const blob = new Blob(chunksRef.current, { type: mimeType }); previewDurationRef.current = Date.now() - startTimeRef.current; setPreviewBlob(blob); setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(blob); }); setState('preview'); }; mr.start(250); setState('recording'); } catch { onError?.('Microphone access denied'); } }, [onError]); const stopRecording = useCallback(() => { stopAll(); if (mediaRecorderRef.current?.state === 'recording') { mediaRecorderRef.current.stop(); } }, [stopAll]); const cancelRecording = useCallback(() => { stopAll(); const mr = mediaRecorderRef.current; if (mr?.state === 'recording') { mr.ondataavailable = null; mr.onstop = null; mr.stop(); } setPreviewBlob(null); setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); rawSamplesRef.current = []; setWaveformBars(Array(WAVEFORM_BARS).fill(0)); setDurationMs(0); setState('idle'); }, [stopAll]); const sendVoice = useCallback(() => { if (!previewBlob) return; const waveform = normalizeWaveform(rawSamplesRef.current); onSend(previewBlob, previewMimeRef.current, previewDurationRef.current, waveform); cancelRecording(); }, [previewBlob, onSend, cancelRecording]); const barMax = Math.max(...waveformBars, 1); if (state === 'idle') { return ( ); } if (state === 'recording') { return ( {formatDuration(durationMs)} {waveformBars.map((h, i) => (
))} ); } return ( {previewUrl && ( // eslint-disable-next-line jsx-a11y/media-has-caption ); }