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:
parent
5a31819bca
commit
7862f7c9f3
8 changed files with 862 additions and 0 deletions
146
src/features/t2-live/hooks/useAudioCapture.ts
Normal file
146
src/features/t2-live/hooks/useAudioCapture.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue