Frontend-Data-Eyond / src /audio /AudioPlayer.ts
ishaq101's picture
[NOTICKET] Major update, re-stylign and upgrade using maintiva demo setup
c0ddd13
const DEFAULT_SAMPLE_RATE = 16000;
export class AudioPlayer {
private context: AudioContext | null = null;
private nextPlayTime = 0;
private started = false;
private resumed = false;
private pendingBytes = 0;
private bufferThresholdBytes = 6400; // 200ms at 16kHz default
private pendingBuffers: AudioBuffer[] = [];
// Carries the leftover byte when a chunk has odd byte length, so PCM sample
// boundaries stay aligned across chunk splits from the HTTP stream.
private leftoverByte: number | null = null;
init(sampleRate = DEFAULT_SAMPLE_RATE): void {
if (this.context) {
if (this.context.sampleRate === sampleRate) return;
this.context.close();
this.context = null;
}
this.context = new AudioContext({ sampleRate });
this.bufferThresholdBytes = Math.floor(sampleRate * 0.5) * 2; // 500ms
this.nextPlayTime = 0;
this.started = false;
this.resumed = false;
this.pendingBytes = 0;
this.pendingBuffers = [];
this.leftoverByte = null;
}
enqueue(rawPcm: ArrayBuffer, onStarted?: () => void): void {
if (!this.context) return;
// Prepend any leftover byte from the previous chunk so that Int16 sample
// boundaries are always aligned, regardless of how the HTTP stream splits.
let pcmBytes: Uint8Array;
if (this.leftoverByte !== null) {
const combined = new Uint8Array(1 + rawPcm.byteLength);
combined[0] = this.leftoverByte;
combined.set(new Uint8Array(rawPcm), 1);
pcmBytes = combined;
this.leftoverByte = null;
} else {
pcmBytes = new Uint8Array(rawPcm);
}
// If still odd, save the trailing byte for the next chunk.
if (pcmBytes.byteLength % 2 !== 0) {
this.leftoverByte = pcmBytes[pcmBytes.byteLength - 1];
pcmBytes = pcmBytes.slice(0, pcmBytes.byteLength - 1);
}
if (pcmBytes.byteLength === 0) return;
const int16 = new Int16Array(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength / 2);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / 32768;
}
const audioBuffer = this.context.createBuffer(1, float32.length, this.context.sampleRate);
audioBuffer.copyToChannel(float32, 0);
this.pendingBytes += rawPcm.byteLength;
if (this.resumed) {
// AudioContext is running — schedule immediately
const source = this.context.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.context.destination);
source.start(this.nextPlayTime);
this.nextPlayTime += audioBuffer.duration;
} else {
// Still buffering: waiting for threshold or waiting for ctx.resume() to complete
this.pendingBuffers.push(audioBuffer);
if (!this.started && this.pendingBytes >= this.bufferThresholdBytes) {
this.started = true;
void this.context.resume().then(() => {
if (!this.context) return;
this.resumed = true;
this.nextPlayTime = this.context.currentTime;
onStarted?.();
const toSchedule = this.pendingBuffers.splice(0);
this.pendingBuffers = [];
for (const buf of toSchedule) {
const source = this.context.createBufferSource();
source.buffer = buf;
source.connect(this.context.destination);
source.start(this.nextPlayTime);
this.nextPlayTime += buf.duration;
}
});
}
}
}
// Call after the stream ends to play any buffered audio that hasn't started yet
// (handles responses shorter than the buffer threshold)
flush(onStarted?: () => void): void {
if (!this.context || this.started || this.pendingBuffers.length === 0) return;
this.started = true;
void this.context.resume().then(() => {
if (!this.context) return;
this.resumed = true;
this.nextPlayTime = this.context.currentTime;
onStarted?.();
const toSchedule = this.pendingBuffers.splice(0);
this.pendingBuffers = [];
for (const buf of toSchedule) {
const source = this.context.createBufferSource();
source.buffer = buf;
source.connect(this.context.destination);
source.start(this.nextPlayTime);
this.nextPlayTime += buf.duration;
}
});
}
drain(): void {
// Let queued buffers play out — nothing to do, the AudioContext schedule handles it
}
stopImmediately(): void {
if (!this.context) return;
this.context.close();
this.context = null;
this.nextPlayTime = 0;
this.started = false;
this.resumed = false;
this.pendingBytes = 0;
this.pendingBuffers = [];
this.leftoverByte = null;
}
}
export function replayAudio(
chunks: ArrayBuffer[],
sampleRate: number
): () => void {
const ctx = new AudioContext({ sampleRate });
let nextTime = ctx.currentTime + 0.05;
for (const chunk of chunks) {
const int16 = new Int16Array(chunk, 0, Math.floor(chunk.byteLength / 2));
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768;
const buf = ctx.createBuffer(1, float32.length, sampleRate);
buf.copyToChannel(float32, 0);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(nextTime);
nextTime += buf.duration;
}
return () => ctx.close();
}