From a6a5f75b82df594604b12e49dae88ef7d0929d71 Mon Sep 17 00:00:00 2001 From: Harrison Deng Date: Fri, 15 Apr 2022 15:22:35 -0500 Subject: [PATCH] Added Visualized song player and required playlist modifications (untested). Class names and respective file names refactored. Added song player for basic playlist management and playback. Added support for visualizers per song in the playlist. Created a visualizer update manager that acts as a splitter for all the bins and their updates. Fixed potential bugs. --- .vscode/settings.json | 1 + src/Visualizer.js | 110 ++++++++++++++++++++++ src/VisualizerCore.js | 52 ----------- src/VisualizerUpdateManager.js | 77 +++++++++++++++ src/player/Playlist.js | 28 ------ src/player/PlaylistSong.js | 39 +++++++- src/player/SongPlayer.js | 144 +++++++++++++++++++++++++++++ src/player/SongPlaylist.js | 3 +- src/player/VisualizedSongPlayer.js | 89 ++++++++++++++++++ 9 files changed, 456 insertions(+), 87 deletions(-) create mode 100644 src/Visualizer.js delete mode 100644 src/VisualizerCore.js create mode 100644 src/VisualizerUpdateManager.js delete mode 100644 src/player/Playlist.js create mode 100644 src/player/SongPlayer.js create mode 100644 src/player/VisualizedSongPlayer.js diff --git a/.vscode/settings.json b/.vscode/settings.json index b6ce489..4749c3b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "audioshowkit", "linebreak" ] } \ No newline at end of file diff --git a/src/Visualizer.js b/src/Visualizer.js new file mode 100644 index 0000000..9b782ee --- /dev/null +++ b/src/Visualizer.js @@ -0,0 +1,110 @@ +/** + * A visualizer for an audio stream. + */ +export default class Visualizer { + + /** + * @callback visualizerUpdateListener + * @param {number} delta elapsed time since last update. + * @param {Uint8Array} bins the bins with varying frequency values. + */ + + /** + * + * @param {MediaSource|HTMLMediaElement} mediaSource a media source to analyze. + * @param {number} [fftSize = 1024] the size of the fft window. + */ + constructor(mediaSource, fftSize = 1024) { + this._stream = mediaSource; + this._analyzing = false; + this._updateListeners = []; + this._audioCtx = new window.AudioContext(); + if (mediaSource instanceof HTMLMediaElement) { + this._source = this._audioCtx.createMediaElementSource(this._stream); + } else { + this._source = this._audioCtx.createMediaStreamSource(this._stream); + } + this._analyzer = this._audioCtx.createAnalyser(); + this._analyzer.fftSize = fftSize; + this._buffer = new Uint8Array(this._analyzer.frequencyBinCount); + this._source.connect(this._analyzer); + this._analyzer.connect(this._audioCtx.destination); + + this.lastUpdate = null; + } + + + /** + * Begins analyzing and sending out update pings. + */ + analyze() { + if (this._analyzing) { + return; + } + this._analyzing = true; + let self = this; // since calling from requestAnimationFrame means "this" is no longer set to produced object. + const update = (timestamp) => { + if (!self._analyzing) return; + + if (!self.lastUpdate) { + self.lastUpdate = timestamp; + } + let delta = timestamp - self.lastUpdate; + self._analyzer.getByteFrequencyData(self._buffer); + + self._updateListeners.forEach(listener => { + listener(delta, self._buffer); + }); + requestAnimationFrame(update); + }; + requestAnimationFrame(update); + } + + /** + * Stops the analysis. Listeners will stop receiving bins. + */ + stop() { + this._analyzing = false; + } + + /** + * + * @param {visualizerUpdateListener} listener the visualizer update listener to be registered. + * @returns {boolean} true if and only if the listener was successfully added. + */ + addUpdateListener(listener) { + if (this._updateListeners.includes(listener)); + this._updateListeners.push(listener); + return true; + } + + /** + * + * @param {visualizerUpdateListener} listener the visualizer update listener to remove. + * @returns {boolean} true if and only if the removal of the listener was a success. + */ + removeUpdateListener(listener) { + const removeIndex = this._updateListeners.indexOf(listener); + if (removeIndex < 0) return false; + + this._updateListeners.splice(removeIndex, 1); + return true; + } + + + /** + * + * @returns {number} the number of bins based on the size of the FFT window. + */ + getNumberOfBins() { + return this._buffer.length; + } + + /** + * + * @returns {number} the fft window size. + */ + getFftSize() { + return this._analyzer.fftSize; + } +} diff --git a/src/VisualizerCore.js b/src/VisualizerCore.js deleted file mode 100644 index 75b684c..0000000 --- a/src/VisualizerCore.js +++ /dev/null @@ -1,52 +0,0 @@ -export default class VisualizerCore { - constructor(mediaSource, fftSize = 1024) { - this._stream = mediaSource; - this._analyzing = false; - this._updateListeners = []; - this._audioCtx = new window.AudioContext(); - if (mediaSource instanceof HTMLMediaElement) { - this._source = this._audioCtx.createMediaElementSource(this._stream); - } else { - this._source = this._audioCtx.createMediaStreamSource(this._stream); - } - this._analyzer = this._audioCtx.createAnalyser(); - this._analyzer.fftSize = fftSize; - this._source.connect(this._analyzer); - this._analyzer.connect(this._audioCtx.destination); - this._buffer = new Uint8Array(this._analyzer.frequencyBinCount); - - this.lastUpdate = null; - } - - analyze() { - if (this._analyzing) { - return; - } - this._analyzing = true; - let self = this; // since calling from requestAnimationFrame means "this" is no longer set to produced object. - requestAnimationFrame((timestamp) => { - if (!self._analyzing) return; - - requestAnimationFrame(self.update); - if (!self.lastUpdate) { - self.lastUpdate = timestamp; - } - let delta = timestamp - self.lastUpdate; - self._analyzer.getByteFrequencyData(self._buffer); - - self._updateListeners.forEach(listener => { - listener(delta, self._buffer); - }); - }); - } - - stop() { - this._analyzing = false; - } - addUpdateListener(listener) { - this._updateListeners.push(listener); - } - getNumberOfBins() { - return this._buffer.length; - } -} diff --git a/src/VisualizerUpdateManager.js b/src/VisualizerUpdateManager.js new file mode 100644 index 0000000..95c8e19 --- /dev/null +++ b/src/VisualizerUpdateManager.js @@ -0,0 +1,77 @@ +import Visualizer from "./Visualizer"; + +export default class VisualizerUpdateManager { + /** + * + * @param {Visualizer} visualizer the visualizer this manager obtains data from. + */ + constructor(visualizer) { + this._binnedListeners = []; + for (let i = 0; i < visualizer.getNumberOfBins(); i++) { + this._binnedListeners.push([]); + } + this._lastBins = new Uint8Array(this._binnedListeners.length); + + visualizer.addUpdateListener((delta, bins) => { + for (let binInd = 0; binInd < this._lastBins.length; binInd++) { + const lastBin = this._lastBins[binInd]; + if (lastBin !== bins[binInd]) { + this._binnedListeners[binInd].forEach(listener => { + listener(delta, bins[binInd] - lastBin); + }); + this._lastBins[binInd] = bins[binInd]; + } + } + }); + } + + /** + * @callback visualizerBinUpdateListener + * @param {number} timeDelta elapsed time since last update. + * @param {number} ampDelta change in amplitude of the frequency bin. + */ + + /** + * + * @param {number} freqBin the frequency bin this update listener should listen to. + * @param {visualizerBinUpdateListener} listener the listener itself that will be called upon the bin updating. + * @returns {boolean} true if and only if the updater was added successfully, otherwise, false. + */ + AddVisualizerBinUpdateListener(freqBin, listener) { + if (this._binnedListeners[freqBin].includes(listener)) return false; + this._binnedListeners[freqBin].push(listener); + return true; + } + + /** + * + * @param {number} freqBin the frequency bin the update listener to be removed from is in. + * @param {visualizerBinUpdateListener} listener the listener that is to be removed. + * @returns {boolean} true if and only if the listener was successfully removed. + */ + removeVisualizerBinUpdateListener(freqBin, listener) { + const removeIndex = this._binnedListeners[freqBin].indexOf(listener); + if (removeIndex < 0) return false; + this._binnedListeners[freqBin].splice(removeIndex, 1); + return true; + } + + /** + * + * @param {visualizerBinUpdateListener[][]} binnedListeners an array of the same length as the number of bins where each element is another array containing the listeners for that bin. + * @returns {boolean} true if and only if successfully loaded the new listeners. + */ + setBinnedListeners(binnedListeners) { + if (binnedListeners.length !== this._binnedListeners.length) return false; + this._binnedListeners = binnedListeners; + return true; + } + + /** + * + * @returns {visualizerBinUpdateListener[][]} an array of the same length as the number of bins where each element is another array containing the listeners for that bin. + */ + getBinnedListeners() { + return this._binnedListeners; + } +} \ No newline at end of file diff --git a/src/player/Playlist.js b/src/player/Playlist.js deleted file mode 100644 index 6fa391b..0000000 --- a/src/player/Playlist.js +++ /dev/null @@ -1,28 +0,0 @@ -import PlaylistSong from "./PlaylistSong"; - -export default function Playlist() { - this._list = []; - - this.songAtIndex = function (index) { - return this.list[index]; - }; - - this.songsWithName = function (name) { - return this._list.filter((item) => item.getDisplayName() == name); - }; - - this.add = function (url, name, author) { - this._list.push(new PlaylistSong(url, name, author, this, this._list.length)); - }; - - this.remove = function (index) { - let removed = this._list.splice(index, 1); - if (removed.length > 0) { - return removed[0]; - } - }; - - this.findSongIndex = function (name) { - return this._list.findIndex((item) => item.getDisplayName() == name); - }; -} \ No newline at end of file diff --git a/src/player/PlaylistSong.js b/src/player/PlaylistSong.js index d08b909..c2519f8 100644 --- a/src/player/PlaylistSong.js +++ b/src/player/PlaylistSong.js @@ -1,3 +1,4 @@ +import Visualizer from "../Visualizer"; import SongPlaylist from "./SongPlaylist"; /** @@ -17,8 +18,9 @@ export default class PlayListSong { this._displayName = name; this._author = author; this._url = url; - this._audio = null; this._playlist = playlist; + this._audio = null; + this._visualizer = null; /** * Whether or not this song is ready to be played. @@ -40,7 +42,6 @@ export default class PlayListSong { if (this._audio) { if (this.ready) { if (onReady) onReady(this._audio); - return this._audio; } return this._audio; } @@ -91,7 +92,7 @@ export default class PlayListSong { * * @returns {boolean} true if and only if there is audio data that is either already loaded or is being loaded. */ - audioInstantiated() { + isAudioInstantiated() { return this._audio ? true : false; } @@ -108,7 +109,7 @@ export default class PlayListSong { * Pauses the audio playback, unless the audio data has yet to be instantiated. */ pause() { - if (!this.audioInstantiated()) return; + if (!this.isAudioInstantiated()) return; this.getAudio((audio) => { audio.pause(); }); @@ -157,9 +158,37 @@ export default class PlayListSong { * Unloads the audio data. */ unloadAudio() { - if (!this.audioInstantiated()) return; + if (!this.isAudioInstantiated()) return; this._audio.pause(); + this.unloadVisualizer(); this._audio = null; this.ready = false; } + + /** + * + * @param {number} [fftSize=1024] the size of the FFT window. + * @returns {Visualizer} returns the visualizer. + */ + getVisualizer(fftSize = 1024) { + if (this._visualizer && this._visualizer.getFftSize() === fftSize) return this._visualizer; + this._visualizer = new Visualizer(this.getAudio(), fftSize); + return this._visualizer; + } + + /** + * + * @returns {boolean} returns true if and only if the visualizer is instantiated. + */ + isVisualizerInstantiated() { + return this._visualizer ? true : false; + } + + /** + * Unloads the visualizer. + */ + unloadVisualizer() { + this._visualizer.stop(); + this._visualizer = null; + } } \ No newline at end of file diff --git a/src/player/SongPlayer.js b/src/player/SongPlayer.js new file mode 100644 index 0000000..424a13f --- /dev/null +++ b/src/player/SongPlayer.js @@ -0,0 +1,144 @@ +import SongPlaylist from "./SongPlaylist"; + +/** + * A player to play songs. + */ +export default class SongPlayer { + + /** + * + * @param {SongPlaylist} playlist the playlist of songs that this player is in charge of. + */ + constructor(playlist) { + this._playlist = playlist; + this._current = 0; + this._volume = 1; + } + + /** + * + * @param {SongPlaylist} playlist the new playlist of songs that this player is in charge of. + */ + setPlaylist(playlist) { + this._playlist.unloadAllAudio(); + this._playlist = playlist; + this._current = 0; + } + + /** + * + * @returns {SongPlaylist} the current playlist of songs that this player is in charge of. + */ + getPlaylist() { + return this._playlist; + } + + /** + * + * @returns {boolean} true if and only if successful in going to the next song. + */ + next() { + if (this._current >= this._playlist.total() - 1) return false; + this.getCurrentSong().unloadAudio(); + this._current += 1; + return true; + } + + /** + * attempts to go to the previous song. + * + * @returns {boolean} true if and only if successful in going to the previous song. + */ + previous() { + if (this._current <= 0) return false; + this.getCurrentSong().unloadAudio(); + this._current -= 1; + return true; + } + + /** + * + * @param {number} index the index of the song to jump to. + * @returns {boolean} true if and only if successful jumping to the given index. + */ + changeCurrent(index) { + if (index >= this._playlist.total()) return false; + if (index <= 0) return false; + this.getCurrentSong().unloadAudio(); + this._current = index; + return true; + } + + playCurrent() { + this.getCurrentSong().getAudio((audio) => { + // TODO: May need to perform synchronization check to see if this is still the song to be played. + audio.volume = this._volume; + audio.play(); + }); + } + + pauseCurrent() { + this.getCurrentSong().getAudio().pause(); + } + + /** + * + * @param {number} volume a number between 0 to 1 inclusive representing the volume of the player. + * @returns {boolean} true if and only if the volume was successfully set. + */ + setVolume(volume) { + if (volume > 1) return false; + if (volume < 0) return false; + this._volume = volume; + this.getCurrentSong().getAudio().volume = this._volume; + return true; + } + + /** + * + * @returns {number} the current volume of the player represented by a number between 0 and 1 inclusive. + */ + getVolume() { + return this._volume; + } + + seek(position) { + if (position > this.getCurrentSong().getAudio().duration || position < 0) return; + this.getCurrentSong().getAudio(audio => { + // TODO: May need to perform synchronization check to see if this is still the song to be played. + audio.currentTime = position; + }); + } + + getCurrentPosition() { + return this.getCurrentSong().getAudio().currentTime; + } + + getCurrentDuration() { + return this.getCurrentSong().getAudio().duration; + } + + generatePlayElement() { + // TODO: Generates a play button in html. + } + + generateNextElement() { + // TODO: generate a next button in html. + } + + generatePreviousElement() { + // TODO: generate a previous button in html. + } + + generateSeeker() { + // TODO: generate a seeker in html. + } + + generateVolumeSlider() { + // TODO: generate a volume slider in html. + } + + getCurrentSong() { + return this._playlist.songAtIndex(this._current); + } +} \ No newline at end of file diff --git a/src/player/SongPlaylist.js b/src/player/SongPlaylist.js index 54620a1..9d17000 100644 --- a/src/player/SongPlaylist.js +++ b/src/player/SongPlaylist.js @@ -48,14 +48,13 @@ export default class SongPlaylist { if (index >= this._list.length) { return null; } - let removed = this._list.splice(index, 1); + let removed = this._list.splice(index, 1)[0]; if (removed.length > 0) { return removed[0]; } } findSongIndex(name) { - // TODO: Could probably be optimized. return this._list.findIndex((item) => item.getDisplayName() == name); } diff --git a/src/player/VisualizedSongPlayer.js b/src/player/VisualizedSongPlayer.js new file mode 100644 index 0000000..a355c9d --- /dev/null +++ b/src/player/VisualizedSongPlayer.js @@ -0,0 +1,89 @@ +import Visualizer from "../Visualizer"; +import VisualizerUpdateManager from "../VisualizerUpdateManager"; +import SongPlayer from "./SongPlayer"; +import SongPlaylist from "./SongPlaylist"; + +/** + * A song player that provides easier access to the current songs visualizer data. + * + * Additionally, automatically re-binds all the visualizer update listeners for song changes. + * + * Automatically loads the songs. + */ +export default class VisualizedSongPlayer extends SongPlayer { + + /** + * + * @param {SongPlaylist} playlist the playlist this player manages. + * @param {number} [fftSize=1024] the size of the fft window for analysis. + */ + constructor(playlist, fftSize = 1024) { + super(playlist); + this._fftSize = fftSize; + this._visualizerUpdateManager = new VisualizerUpdateManager(this.getCurrentSongVisualizer()); + } + + /** + * + * @param {SongPlaylist} playlist the new playlist of songs that this player is in charge of. + */ + setPlaylist(playlist) { + super.setPlaylist(playlist); + this._visualizerUpdateManager = new VisualizerUpdateManager(this.getCurrentSongVisualizer()); + } + + /** + * + * @returns {boolean} true if and only if successful in changing to the next song. + */ + next() { + const updateListeners = this._visualizerUpdateManager.getBinnedListeners(); + if (!super.next()) return false; + this._visualizerUpdateManager = new VisualizerUpdateManager(this.getCurrentSongVisualizer()); + this._visualizerUpdateManager.setBinnedListeners(updateListeners); + return true; + } + + /** + * Jumps to the previous song if possible. + * + * @returns {boolean} true if and only if successful in switching to the previous song in the playlist. + */ + previous() { + const updateListeners = this._visualizerUpdateManager.getBinnedListeners(); + if (!super.previous()) return false; + + this._visualizerUpdateManager = new VisualizerUpdateManager(this.getCurrentSongVisualizer()); + this._visualizerUpdateManager.setBinnedListeners(updateListeners); + return true; + } + + /** + * + * @param {number} index the index of the song to change to. + * @returns {boolean} true if and only if successful in jumping to the given index. + */ + changeCurrent(index) { + const updateListeners = this.VisualizerUpdateManager.getBinnedListeners(); + if (!super.changeCurrent(index)) return false; + this._visualizerUpdateManager = new VisualizerUpdateManager(this.getCurrentSongVisualizer()); + this._visualizerUpdateManager.setBinnedListeners = updateListeners(); + return true; + } + + /** + * + * @returns {VisualizerUpdateManager} the current visualizer update manager. + */ + getCurrentVisualizerUpdateManager() { + return this._visualizerUpdateManager(); + } + + /** + * + * @returns {Visualizer} the current song's visualizer. + */ + getCurrentSongVisualizer() { + return this.getCurrentSong().getVisualizer(); + } +} \ No newline at end of file