Spaces:
Running
Running
File size: 5,426 Bytes
c0ddd13 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | 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();
}
|