diff --git a/README.md b/README.md index 44fe369..092036e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # audio_x +![NPM Version](https://img.shields.io/npm/v/audio_x) ![NPM Downloads](https://img.shields.io/npm/dm/audio_x) + --- A simple audio player for all your audio playing needs, based on the HTML5 audio element. Supports most popular formats. diff --git a/package.json b/package.json index ba9fd6b..794a1cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audio_x", - "version": "1.0.9", + "version": "1.0.10-beta.13", "description": "The audio player for the gen-x", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/adapters/equalizer.ts b/src/adapters/equalizer.ts index 2a6bfa8..a39dd8d 100644 --- a/src/adapters/equalizer.ts +++ b/src/adapters/equalizer.ts @@ -9,6 +9,8 @@ class Equalizer { private audioCtx: AudioContext; private audioCtxStatus: EqualizerStatus; private eqFilterBands: BiquadFilterNode[]; + private bassBoostFilter: BiquadFilterNode; + private compressor: DynamicsCompressorNode; /** * Creates an instance of Equalizer or returns the existing instance. @@ -31,10 +33,15 @@ class Equalizer { * @private */ private initializeAudioContext() { + const audioContextOptions = { latencyHint: 'playback' }; if (typeof AudioContext !== 'undefined') { - this.audioCtx = new AudioContext(); + this.audioCtx = new AudioContext( + audioContextOptions as AudioContextOptions + ); } else if (typeof (window as any).webkitAudioContext !== 'undefined') { - this.audioCtx = new (window as any).webkitAudioContext(); + this.audioCtx = new (window as any).webkitAudioContext( + audioContextOptions + ); } else { console.error('Web Audio API is not supported in this browser.'); } @@ -77,21 +84,32 @@ class Equalizer { filter.type = band.type; filter.frequency.value = band.frequency; filter.gain.value = band.gain; - filter.Q.value = 1; + filter.Q.value = band.q || 1; // Use a default Q of 1 if not specified return filter; }); - const gainNode = this.audioCtx.createGain(); - gainNode.gain.value = 1; // TODO: Normalize sound output - + // Create a compressor for overall dynamic control + this.compressor = this.audioCtx.createDynamicsCompressor(); + this.compressor.threshold.value = -24; + this.compressor.knee.value = 30; + this.compressor.ratio.value = 12; + this.compressor.attack.value = 0.003; + this.compressor.release.value = 0.25; + + // Create the bass boost filter + this.bassBoostFilter = this.audioCtx.createBiquadFilter(); + this.bassBoostFilter.type = 'lowshelf'; + this.bassBoostFilter.frequency.value = 100; + this.bassBoostFilter.gain.value = 0; + + // Connect the nodes audioSource.connect(equalizerBands[0]); - for (let i = 0; i < equalizerBands.length - 1; i++) { equalizerBands[i].connect(equalizerBands[i + 1]); } - - equalizerBands[equalizerBands.length - 1].connect(gainNode); - gainNode.connect(this.audioCtx.destination); + equalizerBands[equalizerBands.length - 1].connect(this.bassBoostFilter); + this.bassBoostFilter.connect(this.compressor); + this.compressor.connect(this.audioCtx.destination); this.audioCtxStatus = 'ACTIVE'; this.eqFilterBands = equalizerBands; @@ -120,8 +138,10 @@ class Equalizer { return; } + const currentTime = this.audioCtx.currentTime; this.eqFilterBands.forEach((band, index) => { - band.gain.value = preset.gains[index]; + const targetGain = preset.gains[index]; + band.gain.setTargetAtTime(targetGain, currentTime, 0.05); }); } @@ -149,14 +169,79 @@ class Equalizer { * @param {number[]} gains - The gain values for each band. */ setCustomEQ(gains: number[]) { - if (isValidArray(gains)) { + if (isValidArray(gains) && gains.length === this.eqFilterBands.length) { + const currentTime = this.audioCtx.currentTime; this.eqFilterBands.forEach((band: BiquadFilterNode, index: number) => { - band.gain.value = gains[index]; + band.gain.setTargetAtTime(gains[index], currentTime, 0.05); }); } else { console.error('Invalid array of gains provided.'); } } + + /** + * Enables or disables bass boost. + * @param {boolean} enable - Whether to enable or disable bass boost. + * @param {number} gain - The gain value for bass boost. + */ + setBassBoost(enable: boolean, gain: number = 6) { + const currentTime = this.audioCtx.currentTime; + if (enable) { + this.bassBoostFilter.gain.setTargetAtTime(gain, currentTime, 0.05); + } else { + this.bassBoostFilter.gain.setTargetAtTime(0, currentTime, 0.05); + } + } + + /** + * Adjusts the compressor settings. + * @param {Partial} options - The compressor options to adjust. + */ + setCompressorSettings(options: Partial) { + if (this.compressor) { + if (options.threshold !== undefined) + this.compressor.threshold.setTargetAtTime( + options.threshold, + this.audioCtx.currentTime, + 0.01 + ); + if (options.knee !== undefined) + this.compressor.knee.setTargetAtTime( + options.knee, + this.audioCtx.currentTime, + 0.01 + ); + if (options.ratio !== undefined) + this.compressor.ratio.setTargetAtTime( + options.ratio, + this.audioCtx.currentTime, + 0.01 + ); + if (options.attack !== undefined) + this.compressor.attack.setTargetAtTime( + options.attack, + this.audioCtx.currentTime, + 0.01 + ); + if (options.release !== undefined) + this.compressor.release.setTargetAtTime( + options.release, + this.audioCtx.currentTime, + 0.01 + ); + } + } + + /** + * Resets the equalizer to flat response. + */ + reset() { + const currentTime = this.audioCtx.currentTime; + this.eqFilterBands.forEach((band: BiquadFilterNode) => { + band.gain.setTargetAtTime(0, currentTime, 0.05); + }); + this.bassBoostFilter.gain.setTargetAtTime(0, currentTime, 0.05); + } } export { Equalizer }; diff --git a/src/audio.ts b/src/audio.ts index d5f114f..88e56b3 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -90,7 +90,7 @@ class AudioX { enablePlayLog = false, enableHls = false, enableEQ = false, - crossOrigin = 'anonymous', + crossOrigin = null, hlsConfig = {} } = initProps; @@ -99,6 +99,7 @@ class AudioX { this._audio.autoplay = autoPlay; this._audio.crossOrigin = crossOrigin; this.isPlayLogEnabled = enablePlayLog; + this.isEqEnabled = enableEQ; if (customEventListeners !== null) { if (useDefaultEventListeners) { @@ -117,28 +118,42 @@ class AudioX { attachMediaSessionHandlers(); } - if (enableEQ) { - this.isEqEnabled = enableEQ; - } - if (enableHls) { const hls = new HlsAdapter(); hls.init(hlsConfig, enablePlayLog); } } - async addMedia(mediaTrack: MediaTrack) { + async addMedia( + mediaTrack: MediaTrack, + mediaFetchFn?: (mediaTrack: MediaTrack) => Promise + ) { if (!mediaTrack) { return; } + if (mediaFetchFn && !mediaTrack.source.length) { + this._fetchFn = mediaFetchFn; + } + + const queue = this.getQueue(); + if (isValidArray(queue)) { + const index = queue.findIndex((track) => mediaTrack.id === track.id); + if (index > -1) { + this._currentQueueIndex = index; + } + } + const mediaType = mediaTrack.source.includes('.m3u8') ? 'HLS' : 'DEFAULT'; if (this.isPlayLogEnabled) { calculateActualPlayedLength(audioInstance, 'TRACK_CHANGE'); } - if (mediaType === 'HLS') { + if ( + mediaType === 'HLS' && + !audioInstance.canPlayType('application/vnd.apple.mpegurl') + ) { const hls = new HlsAdapter(); const hlsInstance = hls.getHlsInstance(); if (hlsInstance) { @@ -165,7 +180,7 @@ class AudioX { } attachEq() { - if (this.isEqEnabled && this.eqStatus === 'IDEAL') { + if (this.eqStatus === 'IDEAL') { try { const eq = new Equalizer(); this.eqStatus = eq.status(); @@ -192,6 +207,9 @@ class AudioX { console.warn('cancelling current audio playback, track changed'); }); } + if (this.isEqEnabled) { + this.attachEq(); + } } /** @@ -209,23 +227,19 @@ class AudioX { ) { const currentTrack = mediaTrack || (this._queue.length > 0 ? this._queue[0] : undefined); - if (fetchFn && isValidFunction(fetchFn) && currentTrack) { + if (fetchFn && isValidFunction(fetchFn) && currentTrack?.source.length) { this._fetchFn = fetchFn; await fetchFn(currentTrack as MediaTrack); } - - if (this._queue && isValidArray(this._queue)) { - this._currentQueueIndex = this._queue.findIndex( - (track) => track.id === currentTrack?.id - ); - } try { if (currentTrack) { this.addMedia(currentTrack).then(() => { if (audioInstance.HAVE_ENOUGH_DATA === READY_STATE.HAVE_ENOUGH_DATA) { setTimeout(async () => { - this.attachEq(); await this.play(); + if (this.isEqEnabled) { + this.attachEq(); + } }, 950); } }); @@ -329,14 +343,31 @@ class AudioX { } setPreset(id: keyof Preset) { - this.eqInstance.setPreset(id); + if (this.isEqEnabled) { + this.eqInstance.setPreset(id); + } else { + console.error('Equalizer not initialized, please set enableEq at init'); + } } setCustomEQ(gains: number[]) { - this.eqInstance.setCustomEQ(gains); + if (this.isEqEnabled) { + this.eqInstance.setCustomEQ(gains); + } else { + console.error('Equalizer not initialized, please set enableEq at init'); + } + } + + setBassBoost(enabled: boolean, boost: number) { + if (this.isEqEnabled) { + this.eqInstance.setBassBoost(enabled, boost); + } else { + console.error('Equalizer not initialized, please set enableEq at init'); + } } addQueue(queue: MediaTrack[], playbackType: QueuePlaybackType) { + this.clearQueue(); const playerQueue = isValidArray(queue) ? queue.slice() : []; switch (playbackType) { case 'DEFAULT': @@ -353,27 +384,35 @@ class AudioX { break; } handleQueuePlayback(); - // Attaching MediaSession Handler again as this will make sure the next and previous button show up in notification + /* Attaching MediaSession Handler again as this will make sure that + the next and previous button show up in notification */ if (this.showNotificationsActions) { attachMediaSessionHandlers(); } } playNext() { - if (this._queue.length > this._currentQueueIndex + 1) { - this._currentQueueIndex++; - const nextTrack = this._queue[this._currentQueueIndex]; + const index = this._currentQueueIndex + 1; + if (this?._queue?.length > index) { + const nextTrack = this._queue[index]; this.addMediaAndPlay(nextTrack, this._fetchFn); + this._currentQueueIndex = index; } else { - console.warn('Queue ended'); + // stop the audio and end trigger queue ended + this.stop(); + notifier.notify('AUDIO_STATE', { + playbackState: PLAYBACK_STATE.QUEUE_ENDED + }); } } playPrevious() { - if (this._currentQueueIndex > 0) { - this._currentQueueIndex--; - const previousTrack = this._queue[this._currentQueueIndex]; + const index = this?._currentQueueIndex - 1; + + if (index >= 0) { + const previousTrack = this?._queue[index]; this.addMediaAndPlay(previousTrack, this._fetchFn); + this._currentQueueIndex = index; } else { console.log('At the beginning of the queue'); } @@ -382,6 +421,7 @@ class AudioX { clearQueue() { if (this._queue && isValidArray(this._queue)) { this._queue = []; + this._currentQueueIndex = 0; } } diff --git a/src/constants/common.ts b/src/constants/common.ts index 3ebd633..0a693be 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -17,7 +17,8 @@ const PLAYBACK_STATE = Object.freeze({ STALLED: 'stalled', ERROR: 'error', TRACK_CHANGE: 'trackchanged', - DURATION_CHANGE: 'durationchanged' + DURATION_CHANGE: 'durationchanged', + QUEUE_ENDED: 'queueended' }); const ERROR_MSG_MAP: ErrorMessageMap = Object.freeze({ diff --git a/src/constants/equalizer.ts b/src/constants/equalizer.ts index efd6b2b..057e506 100644 --- a/src/constants/equalizer.ts +++ b/src/constants/equalizer.ts @@ -1,16 +1,16 @@ import { Band } from 'types/equalizer.types'; const bands: Band[] = [ - { frequency: 31, type: 'lowshelf', gain: 0 }, - { frequency: 63, type: 'peaking', gain: 0 }, - { frequency: 125, type: 'peaking', gain: 0 }, - { frequency: 250, type: 'peaking', gain: 0 }, - { frequency: 500, type: 'peaking', gain: 0 }, - { frequency: 1000, type: 'peaking', gain: 0 }, - { frequency: 2000, type: 'peaking', gain: 0 }, - { frequency: 4000, type: 'peaking', gain: 0 }, - { frequency: 8000, type: 'peaking', gain: 0 }, - { frequency: 16000, type: 'highshelf', gain: 0 } + { frequency: 31, type: 'lowshelf', gain: 0, q: 1.2 }, + { frequency: 63, type: 'peaking', gain: 0, q: 1.2 }, + { frequency: 125, type: 'peaking', gain: 0, q: 1.2 }, + { frequency: 250, type: 'peaking', gain: 0, q: 1.2 }, + { frequency: 500, type: 'peaking', gain: 0, q: 1.2 }, + { frequency: 1000, type: 'peaking', gain: 0, q: 1.2 }, + { frequency: 2000, type: 'peaking', gain: 0, q: 1.2 }, + { frequency: 4000, type: 'peaking', gain: 0, q: 1.2 }, + { frequency: 8000, type: 'peaking', gain: 0, q: 1.2 }, + { frequency: 16000, type: 'highshelf', gain: 0, q: 1.2 } ]; const presets = [ diff --git a/src/events/audioEvents.ts b/src/events/audioEvents.ts index e0e3cbc..894048f 100644 --- a/src/events/audioEvents.ts +++ b/src/events/audioEvents.ts @@ -23,7 +23,8 @@ export const AUDIO_EVENTS: AudioEvents = Object.freeze({ PROGRESS: 'progress', LOAD_START: 'loadstart', ERROR: 'error', - TRACK_CHANGE: 'trackchange' // this is a custom event added to support track change + TRACK_CHANGE: 'trackchange', // this is a custom event added to support track change + QUEUE_ENDED: 'queueended' // this is a custom event added to support end of queue }); export const HLS_EVENTS = { diff --git a/src/events/baseEvents.ts b/src/events/baseEvents.ts index 2b34acf..4e03e8f 100644 --- a/src/events/baseEvents.ts +++ b/src/events/baseEvents.ts @@ -104,7 +104,8 @@ const BASE_EVENT_CALLBACK_MAP: EventListenerCallbackMap = { const isPaused = audioInstance.paused; const bufferedDuration = getBufferedDuration(audioInstance); - // below we check if the audio was already in paused state then we keep it as paused instead going to ready this make sure ready is fired only on the first load. + /* below we check if the audio was already in paused state then we keep + it as paused instead going to ready this make sure ready is fired only on the first load.*/ notifier.notify( 'AUDIO_STATE', { diff --git a/src/helpers/common.ts b/src/helpers/common.ts index 8b3a187..a7b3fa1 100644 --- a/src/helpers/common.ts +++ b/src/helpers/common.ts @@ -126,7 +126,7 @@ const handleQueuePlayback = () => { if (state.playbackState === 'ended' && !hasEnded) { const queue = audio.getQueue(); hasEnded = true; - if (queue && isValidArray(queue)) { + if (queue && isValidArray(queue) && hasEnded) { audio.playNext(); } } @@ -161,7 +161,49 @@ const shuffle = (array: T[]): T[] => { return shuffledArray; }; +const diffChecker = (d1: any, d2: any): boolean => { + if (d1 === null && d2 === null) { + return true; + } + + if (d1 === null || d2 === null) { + return false; + } + + if (typeof d1 !== typeof d2) { + return false; + } + + if (typeof d1 !== 'object') { + return d1 === d2; + } + + if (Array.isArray(d1) && Array.isArray(d2)) { + if (d1.length !== d2.length) { + return false; + } + + return d1.every((item, index) => diffChecker(item, d2[index])); + } + + const keys1 = Object.keys(d1); + const keys2 = Object.keys(d2); + + if (keys1.length !== keys2.length) { + return false; + } + + return keys1.every((key) => { + if (!keys2.includes(key)) { + return false; + } + + return diffChecker(d1[key], d2[key]); + }); +}; + export { + diffChecker, getBufferedDuration, getReadableErrorMessage, handleQueuePlayback, diff --git a/src/states/audioState.ts b/src/states/audioState.ts index 22997e1..e04d6fd 100644 --- a/src/states/audioState.ts +++ b/src/states/audioState.ts @@ -1,4 +1,5 @@ import { PLAYBACK_STATE } from 'constants/common'; +import { diffChecker } from 'helpers/common'; import ChangeNotifier from 'helpers/notifier'; import { ReadyState } from 'types'; import { AudioState, MediaTrack } from 'types/audio.types'; @@ -35,7 +36,12 @@ export const AUDIO_STATE: AudioState = { ChangeNotifier.listen( 'AUDIO_STATE', (audioState: AudioState) => { - ChangeNotifier.notify('AUDIO_X_STATE', { ...AUDIO_STATE, ...audioState }); + const latestState = ChangeNotifier.getLatestState( + 'AUDIO_X_STATE' + ) as AudioState; + if (!diffChecker(latestState, audioState)) { + ChangeNotifier.notify('AUDIO_X_STATE', { ...AUDIO_STATE, ...audioState }); + } }, AUDIO_STATE ); diff --git a/src/types/audio.types.ts b/src/types/audio.types.ts index 35e489a..fa570f6 100644 --- a/src/types/audio.types.ts +++ b/src/types/audio.types.ts @@ -14,7 +14,8 @@ export type PlayBackState = | 'error' | 'buffering' | 'trackchanged' - | 'durationchanged'; + | 'durationchanged' + | 'queueended'; export type MediaArtwork = { src: string; name?: string; sizes?: string }; export interface MediaTrack { @@ -41,7 +42,7 @@ export interface AudioInit { enablePlayLog?: boolean; enableHls?: boolean; enableEQ?: boolean; - crossOrigin?: string; + crossOrigin?: 'anonymous' | 'use-credentials' | null; hlsConfig?: HlsConfig | {}; } diff --git a/src/types/audioEvents.types.ts b/src/types/audioEvents.types.ts index 70a683a..664a9c9 100644 --- a/src/types/audioEvents.types.ts +++ b/src/types/audioEvents.types.ts @@ -24,6 +24,7 @@ export interface AudioEvents { LOAD_START: 'loadstart'; ERROR: 'error'; TRACK_CHANGE: 'trackchange'; // this is a custom event added to support track change + QUEUE_ENDED: 'queueended'; // this is a custom event added to support end of queue } export interface HlsEvents { diff --git a/src/types/equalizer.types.ts b/src/types/equalizer.types.ts index c5cfa14..556061f 100644 --- a/src/types/equalizer.types.ts +++ b/src/types/equalizer.types.ts @@ -2,6 +2,7 @@ export interface Band { frequency: number; type: BiquadFilterType; gain: number; + q: number; } export interface Preset {