import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { MobileTouchTarget } from '../styles/mobile.css'; type RecorderState = 'idle' | 'recording' | 'paused' | '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 [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); 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 streamRef = useRef(null); const chunksRef = useRef([]); const analyserRef = useRef(null); const audioCtxRef = useRef(null); const rawSamplesRef = useRef([]); // Active-recording duration excluding paused time: accumulated ms from prior // segments + (now - segmentStart) for the current segment. const accumulatedMsRef = useRef(0); const segmentStartRef = useRef(0); const animFrameRef = useRef(0); const timerRef = useRef | null>(null); const previewMimeRef = useRef('audio/ogg;codecs=opus'); const previewDurationRef = useRef(0); const previewAudioRef = useRef(null); const [previewPlaying, setPreviewPlaying] = useState(false); // Start the waveform (rAF) + duration (interval) meters against the live // analyser. Reused by startRecording and resumeRecording. const startMeters = useCallback(() => { const analyser = analyserRef.current; if (!analyser) return; // Guard against ever running two loops. if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); if (timerRef.current) clearInterval(timerRef.current); 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) => [...prev.slice(1), Math.round((avg / 255) * 100)]); animFrameRef.current = requestAnimationFrame(tick); }; animFrameRef.current = requestAnimationFrame(tick); timerRef.current = setInterval(() => { setDurationMs(accumulatedMsRef.current + (Date.now() - segmentStartRef.current)); }, 100); }, []); // Stop the meters (rAF + interval) without tearing down the audio graph, so a // paused recording can resume. const stopMeters = useCallback(() => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }, []); // Release the microphone. The mic tracks are independent of the MediaRecorder // and the AudioContext — neither mr.stop() nor audioCtx.close() releases them — // so they must be stopped explicitly (else the OS mic indicator stays on). const stopStream = useCallback(() => { streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; }, []); const stopAll = useCallback(() => { stopMeters(); if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null; } analyserRef.current = null; }, [stopMeters]); useEffect( () => () => { // Unmounting mid-recording/pause must release the mic — stopAll() only // closes the AudioContext and cancels the meters, not the mic tracks. const mr = mediaRecorderRef.current; if (mr && (mr.state === 'recording' || mr.state === 'paused')) { mr.ondataavailable = null; mr.onstop = null; mr.stop(); } stopStream(); 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; streamRef.current = stream; chunksRef.current = []; rawSamplesRef.current = []; accumulatedMsRef.current = 0; segmentStartRef.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; startMeters(); mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; mr.onstop = () => { stream.getTracks().forEach((t) => t.stop()); streamRef.current = null; const blob = new Blob(chunksRef.current, { type: mimeType }); 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, startMeters]); const pauseRecording = useCallback(() => { const mr = mediaRecorderRef.current; if (mr?.state !== 'recording') return; accumulatedMsRef.current += Date.now() - segmentStartRef.current; setDurationMs(accumulatedMsRef.current); stopMeters(); mr.pause(); setState('paused'); }, [stopMeters]); const resumeRecording = useCallback(() => { const mr = mediaRecorderRef.current; if (mr?.state !== 'paused') return; segmentStartRef.current = Date.now(); mr.resume(); startMeters(); setState('recording'); }, [startMeters]); const stopRecording = useCallback(() => { const mr = mediaRecorderRef.current; const activeMs = mr?.state === 'recording' ? Date.now() - segmentStartRef.current : 0; previewDurationRef.current = accumulatedMsRef.current + activeMs; stopAll(); if (mr && (mr.state === 'recording' || mr.state === 'paused')) { mr.stop(); } }, [stopAll]); const cancelRecording = useCallback(() => { stopAll(); const mr = mediaRecorderRef.current; if (mr && (mr.state === 'recording' || mr.state === 'paused')) { mr.ondataavailable = null; // onstop (which would release the mic) is cleared, so release it here. mr.onstop = null; mr.stop(); } stopStream(); setPreviewBlob(null); setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); rawSamplesRef.current = []; accumulatedMsRef.current = 0; segmentStartRef.current = 0; setWaveformBars(Array(WAVEFORM_BARS).fill(0)); setDurationMs(0); setState('idle'); }, [stopAll, stopStream]); 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' || state === 'paused') { const paused = state === 'paused'; return ( {formatDuration(durationMs)} {waveformBars.map((h, i) => (
))} ); } return ( {previewUrl && ( <> ); }