File size: 5,291 Bytes
004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 36db998 004f460 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 | 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 }
}
|