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 }
}