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)
80 lines
2.7 KiB
JavaScript
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)
|