import { useCallback, useEffect, useRef, useState } from 'react' export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'recorded' | 'denied' | 'unsupported' | 'error' // The AmanPay voice backend decodes WAV/PCM (soundfile), NOT the audio/webm (Opus) // that MediaRecorder produces in Chrome — feeding it webm returns HTTP 500. So we // capture PCM via WebAudio and encode a 16-bit mono WAV client-side. function encodeWav(chunks: Float32Array[], sampleRate: number): Blob { let length = 0 for (const c of chunks) length += c.length const pcm = new Float32Array(length) let off = 0 for (const c of chunks) { pcm.set(c, off); off += c.length } const buffer = new ArrayBuffer(44 + pcm.length * 2) const view = new DataView(buffer) const writeStr = (o: number, s: string) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)) } writeStr(0, 'RIFF'); view.setUint32(4, 36 + pcm.length * 2, true); writeStr(8, 'WAVE') writeStr(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true) view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true) view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true) writeStr(36, 'data'); view.setUint32(40, pcm.length * 2, true) let p = 44 for (let i = 0; i < pcm.length; i++, p += 2) { const s = Math.max(-1, Math.min(1, pcm[i])) view.setInt16(p, s < 0 ? s * 0x8000 : s * 0x7fff, true) } return new Blob([view], { type: 'audio/wav' }) } /** Microphone voice recorder producing a WAV data URI the backend accepts. Requests * the mic only on start(); STOPS ALL MIC TRACKS on stop/delete/unmount; revokes the * playback object URL. */ export function useRecorder() { const streamRef = useRef(null) const ctxRef = useRef(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const nodeRef = useRef(null) const sourceRef = useRef(null) const chunksRef = useRef([]) const rateRef = useRef(44100) const timerRef = useRef | null>(null) const urlRef = useRef(null) const [status, setStatus] = useState('idle') const [elapsed, setElapsed] = useState(0) const [playbackUrl, setPlaybackUrl] = useState(null) const [dataUri, setDataUri] = useState(null) const teardown = useCallback(() => { try { nodeRef.current?.disconnect() } catch { /* noop */ } try { sourceRef.current?.disconnect() } catch { /* noop */ } try { ctxRef.current?.close() } catch { /* noop */ } nodeRef.current = null; sourceRef.current = null; ctxRef.current = null streamRef.current?.getTracks().forEach((t) => t.stop()) streamRef.current = null if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } }, []) const revoke = useCallback(() => { if (urlRef.current) { URL.revokeObjectURL(urlRef.current); urlRef.current = null } }, []) const start = useCallback(async () => { const md = navigator.mediaDevices // eslint-disable-next-line @typescript-eslint/no-explicit-any const AC: any = (window as any).AudioContext || (window as any).webkitAudioContext if (!md?.getUserMedia || !AC) { setStatus('unsupported'); return } revoke(); setDataUri(null); setPlaybackUrl(null); setElapsed(0); chunksRef.current = [] setStatus('requesting') try { const stream = await md.getUserMedia({ audio: true }) streamRef.current = stream const ctx = new AC() ctxRef.current = ctx rateRef.current = ctx.sampleRate const source = ctx.createMediaStreamSource(stream) sourceRef.current = source const node = ctx.createScriptProcessor(4096, 1, 1) nodeRef.current = node // eslint-disable-next-line @typescript-eslint/no-explicit-any node.onaudioprocess = (e: any) => { chunksRef.current.push(new Float32Array(e.inputBuffer.getChannelData(0))) } source.connect(node); node.connect(ctx.destination) setStatus('recording') timerRef.current = setInterval(() => setElapsed((s) => (s < 600 ? s + 1 : s)), 1000) } catch (e) { const name = (e as Error)?.name setStatus(name === 'NotAllowedError' || name === 'SecurityError' ? 'denied' : 'error') teardown() } }, [revoke, teardown]) const stop = useCallback(() => { if (status !== 'recording') return const rate = rateRef.current const chunks = chunksRef.current teardown() const blob = encodeWav(chunks, rate) const url = URL.createObjectURL(blob) urlRef.current = url setPlaybackUrl(url) const reader = new FileReader() reader.onloadend = () => setDataUri(typeof reader.result === 'string' ? reader.result : null) reader.readAsDataURL(blob) setStatus('recorded') }, [status, teardown]) const reset = useCallback(() => { teardown(); revoke(); setDataUri(null); setPlaybackUrl(null); setElapsed(0); setStatus('idle') }, [teardown, revoke]) useEffect(() => () => { teardown(); revoke() }, [teardown, revoke]) // cleanup on unmount return { status, elapsed, playbackUrl, dataUri, start, stop, reset } }