amanpay / web /src /media /useRecorder.ts
MHamdan's picture
CI deploy ee7d435
36db998 verified
Raw
History Blame Contribute Delete
5.29 kB
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<MediaStream | null>(null)
const ctxRef = useRef<AudioContext | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeRef = useRef<any>(null)
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null)
const chunksRef = useRef<Float32Array[]>([])
const rateRef = useRef<number>(44100)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const urlRef = useRef<string | null>(null)
const [status, setStatus] = useState<RecorderStatus>('idle')
const [elapsed, setElapsed] = useState(0)
const [playbackUrl, setPlaybackUrl] = useState<string | null>(null)
const [dataUri, setDataUri] = useState<string | null>(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 }
}