UNPKG

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
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