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.
This commit is contained in:
Harrison Deng 2022-04-15 15:22:35 -05:00
parent b724db37d8
commit a6a5f75b82
9 changed files with 456 additions and 87 deletions

View File

@ -1,5 +1,6 @@
{
"cSpell.words": [
"audioshowkit",
"linebreak"
]
}

110
src/Visualizer.js Normal file
View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
};
}

View File

@ -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;
}
}

144
src/player/SongPlayer.js Normal file
View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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();
}
}