audio.libx.js
Version:
Comprehensive audio library with progressive streaming, recording capabilities, real-time processing, and intelligent caching for web applications
509 lines • 21.9 kB
JavaScript
import { MediaSourceHelper } from './MediaSourceHelper.js';
import { AudioCache } from './AudioCache.js';
import { AudioProcessor } from './AudioProcessor.js';
import { AudioStreamingError, MediaSourceError } from './types.js';
export class AudioStreamer {
constructor(audioElement, options = {}) {
this._eventCallbacks = new Map();
this._activeStreams = new Map();
this._isInitialized = false;
this._audioElement = audioElement;
this._options = {
bufferThreshold: options.bufferThreshold ?? 5,
enableCaching: options.enableCaching ?? true,
enableTrimming: options.enableTrimming ?? true,
mimeType: options.mimeType ?? '',
silenceThresholdDb: options.silenceThresholdDb ?? -50,
minSilenceMs: options.minSilenceMs ?? 100,
cacheDbName: options.cacheDbName ?? 'sound-libx-cache',
cacheStoreName: options.cacheStoreName ?? 'audio-tracks'
};
this._mediaSourceHelper = MediaSourceHelper.getInstance();
this._cache = new AudioCache(this._options.cacheDbName, this._options.cacheStoreName);
this._processor = new AudioProcessor();
this._state = {
state: 'idle',
bufferProgress: 0,
canPlay: false
};
this._setupAudioElementListeners();
}
async initialize() {
if (this._isInitialized)
return;
try {
if (this._options.enableCaching) {
await this._cache.initialize();
}
this._isInitialized = true;
this._emitEvent('loadStart');
}
catch (error) {
throw new AudioStreamingError('Failed to initialize AudioStreamer', 'INITIALIZATION_ERROR', undefined, error);
}
}
async streamFromResponse(response, audioId, options = {}) {
await this.initialize();
const id = audioId || this._generateId();
this._cancelAllActiveStreams();
const abortController = new AbortController();
this._activeStreams.set(id, abortController);
const onLoadedPromise = this._createPromise();
const onEndedPromise = this._createPromise();
this._emitEvent('loadStart', id);
try {
if (this._options.enableCaching) {
const cachedChunks = await this._cache.get(id);
if (cachedChunks) {
this._emitEvent('cacheHit', id);
const result = await this._playFromCache(id, cachedChunks, onLoadedPromise, onEndedPromise);
this._emitEvent('loadEnd', id);
return result;
}
}
this._emitEvent('cacheMiss', id);
if (!options.justCache) {
this._streamForPlayback(response.clone(), id, onLoadedPromise, abortController.signal);
}
await this._cacheFromResponse(response, id, abortController.signal);
if (options.justCache) {
onLoadedPromise.resolve(id);
onEndedPromise.resolve(id);
}
this._emitEvent('loadEnd', id);
}
catch (error) {
this._activeStreams.delete(id);
onLoadedPromise.reject(error);
onEndedPromise.reject(error);
this._emitEvent('error', id, error);
}
return {
audioId: id,
onLoaded: onLoadedPromise.promise,
onEnded: onEndedPromise.promise,
cancel: () => this._cancelStream(id)
};
}
async streamFromUrl(url, audioId, options = {}) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return this.streamFromResponse(response, audioId, options);
}
catch (error) {
throw new AudioStreamingError(`Failed to fetch audio from URL: ${url}`, 'FETCH_ERROR', audioId, error);
}
}
async playFromCache(audioId) {
await this.initialize();
if (!this._options.enableCaching) {
throw new AudioStreamingError('Caching is disabled', 'CACHE_DISABLED', audioId);
}
this._cancelAllActiveStreams();
this._emitEvent('loadStart', audioId);
const cachedChunks = await this._cache.get(audioId);
if (!cachedChunks) {
throw new AudioStreamingError('Audio not found in cache', 'CACHE_MISS', audioId);
}
const onLoadedPromise = this._createPromise();
const onEndedPromise = this._createPromise();
const result = await this._playFromCache(audioId, cachedChunks, onLoadedPromise, onEndedPromise);
this._emitEvent('loadEnd', audioId);
return result;
}
async _playFromCache(audioId, chunks, onLoadedPromise, onEndedPromise) {
try {
this._setState('loading', audioId);
this._resetAudioElement();
if (this._options.enableTrimming) {
const result = await this._processor.processAudio(chunks, {
trimSilence: true,
silenceThresholdDb: this._options.silenceThresholdDb,
minSilenceMs: this._options.minSilenceMs,
outputFormat: 'wav'
});
this._audioElement.src = URL.createObjectURL(result.blob);
onLoadedPromise.resolve(audioId);
}
else {
const mediaSource = await this._createMediaSourceFromChunks(chunks, audioId, onLoadedPromise);
this._audioElement.src = URL.createObjectURL(mediaSource.mediaSource);
}
this._setupPlaybackPromises(audioId, onEndedPromise);
return {
audioId,
onLoaded: onLoadedPromise.promise,
onEnded: onEndedPromise.promise,
cancel: () => this._cancelStream(audioId)
};
}
catch (error) {
onLoadedPromise.reject(error);
onEndedPromise.reject(error);
throw error;
}
}
async _streamForPlayback(response, audioId, onLoadedPromise, signal) {
try {
this._setState('streaming', audioId);
this._resetAudioElement();
const reader = response.body.getReader();
const { value: firstChunk } = await reader.read();
if (!firstChunk || signal.aborted)
return;
const format = this._mediaSourceHelper.detectAudioFormat(firstChunk);
console.log('Streaming - Detected audio format:', format);
if (format.requiresConversion && format.type === 'wav') {
console.log('WAV file detected in streaming - using direct URL streaming (HTML audio element)');
reader.cancel();
this._audioElement.src = response.url;
this._audioElement.addEventListener('canplay', () => {
console.log('WAV file ready for playback');
this._audioElement.play().catch(error => {
console.warn('Auto-play failed for WAV file:', error);
});
}, { once: true });
this._audioElement.addEventListener('play', () => {
this._setState('playing', audioId);
}, { once: true });
this._audioElement.addEventListener('loadstart', () => {
console.log('WAV file started loading');
}, { once: true });
this._audioElement.addEventListener('progress', () => {
if (this._audioElement.buffered.length > 0) {
const bufferedEnd = this._audioElement.buffered.end(this._audioElement.buffered.length - 1);
const duration = this._audioElement.duration || 1;
const progress = bufferedEnd / duration;
this._emitEvent('bufferProgress', audioId, progress);
}
});
onLoadedPromise.resolve(audioId);
return;
}
const mediaSourceInfo = this._mediaSourceHelper.createMediaSource();
this._audioElement.src = URL.createObjectURL(mediaSourceInfo.mediaSource);
await this._waitForSourceOpen(mediaSourceInfo.mediaSource);
const mimeType = this._options.mimeType || this._mediaSourceHelper.getBestMimeType(format);
console.log('Streaming - Selected MIME type:', mimeType);
const sourceBuffer = await this._mediaSourceHelper.createSourceBuffer(mediaSourceInfo.mediaSource, mimeType);
await this._mediaSourceHelper.appendToSourceBuffer(sourceBuffer, firstChunk);
let playbackStarted = false;
let streamEnded = false;
while (!streamEnded && !signal.aborted) {
const { done, value } = await reader.read();
if (done) {
streamEnded = true;
break;
}
await this._mediaSourceHelper.appendToSourceBuffer(sourceBuffer, value);
if (!playbackStarted && this._isBufferSufficient()) {
playbackStarted = true;
onLoadedPromise.resolve(audioId);
this._setState('playing', audioId);
this._emitEvent('canPlay', audioId);
try {
await this._audioElement.play();
this._emitEvent('playStart', audioId);
}
catch (playError) {
console.warn('Playback failed:', playError);
}
}
}
if (streamEnded && !signal.aborted) {
try {
mediaSourceInfo.mediaSource.endOfStream();
}
catch (endError) {
console.warn('endOfStream error:', endError);
}
}
if (!playbackStarted && !signal.aborted) {
onLoadedPromise.resolve(audioId);
this._setState('playing', audioId);
try {
await this._audioElement.play();
this._emitEvent('playStart', audioId);
}
catch (playError) {
console.warn('Final playback attempt failed:', playError);
}
}
}
catch (error) {
if (!signal.aborted) {
onLoadedPromise.reject(error);
this._emitEvent('error', audioId, error);
}
}
}
async _cacheFromResponse(response, audioId, signal) {
if (!this._options.enableCaching)
return;
try {
const reader = response.body.getReader();
const chunks = [];
let mimeType = response.headers.get('content-type') || 'audio/mpeg';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done)
break;
chunks.push(value);
}
if (!signal.aborted && chunks.length > 0) {
await this._cache.set(audioId, chunks, mimeType);
}
}
catch (error) {
console.warn('Failed to cache audio:', error);
}
}
async _createMediaSourceFromChunks(chunks, audioId, onLoadedPromise) {
const mediaSourceInfo = this._mediaSourceHelper.createMediaSource();
const sourceOpenPromise = this._waitForSourceOpen(mediaSourceInfo.mediaSource);
sourceOpenPromise.then(async () => {
try {
const format = this._mediaSourceHelper.detectAudioFormat(chunks[0]);
console.log('Detected audio format:', format);
let processedChunks = chunks;
let mimeType = this._options.mimeType || this._mediaSourceHelper.getBestMimeType(format);
console.log('Selected MIME type:', mimeType);
if (format.requiresConversion && format.type === 'wav') {
console.log('WAV file detected - MediaSource does not support WAV/PCM, falling back to regular audio element');
let audioBlob;
if (this._options.enableTrimming) {
try {
const processingResult = await this._processor.processAudio(chunks, {
trimSilence: true,
silenceThresholdDb: this._options.silenceThresholdDb,
minSilenceMs: this._options.minSilenceMs,
outputFormat: 'wav',
stripID3: false
});
audioBlob = processingResult.blob;
console.log('WAV file processed for silence trimming');
}
catch (processingError) {
console.warn('WAV processing failed, using original file:', processingError);
audioBlob = new Blob(chunks, { type: format.mimeType });
}
}
else {
audioBlob = new Blob(chunks, { type: format.mimeType });
}
const url = URL.createObjectURL(audioBlob);
this._audioElement.src = url;
this._audioElement.addEventListener('ended', () => {
URL.revokeObjectURL(url);
}, { once: true });
this._audioElement.addEventListener('canplay', () => {
this._audioElement.play().catch(error => {
console.warn('Auto-play failed for WAV file:', error);
});
}, { once: true });
this._audioElement.addEventListener('play', () => {
this._setState('playing', audioId);
}, { once: true });
onLoadedPromise.resolve(audioId);
return;
}
const sourceBuffer = await this._mediaSourceHelper.createSourceBuffer(mediaSourceInfo.mediaSource, mimeType);
for (let i = 0; i < processedChunks.length; i++) {
await this._mediaSourceHelper.appendToSourceBuffer(sourceBuffer, processedChunks[i]);
}
try {
mediaSourceInfo.mediaSource.endOfStream();
}
catch (endError) {
console.warn('endOfStream error:', endError);
}
onLoadedPromise.resolve(audioId);
}
catch (error) {
onLoadedPromise.reject(error);
}
}).catch(error => {
onLoadedPromise.reject(error);
});
return { mediaSource: mediaSourceInfo.mediaSource };
}
_waitForSourceOpen(mediaSource) {
return new Promise((resolve, reject) => {
if (mediaSource.readyState === 'open') {
resolve();
return;
}
const onSourceOpen = () => {
mediaSource.removeEventListener('sourceopen', onSourceOpen);
mediaSource.removeEventListener('error', onError);
resolve();
};
const onError = (event) => {
mediaSource.removeEventListener('sourceopen', onSourceOpen);
mediaSource.removeEventListener('error', onError);
reject(new MediaSourceError('MediaSource failed to open', undefined, event));
};
mediaSource.addEventListener('sourceopen', onSourceOpen, { once: true });
mediaSource.addEventListener('error', onError, { once: true });
});
}
_setupAudioElementListeners() {
this._audioElement.addEventListener('canplay', () => {
this._state.canPlay = true;
this._emitEvent('canPlay', this._state.currentAudioId);
});
this._audioElement.addEventListener('ended', () => {
this._setState('ended', this._state.currentAudioId);
this._emitEvent('playEnd', this._state.currentAudioId);
});
this._audioElement.addEventListener('error', (event) => {
this._setState('error', this._state.currentAudioId, event.target.error?.message);
this._emitEvent('error', this._state.currentAudioId, event);
});
this._audioElement.addEventListener('progress', () => {
this._updateBufferProgress();
});
}
_setupPlaybackPromises(audioId, onEndedPromise) {
const onEnded = () => {
this._audioElement.removeEventListener('ended', onEnded);
this._audioElement.removeEventListener('error', onError);
onEndedPromise.resolve(audioId);
};
const onError = (event) => {
this._audioElement.removeEventListener('ended', onEnded);
this._audioElement.removeEventListener('error', onError);
onEndedPromise.reject(new AudioStreamingError('Playback error', 'PLAYBACK_ERROR', audioId, event.target.error));
};
this._audioElement.addEventListener('ended', onEnded, { once: true });
this._audioElement.addEventListener('error', onError, { once: true });
}
_resetAudioElement() {
this._audioElement.pause();
this._audioElement.removeAttribute('src');
this._audioElement.load();
this._state.canPlay = false;
this._state.bufferProgress = 0;
if (this._state.state === 'ended' || this._state.state === 'error') {
this._setState('idle');
}
}
_isBufferSufficient() {
if (this._audioElement.buffered.length === 0)
return false;
const bufferedEnd = this._audioElement.buffered.end(0);
return bufferedEnd >= this._options.bufferThreshold;
}
_updateBufferProgress() {
if (this._audioElement.buffered.length > 0) {
const bufferedEnd = this._audioElement.buffered.end(0);
const duration = this._audioElement.duration || bufferedEnd;
this._state.bufferProgress = Math.min(bufferedEnd / duration, 1);
this._emitEvent('bufferProgress', this._state.currentAudioId, this._state.bufferProgress);
}
}
_setState(state, audioId, error) {
this._state.state = state;
this._state.currentAudioId = audioId;
if (error) {
this._state.error = error;
}
this._emitEvent('stateChange', audioId, { state, error });
}
_cancelStream(audioId) {
const abortController = this._activeStreams.get(audioId);
if (abortController) {
abortController.abort();
this._activeStreams.delete(audioId);
}
}
_cancelAllActiveStreams() {
for (const [audioId, controller] of this._activeStreams) {
controller.abort();
}
this._activeStreams.clear();
}
_generateId() {
return `audio_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
_createPromise() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve: resolve, reject: reject };
}
_emitEvent(type, audioId, data) {
const callbacks = this._eventCallbacks.get(type);
if (callbacks) {
const event = {
type,
audioId,
data,
timestamp: Date.now()
};
callbacks.forEach(callback => {
try {
callback(event);
}
catch (error) {
console.error('Event callback error:', error);
}
});
}
}
on(eventType, callback) {
if (!this._eventCallbacks.has(eventType)) {
this._eventCallbacks.set(eventType, []);
}
this._eventCallbacks.get(eventType).push(callback);
}
off(eventType, callback) {
const callbacks = this._eventCallbacks.get(eventType);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
getState() {
return { ...this._state };
}
async getCacheStats() {
if (!this._options.enableCaching) {
throw new AudioStreamingError('Caching is disabled', 'CACHE_DISABLED');
}
return this._cache.getStats();
}
async clearCache() {
if (!this._options.enableCaching) {
throw new AudioStreamingError('Caching is disabled', 'CACHE_DISABLED');
}
await this._cache.clear();
}
getCapabilities() {
return {
mediaSource: this._mediaSourceHelper.getCapabilities(),
processor: this._processor.getCapabilities(),
caching: this._options.enableCaching
};
}
dispose() {
for (const [audioId, controller] of this._activeStreams) {
controller.abort();
}
this._activeStreams.clear();
this._eventCallbacks.clear();
this._cache.close();
this._processor.dispose();
this._resetAudioElement();
this._isInitialized = false;
}
}
//# sourceMappingURL=AudioStreamer.js.map