import { useEffect, useRef, useState } from 'react'; import { MicIcon, SpinnerIcon, XIcon } from './icons'; interface Props { /** Called with the recorded audio file when the user confirms. The button * ships as a single-attachment message, so the parent can feed it into * the normal send flow. */ onComplete: (file: File) => Promise | void; disabled?: boolean; } // Single-button voice recorder. Click to start; shows an inline pill with // elapsed time + stop + cancel while recording. On stop, hands a File to the // parent. No in-place preview yet — we lean on the optimistic message bubble // to appear once the parent sends. const MAX_RECORD_SEC = 60; export function VoiceRecorder({ onComplete, disabled = false }: Props) { const [state, setState] = useState<'idle' | 'recording' | 'finalizing'>('idle'); const [elapsedSec, setElapsedSec] = useState(0); const [level, setLevel] = useState(0); const recorderRef = useRef(null); const chunksRef = useRef([]); const streamRef = useRef(null); const startedAtRef = useRef(0); const cancelledRef = useRef(false); const audioCtxRef = useRef(null); const analyserRef = useRef(null); const rafRef = useRef(0); // Tick elapsed + auto-stop at MAX_RECORD_SEC. useEffect(() => { if (state !== 'recording') return; const id = window.setInterval(() => { const sec = Math.floor((Date.now() - startedAtRef.current) / 1000); setElapsedSec(sec); if (sec >= MAX_RECORD_SEC) { const rec = recorderRef.current; if (rec && rec.state !== 'inactive') rec.stop(); } }, 200); return () => { window.clearInterval(id); }; }, [state]); // Live mic level meter via Web Audio AnalyserNode. RMS on 0..1, smoothed. useEffect(() => { if (state !== 'recording') return; const analyser = analyserRef.current; if (!analyser) return; const buf = new Uint8Array(analyser.frequencyBinCount); let cancelled = false; const tick = () => { if (cancelled) return; analyser.getByteFrequencyData(buf as Uint8Array); let sum = 0; for (let i = 0; i < buf.length; i++) sum += buf[i]!; setLevel(sum / (buf.length * 255)); rafRef.current = window.requestAnimationFrame(tick); }; rafRef.current = window.requestAnimationFrame(tick); return () => { cancelled = true; window.cancelAnimationFrame(rafRef.current); }; }, [state]); const stopStream = () => { if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; } if (audioCtxRef.current) { void audioCtxRef.current.close().catch(() => {}); audioCtxRef.current = null; } analyserRef.current = null; setLevel(0); }; const start = async () => { if (disabled) return; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef.current = stream; try { const ctx = new ( window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext )(); const source = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.4; source.connect(analyser); audioCtxRef.current = ctx; analyserRef.current = analyser; } catch { /* Analyser is best-effort; recording still works without level meter. */ } const mime = pickMime(); const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); recorderRef.current = rec; chunksRef.current = []; cancelledRef.current = false; rec.ondataavailable = (ev: BlobEvent) => { if (ev.data && ev.data.size > 0) chunksRef.current.push(ev.data); }; rec.onstop = () => { const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' }); chunksRef.current = []; stopStream(); if (cancelledRef.current || blob.size === 0) { setState('idle'); setElapsedSec(0); return; } setState('finalizing'); const ext = extFor(blob.type); const filename = 'voice-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ext; const file = new File([blob], filename, { type: blob.type }); void Promise.resolve(onComplete(file)).finally(() => { setState('idle'); setElapsedSec(0); }); }; rec.start(); startedAtRef.current = Date.now(); setElapsedSec(0); setState('recording'); } catch (err: unknown) { stopStream(); // Surface device-permission denials or capture failures via console; // the composer doesn't have space for inline errors here and the // browser already shows a system-level permission prompt. console.error('VoiceRecorder.start failed', err); } }; const confirm = () => { const rec = recorderRef.current; if (!rec) return; if (rec.state !== 'inactive') rec.stop(); }; const cancel = () => { cancelledRef.current = true; const rec = recorderRef.current; if (rec && rec.state !== 'inactive') rec.stop(); else { stopStream(); setState('idle'); setElapsedSec(0); } }; if (state === 'idle') { return ( ); } if (state === 'finalizing') { return (
Wird gesendet…
); } // Visual level: scale from 0..1 → 0..100% width. Floor at 4% so the bar // stays visible when silent. const levelPct = Math.max(4, Math.min(100, Math.round(level * 180))); const remaining = Math.max(0, MAX_RECORD_SEC - elapsedSec); return (
{formatTime(elapsedSec)} −{remaining}s
); } function pickMime(): string | null { const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4']; for (const c of candidates) { if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) return c; } return null; } function extFor(mime: string): string { if (mime.includes('webm')) return 'webm'; if (mime.includes('ogg')) return 'ogg'; if (mime.includes('mp4')) return 'm4a'; return 'bin'; } function formatTime(sec: number): string { const m = Math.floor(sec / 60); const s = sec % 60; return m + ':' + s.toString().padStart(2, '0'); }