| import { useCallback, useEffect, useRef, useState } from 'react' |
|
|
| export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'recorded' | 'denied' | 'unsupported' | 'error' |
|
|
| |
| |
| |
| 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' }) |
| } |
|
|
| |
| |
| |
| export function useRecorder() { |
| const streamRef = useRef<MediaStream | null>(null) |
| const ctxRef = useRef<AudioContext | null>(null) |
| |
| 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 { } |
| try { sourceRef.current?.disconnect() } catch { } |
| try { ctxRef.current?.close() } catch { } |
| 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 |
| |
| 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 |
| |
| 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]) |
|
|
| return { status, elapsed, playbackUrl, dataUri, start, stop, reset } |
| } |
|
|