expria-frontend/public/pcm-capture-processor.js
Hermann_Kitio 7862f7c9f3 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)
2026-04-26 20:12:36 +03:00

80 lines
2.7 KiB
JavaScript

/**
* pcm-capture-processor.js — AudioWorklet processor pour T2 Live (Sprint 6b).
*
* Capture du micro à `sampleRate` natif du navigateur (typiquement 48 kHz),
* rééchantillonnage vers 16 kHz si nécessaire, conversion Float32 → Int16
* little-endian, envoi par chunks de ~4096 samples (≈ 256 ms à 16 kHz).
*
* Format de sortie attendu par Gemini Live API :
* PCM brut, 16 kHz, 16 bits, little-endian, mono.
*
* Le rééchantillonnage utilise une interpolation linéaire — équivalent
* à `resample16kTo24k` côté audio-utils.ts mais en sens inverse.
*
* Vanille JS (pas TS) : les AudioWorklet processors s'exécutent dans un
* scope global isolé qui ne peut pas importer depuis le bundle TS.
*/
const TARGET_SAMPLE_RATE = 16000
const CHUNK_SIZE_16K = 4096 // ≈ 256 ms à 16 kHz
class PcmCaptureProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.buffer16k = new Float32Array(0)
}
/**
* Rééchantillonne un Float32 du sample rate source vers 16 kHz par
* interpolation linéaire. Si srcRate === 16000, no-op.
*/
resampleTo16k(input, srcRate) {
if (srcRate === TARGET_SAMPLE_RATE) return input
const ratio = TARGET_SAMPLE_RATE / srcRate
const outLength = Math.floor(input.length * ratio)
const out = new Float32Array(outLength)
for (let i = 0; i < outLength; i++) {
const srcIndex = i / ratio
const srcFloor = Math.floor(srcIndex)
const srcCeil = Math.min(srcFloor + 1, input.length - 1)
const frac = srcIndex - srcFloor
out[i] = input[srcFloor] * (1 - frac) + input[srcCeil] * frac
}
return out
}
process(inputs) {
const input = inputs[0]
if (!input || !input[0]) return true
const channelData = input[0] // mono
// Rééchantillonner d'abord vers 16 kHz puis accumuler.
// `sampleRate` est une variable globale du scope AudioWorklet (Web Audio spec).
const resampled = this.resampleTo16k(channelData, sampleRate)
const newBuffer = new Float32Array(this.buffer16k.length + resampled.length)
newBuffer.set(this.buffer16k)
newBuffer.set(resampled, this.buffer16k.length)
this.buffer16k = newBuffer
while (this.buffer16k.length >= CHUNK_SIZE_16K) {
const chunk = this.buffer16k.slice(0, CHUNK_SIZE_16K)
this.buffer16k = this.buffer16k.slice(CHUNK_SIZE_16K)
// Float32 [-1, 1] → Int16 PCM little-endian
const pcm = new ArrayBuffer(chunk.length * 2)
const view = new DataView(pcm)
for (let i = 0; i < chunk.length; i++) {
const s = Math.max(-1, Math.min(1, chunk[i]))
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
this.port.postMessage(pcm, [pcm])
}
return true
}
}
registerProcessor('pcm-capture-processor', PcmCaptureProcessor)