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
80
public/pcm-capture-processor.js
Normal file
80
public/pcm-capture-processor.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue