@just-every/ensemble
Version:
LLM provider abstraction layer with unified streaming interface
342 lines • 12.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AudioStreamPlayer = void 0;
class AudioStreamPlayer {
audioContext = null;
sourceNodes = [];
gainNodes = [];
nextStartTime = 0;
expectedChunkIndex = 0;
receivedFinalChunk = false;
pcmParameters = null;
pcmDataQueue = [];
bufferDurationTarget = 0.2;
bytesPerSample = 2;
isFirstBuffer = true;
currentFormat = null;
fallbackAudio = null;
fallbackChunks = [];
onFirstAudioPlay;
constructor(options = {}) {
this.onFirstAudioPlay = options.onFirstAudioPlay;
}
async initAudioContext() {
if (this.audioContext && this.audioContext.state === 'running') {
return;
}
try {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
}
catch (error) {
console.error('Failed to initialize AudioContext:', error);
this.audioContext = null;
}
}
startStream(params, format) {
this.stopStream();
this.currentFormat = format;
if ((format === 'wav' || format.includes('pcm')) && params) {
if (!this.audioContext || this.audioContext.state !== 'running') {
console.error('AudioContext not ready');
return;
}
this.pcmParameters = params;
this.bytesPerSample = params.bitDepth / 8;
this.expectedChunkIndex = 0;
this.receivedFinalChunk = false;
this.pcmDataQueue = [];
this.isFirstBuffer = true;
this.nextStartTime = 0;
}
else {
this.fallbackChunks = [];
console.log(`Starting ${format} stream - will play when complete`);
}
}
addChunk(base64Chunk, chunkIndex, isFinalChunk) {
const format = this.currentFormat;
if (!format) {
console.error('No format set');
return;
}
if (this.receivedFinalChunk) {
return;
}
if (format === 'wav' || format.includes('pcm')) {
this._addPcmChunk(base64Chunk, chunkIndex, isFinalChunk);
}
else {
this._addFallbackChunk(base64Chunk, chunkIndex, isFinalChunk, format);
}
}
_addPcmChunk(base64Chunk, chunkIndex, isFinalChunk) {
if (!this.audioContext || !this.pcmParameters) {
console.error('Not initialized for PCM');
return;
}
if (chunkIndex !== this.expectedChunkIndex) {
console.warn(`Out of order chunk: expected ${this.expectedChunkIndex}, got ${chunkIndex}`);
return;
}
this.expectedChunkIndex++;
this.receivedFinalChunk = isFinalChunk;
try {
const binaryString = atob(base64Chunk);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
if (bytes.length > 0) {
this.pcmDataQueue.push(bytes.buffer);
}
this._processPcmQueue();
}
catch (error) {
console.error('Error processing PCM chunk:', error);
}
}
_processPcmQueue() {
if (!this.audioContext || !this.pcmParameters) {
return;
}
if (this.receivedFinalChunk && this.pcmDataQueue.length === 0 && this.sourceNodes.length === 0) {
console.log('PCM stream finished');
this._resetState();
return;
}
if (!this.currentFormat) {
return;
}
if (this.nextStartTime > this.audioContext.currentTime + 1.0) {
setTimeout(() => this._processPcmQueue(), 100);
return;
}
const totalBytes = this.pcmDataQueue.reduce((sum, buffer) => sum + buffer.byteLength, 0);
const requiredBytes = this.pcmParameters.sampleRate *
this.pcmParameters.channels *
this.bytesPerSample *
this.bufferDurationTarget;
if (totalBytes < requiredBytes && !(this.receivedFinalChunk && totalBytes > 0)) {
return;
}
const bytesToProcess = this.receivedFinalChunk ? totalBytes : requiredBytes;
let processedBytes = 0;
const buffersToProcess = [];
while (processedBytes < bytesToProcess && this.pcmDataQueue.length > 0) {
const buffer = this.pcmDataQueue.shift();
buffersToProcess.push(buffer);
processedBytes += buffer.byteLength;
}
if (buffersToProcess.length === 0 || processedBytes === 0)
return;
let skipBytes = 0;
if (this.nextStartTime === 0 && buffersToProcess.length > 0) {
const firstBuffer = new Uint8Array(buffersToProcess[0]);
if (firstBuffer.length >= 4) {
const header = String.fromCharCode(...firstBuffer.slice(0, 4));
if (header === 'RIFF') {
skipBytes = 44;
}
}
}
const totalSamples = (processedBytes - skipBytes) / this.bytesPerSample;
const concatenatedPcm = new Int16Array(totalSamples);
let offset = 0;
let bytesSkipped = 0;
for (const buffer of buffersToProcess) {
const view = new DataView(buffer);
for (let i = 0; i < buffer.byteLength; i += 2) {
if (bytesSkipped < skipBytes) {
bytesSkipped += 2;
continue;
}
if (offset < concatenatedPcm.length && i + 1 < buffer.byteLength) {
concatenatedPcm[offset] = view.getInt16(i, true);
offset++;
}
}
}
const float32Array = new Float32Array(concatenatedPcm.length);
for (let i = 0; i < concatenatedPcm.length; i++) {
float32Array[i] = concatenatedPcm[i] / 32768;
}
const numberOfSamples = float32Array.length / this.pcmParameters.channels;
const audioBuffer = this.audioContext.createBuffer(this.pcmParameters.channels, numberOfSamples, this.pcmParameters.sampleRate);
audioBuffer.getChannelData(0).set(float32Array);
const sourceNode = this.audioContext.createBufferSource();
sourceNode.buffer = audioBuffer;
const gainNode = this.audioContext.createGain();
gainNode.gain.value = 1.0;
sourceNode.connect(gainNode);
gainNode.connect(this.audioContext.destination);
const currentTime = this.audioContext.currentTime;
const startTime = this.nextStartTime <= currentTime ? currentTime : this.nextStartTime;
sourceNode.start(startTime);
this.nextStartTime = startTime + audioBuffer.duration;
this.sourceNodes.push(sourceNode);
this.gainNodes.push(gainNode);
if (this.isFirstBuffer && this.onFirstAudioPlay) {
this.isFirstBuffer = false;
if (startTime === currentTime) {
this.onFirstAudioPlay();
}
else {
const delay = (startTime - currentTime) * 1000;
setTimeout(() => this.onFirstAudioPlay?.(), delay);
}
}
sourceNode.onended = () => {
const index = this.sourceNodes.indexOf(sourceNode);
if (index > -1) {
this.sourceNodes.splice(index, 1);
const gainNode = this.gainNodes[index];
if (gainNode) {
this.gainNodes.splice(index, 1);
}
}
setTimeout(() => this._processPcmQueue(), 20);
};
if (this.pcmDataQueue.length > 0 || !this.receivedFinalChunk) {
setTimeout(() => this._processPcmQueue(), 50);
}
}
_addFallbackChunk(base64Chunk, chunkIndex, isFinalChunk, format) {
const binaryString = atob(base64Chunk);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
this.fallbackChunks.push(bytes);
if (isFinalChunk) {
this.receivedFinalChunk = true;
if (!this.fallbackAudio) {
const mimeType = format === 'mp3'
? 'audio/mpeg'
: format === 'opus'
? 'audio/opus'
: format === 'aac'
? 'audio/aac'
: format === 'flac'
? 'audio/flac'
: 'audio/mpeg';
const blob = new Blob(this.fallbackChunks, { type: mimeType });
const url = URL.createObjectURL(blob);
this.fallbackAudio = new Audio();
this.fallbackAudio.src = url;
this.fallbackAudio
.play()
.then(() => {
if (this.onFirstAudioPlay) {
this.onFirstAudioPlay();
}
})
.catch(err => console.error('Playback failed:', err));
}
}
}
stopStream() {
this.sourceNodes.forEach(node => {
try {
node.onended = null;
node.stop();
}
catch {
}
});
this.sourceNodes = [];
this.gainNodes = [];
if (this.fallbackAudio) {
this.fallbackAudio.pause();
this.fallbackAudio = null;
}
this._resetState();
}
fadeOutAndStop(fadeTimeMs = 150) {
this.receivedFinalChunk = true;
this.pcmDataQueue = [];
if (!this.audioContext) {
this.stopStream();
return;
}
const currentTime = this.audioContext.currentTime;
const fadeTimeSeconds = fadeTimeMs / 1000;
this.gainNodes.forEach((gainNode, index) => {
try {
gainNode.gain.cancelScheduledValues(currentTime);
gainNode.gain.setValueAtTime(gainNode.gain.value, currentTime);
gainNode.gain.linearRampToValueAtTime(0, currentTime + fadeTimeSeconds);
const sourceNode = this.sourceNodes[index];
if (sourceNode) {
sourceNode.stop(currentTime + fadeTimeSeconds);
}
}
catch {
}
});
if (this.fallbackAudio && !this.fallbackAudio.paused) {
const audio = this.fallbackAudio;
const initialVolume = audio.volume;
const fadeSteps = 20;
const stepTime = fadeTimeMs / fadeSteps;
let step = 0;
const fadeInterval = setInterval(() => {
step++;
audio.volume = initialVolume * (1 - step / fadeSteps);
if (step >= fadeSteps) {
clearInterval(fadeInterval);
audio.pause();
this.fallbackAudio = null;
}
}, stepTime);
}
const tempSourceNodes = [...this.sourceNodes];
const tempGainNodes = [...this.gainNodes];
this.expectedChunkIndex = 0;
this.pcmDataQueue = [];
this.fallbackChunks = [];
this.nextStartTime = 0;
this.isFirstBuffer = true;
this.currentFormat = null;
setTimeout(() => {
tempSourceNodes.forEach(node => {
try {
node.disconnect();
}
catch {
}
});
tempGainNodes.forEach(node => {
try {
node.disconnect();
}
catch {
}
});
this.sourceNodes = [];
this.gainNodes = [];
}, fadeTimeMs + 50);
}
_resetState() {
this.expectedChunkIndex = 0;
this.receivedFinalChunk = false;
this.pcmDataQueue = [];
this.fallbackChunks = [];
this.nextStartTime = 0;
this.isFirstBuffer = true;
this.currentFormat = null;
this.gainNodes = [];
}
get isPlaying() {
return this.sourceNodes.length > 0 || (this.fallbackAudio !== null && !this.fallbackAudio.paused);
}
get isStreaming() {
return !this.receivedFinalChunk || this.pcmDataQueue.length > 0 || this.sourceNodes.length > 0;
}
}
exports.AudioStreamPlayer = AudioStreamPlayer;
//# sourceMappingURL=audio_stream_player.js.map