Sprint 6b — Frontend audio capture + playback hooks

feat(audio): pcm-capture-processor.js AudioWorklet (16kHz resample, Int16 LE)
feat(hooks): useAudioCapture (getUserMedia + worklet + onChunk base64)
feat(hooks): useAudioPlayback (24kHz sequential scheduling, gap-free)
feat(hooks): useAudioRecording (chronological buffer, resample 16→24k, WAV export)
feat(lib): audio-utils (base64, int16/float32, resample, WAV header)
test: 12 audio-utils + 7 useAudioRecording = 238/238 green (+19)
This commit is contained in:
Hermann_Kitio 2026-04-26 20:08:45 +03:00
parent 5a31819bca
commit 7862f7c9f3
8 changed files with 862 additions and 0 deletions

View file

@ -0,0 +1,146 @@
/**
* useAudioCapture Hook de capture micro pour T2 Live (Sprint 6b).
*
* Encapsule le pipeline :
* getUserMedia AudioContext AudioWorklet (pcm-capture-processor.js)
* chunks PCM 16 kHz Int16 LE base64 onChunk()
*
* Le worklet gère le rééchantillonnage si le sample rate natif diffère de 16 kHz.
* Le hook ne touche pas au WebSocket l'appelant (Sprint 6c) branche `onChunk`
* sur `ws.send`.
*
* Cleanup garanti : tracks.stop(), worklet.disconnect(), context.close() au
* stop() ou au démontage du composant.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { arrayBufferToBase64 } from '@/shared/lib/audio-utils'
export interface UseAudioCaptureOptions {
/** Callback invoqué pour chaque chunk PCM 16 kHz encodé en base64. */
onChunk: (base64: string) => void
}
export interface UseAudioCaptureResult {
start: () => Promise<void>
stop: () => void
isCapturing: boolean
error: string | null
}
const WORKLET_URL = '/pcm-capture-processor.js'
export function useAudioCapture(options: UseAudioCaptureOptions): UseAudioCaptureResult {
const [isCapturing, setIsCapturing] = useState(false)
const [error, setError] = useState<string | null>(null)
const contextRef = useRef<AudioContext | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const workletNodeRef = useRef<AudioWorkletNode | null>(null)
const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null)
// Capture options dans une ref pour éviter de réabonner les effets
// sur chaque render (l'appelant fournit souvent un onChunk inline).
const optionsRef = useRef(options)
useEffect(() => {
optionsRef.current = options
})
const cleanup = useCallback(() => {
if (workletNodeRef.current) {
try {
workletNodeRef.current.port.onmessage = null
workletNodeRef.current.disconnect()
} catch {
/* ignore */
}
workletNodeRef.current = null
}
if (sourceNodeRef.current) {
try {
sourceNodeRef.current.disconnect()
} catch {
/* ignore */
}
sourceNodeRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => {
try {
t.stop()
} catch {
/* ignore */
}
})
streamRef.current = null
}
if (contextRef.current) {
try {
void contextRef.current.close()
} catch {
/* ignore */
}
contextRef.current = null
}
}, [])
const start = useCallback(async () => {
if (isCapturing) return
setError(null)
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
})
streamRef.current = stream
// Tenter 16 kHz natif (Chrome / Firefox modernes l'acceptent).
// Sinon, le worklet rééchantillonnera.
const ctx = new AudioContext({ sampleRate: 16000 })
contextRef.current = ctx
await ctx.audioWorklet.addModule(WORKLET_URL)
const source = ctx.createMediaStreamSource(stream)
sourceNodeRef.current = source
const workletNode = new AudioWorkletNode(ctx, 'pcm-capture-processor')
workletNodeRef.current = workletNode
workletNode.port.onmessage = (e: MessageEvent<ArrayBuffer>) => {
try {
optionsRef.current.onChunk(arrayBufferToBase64(e.data))
} catch {
/* ignore — ne pas casser le worklet sur callback throw */
}
}
source.connect(workletNode)
// Pas besoin de connecter au destination — on ne lit pas le micro local.
setIsCapturing(true)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
cleanup()
}
}, [cleanup, isCapturing])
const stop = useCallback(() => {
cleanup()
setIsCapturing(false)
}, [cleanup])
// Cleanup au démontage.
useEffect(() => {
return () => {
cleanup()
}
}, [cleanup])
return { start, stop, isCapturing, error }
}