Refactored repo file structure in preparation for jsdoc tutorials.
This commit is contained in:
13
src/audioshowkitlib.js
Normal file
13
src/audioshowkitlib.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// TODO: basic playlist display.
|
||||
// TODO: Demo functions.
|
||||
// TODO: Detect annotated elements.
|
||||
// TODO: Create getting started.
|
||||
|
||||
|
||||
|
||||
import * as mappings from "./mapping/mappings.js";
|
||||
import * as player from "./player/player.js";
|
||||
import * as visualization from "./visualization/visualization.js";
|
||||
import * as support from "./support/support.js";
|
||||
|
||||
export { mappings, player, visualization, support };
|
82
src/mapping/dimensions.js
Normal file
82
src/mapping/dimensions.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import VisualizerUpdateManager from "../visualization/VisualizerUpdateManager.js";
|
||||
import { mapBinNumerical, mapRangedAvgNumerical } from "./numeric.js";
|
||||
|
||||
|
||||
/**
|
||||
* Maps the width of an element to frequency bin(s).
|
||||
*
|
||||
* @param {object} conf The configuration for mapping amplitudes to a width CSS property.
|
||||
* @param {HTMLElement} conf.element The html element to map the width to.
|
||||
* @param {number} conf.growLower The lower limit of the width.
|
||||
* @param {number} conf.growUpper The upper limit of the width.
|
||||
* @param {string} conf.unit The unit the upper and lower bounds are measured in.
|
||||
* @param {number} conf.lowerBin The lower boundary of bins to be mapped (or the single bin to be mapped if the upper bin is undefined).
|
||||
* @param {VisualizerUpdateManager} conf.visUpdateManager The visualizer update manager to be mapped to.
|
||||
* @param {interpolator} conf.interpolator The interpolation function to be used to transition from one value to the next.
|
||||
* @param {number} [conf.upperBin] The upper bin or undefined, which results in mapping to a single bin.
|
||||
* @param {boolean} [conf.reversed=false] If true, then high amplitudes are mapped to lower values and vice versa.
|
||||
* @returns {{bin: number, listener: VisualizerUpdateManager.visualizerBinUpdateListener}|{lower: number, upper: number, listener: VisualizerUpdateManager.visualizerRangedUpdateListener}} The listener that was added (ranged if an upper bound was provided, binned otherwise).
|
||||
*/
|
||||
function mapWidth({ element, growLower, growUpper, unit, lowerBin, visUpdateManager, interpolator, upperBin = undefined, reversed = false }) {
|
||||
const getter = () => element.style.width;
|
||||
const setter = (value) => element.style.width = value + unit;
|
||||
const conf = {
|
||||
minVal: growLower,
|
||||
maxVal: growUpper,
|
||||
bin: lowerBin,
|
||||
getter: getter,
|
||||
setter: setter,
|
||||
interpolator: interpolator,
|
||||
visUpdateManager: visUpdateManager,
|
||||
reversed: reversed
|
||||
};
|
||||
|
||||
if (upperBin) {
|
||||
conf.bin = undefined;
|
||||
conf.lowerBin = lowerBin;
|
||||
conf.upperBin = upperBin;
|
||||
return mapRangedAvgNumerical(conf);
|
||||
}
|
||||
return mapBinNumerical(conf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the height of an element to frequency bin(s).
|
||||
*
|
||||
* @param {object} conf The configuration for mapping amplitudes to a height CSS property.
|
||||
* @param {HTMLElement} conf.element The html element to map the height to.
|
||||
* @param {number} conf.growLower The lower limit of the height.
|
||||
* @param {number} conf.growUpper The upper limit of the height.
|
||||
* @param {string} conf.unit The unit the upper and lower bounds are measured in.
|
||||
* @param {number} conf.lowerBin The lower boundary of bins to be mapped (or the single bin to be mapped if the upper bin is undefined).
|
||||
* @param {VisualizerUpdateManager} conf.visUpdateManager The visualizer update manager to be mapped to.
|
||||
* @param {interpolator} conf.interpolator The interpolation function to be used to transition from one value to the next.
|
||||
* @param {number} [conf.upperBin] The upper bin or undefined, which results in mapping to a single bin.
|
||||
* @param {boolean} [conf.reversed=false] If true, then high amplitudes are mapped to lower values and vice versa.
|
||||
* @returns {{bin: number, listener: VisualizerUpdateManager.visualizerBinUpdateListener}|{lower: number, upper: number, listener: VisualizerUpdateManager.visualizerRangedUpdateListener}} The listener that was added (ranged if an upper bound was provided, binned otherwise).
|
||||
*/
|
||||
function mapHeight({ element, growLower, growUpper, unit, lowerBin, visUpdateManager, interpolator, upperBin = undefined, reversed = false }) {
|
||||
const getter = () => element.style.height;
|
||||
const setter = (value) => element.style.height = value + unit;
|
||||
const conf = {
|
||||
minVal: growLower,
|
||||
maxVal: growUpper,
|
||||
bin: lowerBin,
|
||||
getter: getter,
|
||||
setter: setter,
|
||||
interpolator: interpolator,
|
||||
visUpdateManager: visUpdateManager,
|
||||
reversed: reversed
|
||||
};
|
||||
|
||||
if (upperBin) {
|
||||
conf.bin = undefined;
|
||||
conf.lowerBin = lowerBin;
|
||||
conf.upperBin = upperBin;
|
||||
return mapRangedAvgNumerical(conf);
|
||||
}
|
||||
return mapBinNumerical(conf);
|
||||
}
|
||||
|
||||
/**@module */
|
||||
export { mapWidth, mapHeight };
|
5
src/mapping/mappings.js
Normal file
5
src/mapping/mappings.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as dimensions from "./dimensions.js";
|
||||
import * as numeric from "./numeric.js";
|
||||
|
||||
/**@module */
|
||||
export { dimensions, numeric };
|
87
src/mapping/numeric.js
Normal file
87
src/mapping/numeric.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// TODO: add for width
|
||||
// TODO: add for height
|
||||
// TODO: add for background
|
||||
|
||||
/**
|
||||
*
|
||||
* @callback numericalGetter
|
||||
* @returns {number} The number this value currently represents.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback numericalSetter
|
||||
* @param {number} value The new numerical value to update to.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maps the average of a range of bins to numerical value defined by a getter and a setter.
|
||||
*
|
||||
* @param {object} conf A configuration for how to map a numerical value.
|
||||
* @param {number} conf.minVal The minimum value of the numerical value.
|
||||
* @param {number} conf.maxVal The maximum value of the numerical value.
|
||||
* @param {number} conf.lowerBin The lower bin of the range of bins this value is to be mapped to.
|
||||
* @param {number} conf.upperBin The upper bin of the range of bins this value si to be mapped to.
|
||||
* @param {numericalGetter} conf.getter The getter callback to be used to get the current number.
|
||||
* @param {numericalSetter} conf.setter The setter callback to be used to set the new number.
|
||||
* @param {interpolator} conf.interpolator The interpolation function to use.
|
||||
* @param {VisualizerUpdateManager} conf.visUpdateManager the visualizer update manager this mapping corresponds with.
|
||||
* @param {boolean} [conf.reversed = false] If true, then high amplitudes will be mapped to low values and vice versa.
|
||||
* @returns {{lower: number, upper: number, listener: VisualizerUpdateManager.visualizerRangedUpdateListener}} An object containing the lower and upper bounds for the range of a listener, which is also in the object.
|
||||
*/
|
||||
function mapRangedAvgNumerical({ minVal, maxVal, lowerBin = undefined, upperBin, getter, setter, interpolator, visUpdateManager, reversed = false }) {
|
||||
const rangedListener = {
|
||||
lower: lowerBin,
|
||||
upper: upperBin,
|
||||
listener: (timeDelta, bins) => {
|
||||
const normalBins = [...bins];
|
||||
// TODO: Future: add weighting / distribution system?
|
||||
let average = 0;
|
||||
for (let i = 0; i < normalBins.length; i++) {
|
||||
normalBins[i] = normalBins[i] / 255.0;
|
||||
average += normalBins[i];
|
||||
}
|
||||
average /= normalBins.length;
|
||||
const range = maxVal - minVal;
|
||||
let interpolated = interpolator(getter(), average, timeDelta); // TODO: May be optimized by allowing for transferring of previous style state.
|
||||
if (reversed) interpolated = 1 - interpolated;
|
||||
setter(minVal + range * interpolated);
|
||||
}
|
||||
};
|
||||
if (visUpdateManager.addVisualizerRangedUpdateListener(rangedListener)) {
|
||||
return rangedListener;
|
||||
}
|
||||
return null; // Technically doesn't occur since the functions are anonymous?
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a single bin of frequency amplitudes to a numerical value defined by a getter and a setter.
|
||||
*
|
||||
* @param {object} conf The configuration for mapping a single bin to a numerical value.
|
||||
* @param {number} conf.minVal The minimum value the mapping can produce.
|
||||
* @param {number} conf.maxVal The maximum value the mapping can produce.
|
||||
* @param {number} conf.bin The bin to map this number to.
|
||||
* @param {numericalGetter} conf.getter The callback to be used to get the current number.
|
||||
* @param {numericalSetter} conf.setter The callback to be used to set the new number.
|
||||
* @param {interpolator} conf.interpolator The interpolation function to use.
|
||||
* @param {VisualizerUpdateManager} conf.visUpdateManager The update manager to map to.
|
||||
* @param {boolean} [conf.reversed] If true, then high amplitudes will be mapped to lower values and vice versa.
|
||||
* @returns {{bin: number, listener: VisualizerUpdateManager.visualizerBinUpdateListener}} The bin listener that was added.
|
||||
*/
|
||||
function mapBinNumerical({ minVal, maxVal, bin, getter, setter, interpolator, visUpdateManager, reversed = false }) {
|
||||
const listener = {
|
||||
bin: bin,
|
||||
listener: (timeDelta, amp) => {
|
||||
const range = maxVal - minVal;
|
||||
let interpolated = interpolator(getter(), amp, timeDelta);
|
||||
if (reversed) interpolated = 1 - interpolated;
|
||||
setter(minVal + range * interpolated);
|
||||
}
|
||||
};
|
||||
if (visUpdateManager.AddVisualizerBinUpdateListener(listener)) {
|
||||
return listener;
|
||||
}
|
||||
return null; // Technically doesn't occur since the functions are anonymous?
|
||||
}
|
||||
|
||||
/**@module */
|
||||
export { mapRangedAvgNumerical, mapBinNumerical };
|
86
src/mapping/text.js
Normal file
86
src/mapping/text.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { parseColor, rgbaToHexRgba } from "../support/colors.js";
|
||||
import { mapBinNumerical, mapRangedAvgNumerical } from "./numeric.js";
|
||||
|
||||
|
||||
/**
|
||||
* Maps the red component of the text color to a certain range of bins.
|
||||
*
|
||||
* @param {object} rgbaMapConfiguration The configuration of the mapping.
|
||||
* @param {HTMLElement} rgbaMapConfiguration.element The element whose text's red value should be mapped.
|
||||
* @param {number} rgbaMapConfiguration.color Where r for red, g, for green, b for blue, and a for alpha.
|
||||
* @param {number} rgbaMapConfiguration.lowerBin The lower bound of the bins to be mapped.
|
||||
* @param {VisualizerUpdateManager} rgbaMapConfiguration.visUpdateManager The visualizer update manager associated with the audio playback you would like the mapping with.
|
||||
* @param {interpolator} rgbaMapConfiguration.interpolator The interpolation function to use.
|
||||
* @param {number} [rgbaMapConfiguration.upperBin=undefined] The upper bound of the bins to be mapped. If left undefined, then only the bin defined by the min value is used.
|
||||
* @param {boolean} [rgbaMapConfiguration.reversed=true] If true, then the quieter, the greater the red value.
|
||||
* @returns {{bin: number, listener: VisualizerUpdateManager.visualizerBinUpdateListener}|{lower: number, upper: number, listener: visualizerRangedUpdateListener}} The ranged listener that was added.
|
||||
*/
|
||||
function mapRgba({ element, color, lowerBin, visUpdateManager, interpolator, upperBin = undefined, reversed = false }) {
|
||||
const rgbaStr = "rgba";
|
||||
color = rgbaStr.indexOf(color);
|
||||
if (color < 0) throw new Error("Invalid color parameter provided.");
|
||||
const getter = () => parseColor(element.style.color)[color];
|
||||
const setter = (value) => {
|
||||
const changed = parseColor(element.style.color);
|
||||
changed[color] = value;
|
||||
element.style.color = rgbaToHexRgba(changed);
|
||||
};
|
||||
const conf = {
|
||||
minVal: 0,
|
||||
maxVal: 255,
|
||||
bin: lowerBin,
|
||||
getter: getter,
|
||||
setter: setter,
|
||||
interpolator: interpolator,
|
||||
visUpdateManager: visUpdateManager,
|
||||
reversed: reversed
|
||||
};
|
||||
if (upperBin) {
|
||||
conf.bin = undefined;
|
||||
conf.lowerBin = lowerBin;
|
||||
conf.upperBin = upperBin;
|
||||
return mapRangedAvgNumerical(conf);
|
||||
}
|
||||
return mapBinNumerical(conf);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} fontSizeMapConfiguration The configuration of the font size mapping.
|
||||
* @param {HTMLElement} fontSizeMapConfiguration.element The element whose font sizes will be mapped to the amplitude.
|
||||
* @param {number} fontSizeMapConfiguration.growLower The lower limit of the font size.
|
||||
* @param {number} fontSizeMapConfiguration.growUpper The upper limit of the font size.
|
||||
* @param {string} fontSizeMapConfiguration.unit The unit the upper and lower bounds are measured in.
|
||||
* @param {number} fontSizeMapConfiguration.lowerBin The lower boundary of bins to be mapped (or the single bin to be mapped if the upper bin is undefined).
|
||||
* @param {VisualizerUpdateManager} fontSizeMapConfiguration.visUpdateManager the visualizer update manager to be mapped to.
|
||||
* @param {interpolator} fontSizeMapConfiguration.interpolator The interpolation function to be used to transition from one value to the next.
|
||||
* @param {number} [fontSizeMapConfiguration.upperBin=undefined] The upper bin, or undefined, which results in mapping to a single bin.
|
||||
* @param {boolean} [fontSizeMapConfiguration.reversed=false] If true, then high amplitudes are mapped to lower values and vice versa.
|
||||
* @returns {{bin: number, listener: VisualizerUpdateManager.visualizerBinUpdateListener}|{lower: number, upper: number, listener: VisualizerUpdateManager.visualizerRangedUpdateListener}} The listener that was added (ranged if an upper bound was provided, binned otherwise).
|
||||
*/
|
||||
function mapFontSize({ element, growLower, growUpper, unit, lowerBin, visUpdateManager, interpolator, upperBin = undefined, reversed = false }) {
|
||||
const getter = () => parseInt(element.style.fontSize);
|
||||
const setter = (value) => element.style.fontSize = value + unit;
|
||||
const conf = {
|
||||
minVal: growLower,
|
||||
maxVal: growUpper,
|
||||
bin: lowerBin,
|
||||
getter: getter,
|
||||
setter: setter,
|
||||
interpolator: interpolator,
|
||||
visUpdateManager: visUpdateManager,
|
||||
reversed: reversed
|
||||
};
|
||||
if (upperBin) {
|
||||
conf.bin = undefined;
|
||||
conf.lowerBin = lowerBin;
|
||||
conf.upperBin = upperBin;
|
||||
return mapRangedAvgNumerical(conf);
|
||||
}
|
||||
return mapBinNumerical(conf);
|
||||
}
|
||||
|
||||
// TODO: Future: map hue
|
||||
|
||||
/**@module */
|
||||
export { mapRgba, mapFontSize };
|
30
src/patterns/canvas.js
Normal file
30
src/patterns/canvas.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import Visualizer from "../visualization/Visualizer.js";
|
||||
|
||||
/**
|
||||
* Accepts a canvas and uses the entire canvas to draw a horizontal visualizer.
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvasElement the canvas element to use to draw this horizontal visualizer.
|
||||
* @param {Visualizer} visualizer the visualizer in which the data to display is obtained.
|
||||
*/
|
||||
function horizontalVisualizer(canvasElement, visualizer) {
|
||||
let _width = canvasElement.width;
|
||||
let _height = canvasElement.height;
|
||||
let _canvasCtx = canvasElement.getContext("2d");
|
||||
let _visualizer = visualizer;
|
||||
let update = function (delta, bins) {
|
||||
_canvasCtx.clearRect(0, 0, _width, _height); // clear canvas.
|
||||
let barWidth = Math.floor(_width / bins.length) - 1; // -1 for 1 pixel gap between bars.
|
||||
let barIndex = 0;
|
||||
bins.forEach(bin => {
|
||||
let normalBin = bin / 255.0;
|
||||
_canvasCtx.fillStyle = "rgb(" + 0 + "," + bin + "," + bin + ")";
|
||||
let barHeight = _height * normalBin;
|
||||
_canvasCtx.fillRect(barIndex * barWidth, _height - barHeight, barWidth, barHeight);
|
||||
barIndex += 1;
|
||||
});
|
||||
};
|
||||
_visualizer.addUpdateListener(update);
|
||||
}
|
||||
|
||||
/**@module */
|
||||
export { horizontalVisualizer };
|
199
src/player/PlaylistSong.js
Normal file
199
src/player/PlaylistSong.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import Visualizer from "../visualization/Visualizer.js";
|
||||
|
||||
/**@module */
|
||||
|
||||
/**
|
||||
* @callback AudioEventCallback
|
||||
* @param {HTMLAudioElement} audio
|
||||
*/
|
||||
|
||||
/**
|
||||
* A song with metadata that can be used as part of a {@link SongPlaylist}.
|
||||
*
|
||||
*/
|
||||
export default class PlayListSong {
|
||||
#displayName;
|
||||
#author;
|
||||
#url;
|
||||
#audio;
|
||||
#visualizer;
|
||||
#ready = false;
|
||||
|
||||
/**
|
||||
* Constructs a song for a {@link SongPlaylist}.
|
||||
*
|
||||
* @param {string} url the url to fetch the song from.
|
||||
* @param {string} name the name of the song.
|
||||
* @param {string} author the author of the song.
|
||||
*/
|
||||
constructor(url, name, author) {
|
||||
this.#displayName = name;
|
||||
this.#author = author;
|
||||
this.#url = url;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {AudioEventCallback} [onReady] called when the song is ready, including right away if the song is already ready.
|
||||
* @returns {HTMLAudioElement} The audio element that represents this song.
|
||||
*/
|
||||
fetchAudio(onReady) {
|
||||
console.log("Fetching audio...");
|
||||
if (this.#audio) {
|
||||
if (this.#ready) {
|
||||
console.log("Already ready.");
|
||||
if (onReady) onReady(this.#audio);
|
||||
} else if (onReady) {
|
||||
this.#audio.addEventListener("canplaythrough", () => {
|
||||
onReady(this.#audio);
|
||||
}, { once: true });
|
||||
}
|
||||
return this.#audio;
|
||||
}
|
||||
this.#audio = new Audio(this.#url);
|
||||
console.log("Fetching from url: " + this.#url);
|
||||
this.#audio.addEventListener("canplaythrough", () => {
|
||||
this.#ready = true;
|
||||
console.log("attempting to invoke onReady.");
|
||||
console.log(onReady);
|
||||
if (onReady && this.isAudioInstantiated()) {
|
||||
onReady(this.#audio);
|
||||
console.log("onReady invoked.");
|
||||
}
|
||||
}, { once: true });
|
||||
return this.#audio;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The name of the song to be displayed.
|
||||
*/
|
||||
get displayName() {
|
||||
return this.#displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The author of the song.
|
||||
*/
|
||||
get author() {
|
||||
return this.#author;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The url at which the file for this song can be found.
|
||||
*/
|
||||
get url() {
|
||||
return this.#url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if and only if there is audio data that is either already loaded or is being loaded.
|
||||
*/
|
||||
isAudioInstantiated() {
|
||||
return this.#audio ? true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins audio playback as soon as possible.
|
||||
*
|
||||
* This function uses the {@link PlaylistSong#getAudio} function and specifically, the {@link AudioEventCallback}.
|
||||
*/
|
||||
play() {
|
||||
this.fetchAudio((audio) => {
|
||||
audio.play();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the audio playback, unless the audio data has yet to be instantiated.
|
||||
*/
|
||||
pause() {
|
||||
if (!this.isAudioInstantiated()) return;
|
||||
this.fetchAudio((audio) => {
|
||||
audio.pause();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {number} The volume on a scale of 0 to 1.
|
||||
*/
|
||||
get volume() {
|
||||
return this.fetchAudio().volume;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* The normalized volume on a scale of 0 to 1.
|
||||
*/
|
||||
set volume(volume) {
|
||||
this.fetchAudio().volume = volume;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @returns {number} The number of seconds into the song.
|
||||
*/
|
||||
get currentTime() {
|
||||
return this.fetchAudio().currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* The time position in the song to jump to in seconds.
|
||||
*/
|
||||
set currentTime(currentTime) {
|
||||
this.fetchAudio().currentTime = currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {number} The duration of the song.
|
||||
*/
|
||||
get duration() {
|
||||
return this.fetchAudio().duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unloads the audio data.
|
||||
* Makes sure the audio is paused.
|
||||
*
|
||||
* Also calls {@link PlaylistSong#unloadVisualizer}.
|
||||
*/
|
||||
unloadAudio() {
|
||||
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.fetchAudio(), 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.
|
||||
* Stops the visualizer.
|
||||
*/
|
||||
unloadVisualizer() {
|
||||
if (this.#visualizer) {
|
||||
this.#visualizer.stop();
|
||||
this.#visualizer = null;
|
||||
}
|
||||
}
|
||||
}
|
365
src/player/SongPlayer.js
Normal file
365
src/player/SongPlayer.js
Normal file
@@ -0,0 +1,365 @@
|
||||
import "../styles/songPlayer.css";
|
||||
import PlayListSong from "./PlaylistSong.js";
|
||||
import SongPlaylist from "./SongPlaylist.js";
|
||||
|
||||
/**@module */
|
||||
|
||||
/**
|
||||
* @callback changeListener
|
||||
* @param {*} old the previous value.
|
||||
* @param {*} current the the current (new) value.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A player that keeps track of global properties for playback as well as traversing a playlist. Additionally keeps track of song audio data and attempts to reduce memory usage when possible.
|
||||
*/
|
||||
export default class SongPlayer {
|
||||
#playlist;
|
||||
#current = 0;
|
||||
#volume = 1;
|
||||
#autoplay = false;
|
||||
#wasPlaying = false;
|
||||
#playlistChangeListeners = [];
|
||||
#currentSongChangeListeners = [];
|
||||
#volumeChangeListeners = [];
|
||||
#allAudioEventListeners = {};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {SongPlaylist} playlist the playlist of songs that this player is in charge of.
|
||||
*/
|
||||
constructor(playlist) {
|
||||
this.playlist = playlist;
|
||||
this.addEventListenerToCurrentAudio("ended", () => {
|
||||
this.next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The new playlist of songs that this player is in charge of.
|
||||
*/
|
||||
set playlist(playlist) {
|
||||
this.#playlist?.unloadAllAudio();
|
||||
const old = this.#playlist;
|
||||
this.#playlist = playlist;
|
||||
if (!this.changeCurrentSongIndex(0)) {
|
||||
throw new Error("The provided playlist has no songs.");
|
||||
}
|
||||
this.#playlistChangeListeners.forEach(playlistChangeListener => {
|
||||
playlistChangeListener(old, this.getPlaylist());
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* The current playlist of songs that this player is in charge of.
|
||||
*
|
||||
* @returns {SongPlaylist} The song playlist this player is currently using.
|
||||
*/
|
||||
get playlist() {
|
||||
return this.#playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {boolean} true if and only if successful in going to the next song.
|
||||
*/
|
||||
next() {
|
||||
return this.changeCurrentSongIndex(this.currentSongIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* attempts to go to the previous song.
|
||||
*
|
||||
* @returns {boolean} true if and only if successful in going to the previous song.
|
||||
*/
|
||||
previous() {
|
||||
return this.changeCurrentSongIndex(this.currentSongIndex - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} index the index of the song to jump to.
|
||||
* @returns {boolean} true if and only if successful jumping to the given index.
|
||||
*/
|
||||
changeCurrentSongIndex(index) {
|
||||
console.log("Changing current song to " + index);
|
||||
if (index >= this.#playlist.total()) return false;
|
||||
if (index < 0) return false;
|
||||
Object.keys(this.#allAudioEventListeners).forEach(key => {
|
||||
const listeners = this.#allAudioEventListeners[key];
|
||||
listeners.forEach(listener => {
|
||||
this.currentSong.fetchAudio().removeEventListener(key, listener);
|
||||
});
|
||||
});
|
||||
this.currentSong.unloadAudio();
|
||||
const old = this.currentSong;
|
||||
this.#current = index;
|
||||
this.#currentSongChangeListeners.forEach(currentChangeListener => {
|
||||
currentChangeListener(old, this.currentSong);
|
||||
});
|
||||
Object.keys(this.#allAudioEventListeners).forEach(key => {
|
||||
const listeners = this.#allAudioEventListeners[key];
|
||||
listeners.forEach(listener => {
|
||||
this.currentSong.fetchAudio().addEventListener(key, listener);
|
||||
});
|
||||
});
|
||||
if (this.#wasPlaying || this.#autoplay) {
|
||||
this.playCurrent();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {number} The current song's index in the playlist.
|
||||
*/
|
||||
get currentSongIndex() {
|
||||
return this.#current;
|
||||
}
|
||||
|
||||
playCurrent() {
|
||||
console.log("playing " + this.#current);
|
||||
this.currentSong.fetchAudio((audio) => {
|
||||
audio.volume = this.volume;
|
||||
audio.play();
|
||||
});
|
||||
this.#wasPlaying = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the current playing song (if there is one playing).
|
||||
*/
|
||||
pauseCurrent() {
|
||||
console.log("Pausing.");
|
||||
this.currentSong.fetchAudio().pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles whether or not the current song is playing.
|
||||
*/
|
||||
togglePlay() {
|
||||
if (this.playing) {
|
||||
this.pauseCurrent();
|
||||
} else {
|
||||
this.playCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A number between 0 to 1 inclusive representing the volume of the player. Values out of these bounds will be rounded to the nearest bound.
|
||||
*/
|
||||
set volume(volume) {
|
||||
if (volume > 1) volume = 1;
|
||||
if (volume < 0) volume = 0;
|
||||
this.#volume = volume;
|
||||
this.currentSong.fetchAudio().volume = this.#volume;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {number} The current volume of the player represented by a number between 0 and 1 inclusive.
|
||||
*/
|
||||
get volume() {
|
||||
return this.#volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to seek to the given position.
|
||||
*
|
||||
* @param {number} position the position to seek to.
|
||||
* @returns {boolean} true if and only if the position to seek to is within the duration of the track. This also means that if the track has not finished loading the duration data, than this will always return false.
|
||||
*/
|
||||
seek(position) {
|
||||
if (position > this.currentSong.fetchAudio().duration || position < 0) return false;
|
||||
this.currentSong.fetchAudio(audio => {
|
||||
audio.currentTime = position;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {number} How many seconds into the audio track has been played in seconds.
|
||||
*/
|
||||
get currentPosition() {
|
||||
return this.currentSong.fetchAudio().currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {number} The total length of the song, or NaN if this information has not loaded yet.
|
||||
*/
|
||||
get currentDuration() {
|
||||
return this.currentSong.fetchAudio().duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for creating a play button complete with CSS for transitioning to a paused button.
|
||||
*
|
||||
* Automatically hooked up to the player's state change updates and play and pause functions.
|
||||
*
|
||||
* @returns {HTMLElement} the play button element that can be added to a DOM.
|
||||
*/
|
||||
generatePlayElement() {
|
||||
const playButton = document.createElement("button");
|
||||
playButton.classList.add("player");
|
||||
playButton.classList.add("play-btn");
|
||||
if (this.playing) playButton.classList.add("pause");
|
||||
|
||||
playButton.addEventListener("click", () => {
|
||||
console.log("Generated play button has been pressed.");
|
||||
this.togglePlay();
|
||||
});
|
||||
|
||||
this.addEventListenerToCurrentAudio("play", () => {
|
||||
playButton.classList.add("pause");
|
||||
});
|
||||
this.addEventListenerToCurrentAudio("pause", () => {
|
||||
playButton.classList.remove("pause");
|
||||
});
|
||||
return playButton;
|
||||
}
|
||||
|
||||
generateNextElement() {
|
||||
const nextButton = document.createElement("button");
|
||||
nextButton.classList.add("player");
|
||||
nextButton.classList.add("next");
|
||||
nextButton.addEventListener("click", () => {
|
||||
this.next();
|
||||
});
|
||||
return nextButton;
|
||||
}
|
||||
|
||||
generatePreviousElement() {
|
||||
const previousButton = document.createElement("button");
|
||||
previousButton.classList.add("player");
|
||||
previousButton.classList.add("previous");
|
||||
previousButton.addEventListener("click", () => {
|
||||
this.previous();
|
||||
});
|
||||
return previousButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {PlayListSong} The current playlist being used.
|
||||
*/
|
||||
get currentSong() {
|
||||
return this.#playlist.songAtIndex(this.#current);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {changeListener} listener the listener to receive the updates.
|
||||
* @returns {boolean} true if and only if successfully added the listener.
|
||||
*/
|
||||
addCurrentSongChangeListener(listener) {
|
||||
if (this.#currentSongChangeListeners.includes(listener)) return false;
|
||||
this.#currentSongChangeListeners.push(listener);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {changeListener} listener the song change listener to remove.
|
||||
* @returns {boolean} true if and only if the song change listener given was successfully removed.
|
||||
*/
|
||||
removeCurrentSongChangeListener(listener) {
|
||||
const removeIndex = this.#currentSongChangeListeners.indexOf(listener);
|
||||
if (removeIndex < 0) return false;
|
||||
|
||||
this.#currentSongChangeListeners.splice(removeIndex, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {changeListener} listener the playlist change listener to add to the callback list.
|
||||
* @returns {boolean} true if and only if the given listener was successfully registered.
|
||||
*/
|
||||
addPlaylistChangeListener(listener) {
|
||||
if (this.#playlistChangeListeners.includes(listener)) return false;
|
||||
this.#playlistChangeListeners.push(listener);
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {changeListener} listener the playlist change listener to remove.
|
||||
* @returns {boolean} true if and only if a listener was successfully removed from the callback list.
|
||||
*/
|
||||
removePlaylistChangeListener(listener) {
|
||||
const removeIndex = this.#playlistChangeListeners.indexOf(listener);
|
||||
if (removeIndex < 0) return false;
|
||||
|
||||
this.playlistChangeListener.splice(removeIndex, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {changeListener} listener the listener that is called when the player's volume is changed.
|
||||
* @returns {boolean} true if and only if the listener was successfully added.
|
||||
*/
|
||||
addVolumeChangeListener(listener) {
|
||||
if (this.#volumeChangeListeners.includes(listener)) return false;
|
||||
this.#volumeChangeListeners.push(listener);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {changeListener} listener the volume change listener to remove.
|
||||
* @returns {boolean} true if and only if a listener was successfully removed from the callback list.
|
||||
*/
|
||||
removeVolumeChangeListener(listener) {
|
||||
const removeIndex = this.#volumeChangeListeners.indexOf(listener);
|
||||
if (removeIndex < 0) return false;
|
||||
|
||||
this.#volumeChangeListeners.splice(removeIndex, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} type the type of the listener on the {@link HTMLAudioElement}.
|
||||
* @param {Function} eventListener the event listener.
|
||||
* @returns {boolean} true if and only if successfully registered event listener.
|
||||
*/
|
||||
addEventListenerToCurrentAudio(type, eventListener) {
|
||||
let typeListeners = this.#allAudioEventListeners[type];
|
||||
if (!typeListeners) {
|
||||
typeListeners = [];
|
||||
this.#allAudioEventListeners[type] = typeListeners;
|
||||
}
|
||||
if (typeListeners.includes(eventListener)) return false;
|
||||
typeListeners.push(eventListener);
|
||||
this.currentSong.fetchAudio().addEventListener(type, eventListener);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} type the type of the listener on the {@link HTMLAudioElement}.
|
||||
* @param {Function} eventListener the event listener.
|
||||
* @returns {boolean} true if and only if the event listener was successfully added.
|
||||
*/
|
||||
removeEventListenerFromCurrentAudio(type, eventListener) {
|
||||
let typeListeners = this.#allAudioEventListeners[type];
|
||||
if (!typeListeners) return false;
|
||||
const removeIndex = typeListeners.indexOf(eventListener);
|
||||
if (removeIndex < 0) return false;
|
||||
typeListeners.splice(removeIndex, 1);
|
||||
this.currentSong.fetchAudio().removeEventListener(type, eventListener);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} If the player is playing any songs right now.
|
||||
*/
|
||||
get playing() {
|
||||
return this.currentSong.isAudioInstantiated() && !this.currentSong.fetchAudio().paused;
|
||||
}
|
||||
}
|
93
src/player/SongPlaylist.js
Normal file
93
src/player/SongPlaylist.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import PlaylistSong from "./PlaylistSong.js";
|
||||
|
||||
/**@module */
|
||||
|
||||
/**
|
||||
* A playlist that holds a multitude of songs in the form of {@link PlaylistSong}.
|
||||
*/
|
||||
export default class SongPlaylist {
|
||||
#list = [];
|
||||
#name;
|
||||
|
||||
/**
|
||||
* Instantiates a playlist for songs.
|
||||
*
|
||||
* @param {string} name The name of the playlist.
|
||||
*/
|
||||
constructor(name) {
|
||||
this.#name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The name of the playlist.
|
||||
*/
|
||||
get name() {
|
||||
return this.#name;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} index the index of the song to retrieve.
|
||||
* @returns {PlaylistSong} the song at the given index.
|
||||
*/
|
||||
songAtIndex(index) {
|
||||
if (index >= this.#list.length) {
|
||||
return null;
|
||||
}
|
||||
return this.#list[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically creates and adds a {@see PlaylistSong} to this playlist.
|
||||
*
|
||||
* @param {string} url where the audio data can be found.
|
||||
* @param {string} name the name of the song.
|
||||
* @param {string} author the author(s) of the song.
|
||||
*/
|
||||
add(url, name, author) {
|
||||
this.#list.push(new PlaylistSong(url, name, author, this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a {@see playlistSong} from this playlist.
|
||||
*
|
||||
* @param {number} index the index of the song to be removed.
|
||||
* @returns {PlaylistSong} the song that was removed, or null if the index of was invalid.
|
||||
*/
|
||||
remove(index) {
|
||||
if (index >= this.#list.length) {
|
||||
return null;
|
||||
}
|
||||
let removed = this.#list.splice(index, 1)[0];
|
||||
if (removed.length > 0) {
|
||||
return removed[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to find a {@link PlaylistSong} given a name.
|
||||
*
|
||||
* @param {string} name the name of the song to be found.
|
||||
* @returns {number} the index of the song found, or -1 if it was not found.
|
||||
*/
|
||||
findSongIndex(name) {
|
||||
return this.#list.findIndex((item) => item.getDisplayName() == name);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {number} total number of songs in this playlist.
|
||||
*/
|
||||
total() {
|
||||
return this.#list.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unloads the audio data of all songs in this playlist.
|
||||
*/
|
||||
unloadAllAudio() {
|
||||
this.#list.forEach(playlistSong => {
|
||||
playlistSong.unloadAudio();
|
||||
});
|
||||
}
|
||||
}
|
90
src/player/VisualizedSongPlayer.js
Normal file
90
src/player/VisualizedSongPlayer.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import Visualizer from "../visualization/Visualizer.js";
|
||||
import VisualizerUpdateManager from "../visualization/VisualizerUpdateManager.js";
|
||||
import SongPlayer from "./SongPlayer.js";
|
||||
import SongPlaylist from "./SongPlaylist.js";
|
||||
|
||||
/**@module */
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @augments SongPlayer
|
||||
*/
|
||||
export default class VisualizedSongPlayer extends SongPlayer {
|
||||
#fftSize = 1024;
|
||||
#visualizerUpdateManager;
|
||||
|
||||
/**
|
||||
* Instantiates a song player with visualization features.
|
||||
*
|
||||
* @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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the the playlist.
|
||||
*/
|
||||
set playlist(playlist) {
|
||||
super.playlist = 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.
|
||||
*/
|
||||
get currentVisualizerUpdateManager() {
|
||||
return this.#visualizerUpdateManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Visualizer} The current song's visualizer.
|
||||
*/
|
||||
get currentSongVisualizer() {
|
||||
return this.getCurrentSong().getVisualizer();
|
||||
}
|
||||
}
|
7
src/player/player.js
Normal file
7
src/player/player.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import SongPlayer from "./SongPlayer.js";
|
||||
import SongPlaylist from "./SongPlaylist.js";
|
||||
import VisualizedSongPlayer from "./VisualizedSongPlayer.js";
|
||||
import PlayListSong from "./PlaylistSong.js";
|
||||
|
||||
/**@module */
|
||||
export { SongPlayer, SongPlaylist, VisualizedSongPlayer, PlayListSong };
|
34
src/styles/songPlayer.css
Normal file
34
src/styles/songPlayer.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.player.play-btn {
|
||||
box-sizing: border-box;
|
||||
height: 48px;
|
||||
width: 48;
|
||||
transition: 80ms all ease;
|
||||
will-change: border-width;
|
||||
cursor: pointer;
|
||||
border-color: transparent transparent transparent black;
|
||||
|
||||
border-style: solid;
|
||||
border-width: 24px 0px 24px 48px;
|
||||
}
|
||||
|
||||
.player.play-btn.pause {
|
||||
border-style: double;
|
||||
border-width: 0px 0px 0px 60px;
|
||||
}
|
||||
|
||||
|
||||
.player.next {
|
||||
border-style: none;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.player.previous {
|
||||
border-style: none;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
table.player {
|
||||
border-collapse: collapse;
|
||||
}
|
107
src/support/colors.js
Normal file
107
src/support/colors.js
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
/**
|
||||
* Converts from rgba hex value to an array of four numbers representing r, g, b, and a respectively.
|
||||
*
|
||||
* @param {string} hex the hex value.
|
||||
* @returns {number[]} the resulting components of the hex value.
|
||||
*/
|
||||
function rgbaHexToRgba(hex) {
|
||||
if (hex.startsWith("#")) {
|
||||
hex = hex.substring(1);
|
||||
if (hex.length > 8) {
|
||||
throw new Error("Invalid hex syntax (length).");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid hex syntax (missing pound).");
|
||||
}
|
||||
for (let i = hex.length; i < 8; i++) {
|
||||
hex = "0" + hex;
|
||||
}
|
||||
let remaining = hex.length;
|
||||
let result = [0, 0, 0, 0];
|
||||
result[3] = parseInt(hex.substring(remaining -= 2, remaining + 2), 16);
|
||||
result[2] = parseInt(hex.substring(remaining -= 2, remaining + 2), 16);
|
||||
result[1] = parseInt(hex.substring(remaining -= 2, remaining + 2), 16);
|
||||
result[0] = parseInt(hex.substring(remaining -= 2, remaining + 2), 16);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} hex the hex value.
|
||||
* @returns {number[]} the resulting r, g, and b components.
|
||||
*/
|
||||
function rgbHexToRgba(hex) {
|
||||
const result = rgbaHexToRgba(hex + "FF");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from the CSS rgba call to an array of four numbers representing r, g, b and a.
|
||||
*
|
||||
* @param {string} rgba The CSS rgba(r,g,b,a) call.
|
||||
* @returns {number[]} the rgba components.
|
||||
*/
|
||||
function cssRgbaToRgba(rgba) {
|
||||
const cssRgbaRegex = /rgba\((\d+),(\d+),(\d+),(\d+)\)/;
|
||||
try {
|
||||
const matches = rgba.match(cssRgbaRegex);
|
||||
return [parseInt(matches[1]), parseInt(matches[2]), parseInt(matches[3]), parseInt(matches[4])];
|
||||
} catch (error) {
|
||||
throw new Error("Could not parse the given css rgba function call: " + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from the CSS rgb call to an array of four numbers representing r, g, b and a.
|
||||
*
|
||||
* @param {string} rgb The CSS rgb(r,g,b) call.
|
||||
* @returns {number[]} the rgba components.
|
||||
*/
|
||||
function cssRgbToRgba(rgb) {
|
||||
const cssRgbRegex = /rgb\((\d+),(\d+),(\d+)\)/;
|
||||
try {
|
||||
const matches = rgb.match(cssRgbRegex);
|
||||
return [parseInt(matches[1]), parseInt(matches[2]), parseInt(matches[3]), 255];
|
||||
} catch (error) {
|
||||
throw new Error("Could not parse the given css rgb function call: " + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a given array of r, g, b, and a components into a hex string.
|
||||
*
|
||||
* @param {number[]} rgba an array with red, green, blue, and alpha components in that order.
|
||||
* @returns {string} The resulting hex value.
|
||||
*/
|
||||
function rgbaToHexRgba(rgba) {
|
||||
const filler = (hex) => hex.length < 2 ? "0" + hex : hex;
|
||||
return "#" + filler(rgba[0].toString(16)) + filler(rgba[1].toString(16)) + filler(rgba[2].toString(16)) + filler(rgba[3].toString(16));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts a css rgb, rgba, hex, or rgba hex to a rgba array.
|
||||
*
|
||||
* For hex, we assume there is no alpha channel unless the hex value is not minimized.
|
||||
*
|
||||
* @param {string} color The string that contains the color information.
|
||||
* @returns {number[]} an array that contains the r, g, b and a components.
|
||||
*/
|
||||
function parseColor(color) {
|
||||
if (color.startsWith("rgba(")) {
|
||||
return cssRgbaToRgba(color);
|
||||
} else if (color.startsWith("rgb(")) {
|
||||
return cssRgbToRgba(color);
|
||||
} else if (color.startsWith("#")) {
|
||||
if (color.length === 9) {
|
||||
return rgbaHexToRgba(color);
|
||||
} else {
|
||||
return rgbHexToRgba(color);
|
||||
}
|
||||
}
|
||||
throw new Error("Could not parse to an rgba value.");
|
||||
}
|
||||
|
||||
/**@module */
|
||||
export { rgbaHexToRgba, rgbHexToRgba, cssRgbaToRgba, cssRgbToRgba, rgbaToHexRgba, parseColor };
|
28
src/support/easings.js
Normal file
28
src/support/easings.js
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
/**
|
||||
* @callback interpolator
|
||||
* @param {number} current the current value.
|
||||
* @param {number} dest the destination value.
|
||||
* @param {number} frameDelta the time elapsed since the last update.
|
||||
* @returns {number} the new current value.
|
||||
*/
|
||||
|
||||
// TODO: Add some interpolation functions.
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} rate the number of frequency values to shift by per second.
|
||||
* @returns {interpolator} the interpolation function with the given rate.
|
||||
*/
|
||||
function createEaseLinear(rate) {
|
||||
return (current, dest, frameDelta) => {
|
||||
let direction = 1;
|
||||
if (dest < current) {
|
||||
direction = -1;
|
||||
}
|
||||
return current + (Math.min(rate * frameDelta, dest) * direction);
|
||||
};
|
||||
}
|
||||
|
||||
/**@module */
|
||||
export { createEaseLinear };
|
5
src/support/support.js
Normal file
5
src/support/support.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as colors from "./colors.js";
|
||||
import * as easings from "./easings.js";
|
||||
|
||||
/**@module */
|
||||
export { colors, easings };
|
119
src/visualization/Visualizer.js
Normal file
119
src/visualization/Visualizer.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**@module */
|
||||
|
||||
/**
|
||||
* @callback visualizerUpdateListener
|
||||
* @param {number} delta elapsed time since last update.
|
||||
* @param {Uint8Array} bins the bins with varying frequency values.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provides a simplified access point to the frequency bins in the form of a visualization update listener.
|
||||
*/
|
||||
export default class Visualizer {
|
||||
#stream;
|
||||
#analyzing = false;
|
||||
#updateListeners = [];
|
||||
#audioCtx;
|
||||
#source;
|
||||
#analyzer;
|
||||
#buffer;
|
||||
#lastUpdate;
|
||||
|
||||
/**
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
get numberOfBins() {
|
||||
return this.#buffer.length;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {number} The fft window size.
|
||||
*/
|
||||
get fftSize() {
|
||||
return this.#analyzer.fftSize;
|
||||
}
|
||||
}
|
182
src/visualization/VisualizerUpdateManager.js
Normal file
182
src/visualization/VisualizerUpdateManager.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import Visualizer from "./Visualizer.js";
|
||||
|
||||
/**@module */
|
||||
|
||||
/**
|
||||
* @callback visualizerBinUpdateListener
|
||||
* @param {number} timeDelta elapsed time since last update.
|
||||
* @param {number} amplitude The amplitude of the associated bin.
|
||||
* @param {number} ampDelta change in amplitude of the frequency bin.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback visualizerRangedUpdateListener
|
||||
* @param {number} timeDelta elapsed time since last update.
|
||||
* @param {number} bins the bins of the range.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* A visualizer update manager offers an extra layer of abstraction on top of the {@link Visualizer}'s update listener.
|
||||
*
|
||||
* Specifically, the update manager handles updates directly from the {@link Visualizer} and checks for changes in the individual bins. These changes are then broadcasted to the individual bin listeners.
|
||||
* In the rare event that a bin has not changed, then it will not receive an update call.
|
||||
*/
|
||||
export default class VisualizerUpdateManager {
|
||||
#binnedListeners = [];
|
||||
#rangedListeners = [];
|
||||
#lastBins;
|
||||
#visualizer;
|
||||
#visualizerListener;
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Visualizer} visualizer the visualizer this manager obtains data from.
|
||||
*/
|
||||
constructor(visualizer) {
|
||||
this.#binnedListeners = [];
|
||||
this.#rangedListeners = [];
|
||||
for (let i = 0; i < visualizer.getNumberOfBins(); i++) {
|
||||
this.#binnedListeners.push([]);
|
||||
}
|
||||
this.#lastBins = new Uint8Array(this.#binnedListeners.length);
|
||||
this.#visualizer = visualizer;
|
||||
this.#visualizerListener = (delta, bins) => {
|
||||
const sortedCopyOfRangedListeners = [... this.#rangedListeners]; // We assume this is sorted properly. A priority queue could be better.
|
||||
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], bins[binInd] - lastBin);
|
||||
});
|
||||
for (let rangedInd = 0; rangedInd < sortedCopyOfRangedListeners.length; rangedInd++) { // Could switch to a while loop.
|
||||
const { lower, upper, listener } = sortedCopyOfRangedListeners[rangedInd];
|
||||
if (lower > binInd) break; // Don't need to check the rest if the current lowest minimum is greater than the current bin index.
|
||||
if (binInd <= upper) {
|
||||
listener(delta, bins.slice(lower, upper));
|
||||
sortedCopyOfRangedListeners.shift();
|
||||
rangedInd--;
|
||||
}
|
||||
}
|
||||
this.#lastBins[binInd] = bins[binInd];
|
||||
}
|
||||
}
|
||||
};
|
||||
visualizer.addUpdateListener(this.#visualizerListener);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds a listener to a specific frequency bin.
|
||||
*
|
||||
* @param {object} freqBinListener the listener for a specific frequency bin.
|
||||
* @param {number} freqBinListener.freqBin the frequency bin this update listener should listen to.
|
||||
* @param {visualizerBinUpdateListener} freqBinListener.listener the listener itself that will be called upon the bin updating.
|
||||
* @returns {boolean} true if and only if the updater was added successfully.
|
||||
*/
|
||||
AddVisualizerBinUpdateListener({ freqBin, listener }) {
|
||||
if (this.#binnedListeners[freqBin].includes(listener)) return false;
|
||||
this.#binnedListeners[freqBin].push(listener);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link VisualizerUpdateManager#AddVisualizerBinUpdateListener}, this method adds a listener for to a range of bins.
|
||||
*
|
||||
* @param {object} rangedUpdateListener The ranged update listener to add.
|
||||
* @param {number} rangedUpdateListener.lower The lower bound of the bins to listen to (inclusive).
|
||||
* @param {number} rangedUpdateListener.upper The upper bound of the bins to listen to (inclusive).
|
||||
* @param {visualizerRangedUpdateListener} rangedUpdateListener.listener The listener to register to the range.
|
||||
* @returns {boolean} True if and only if the ranged listener was added successfully.
|
||||
*/
|
||||
addVisualizerRangedUpdateListener({ lower, upper, listener }) {
|
||||
const rangedListener = {
|
||||
lower: lower,
|
||||
upper: upper,
|
||||
listener: listener
|
||||
};
|
||||
if (this.#rangedListeners.includes(rangedListener)) return false;
|
||||
this.#rangedListeners.push(rangedListener);
|
||||
this.#rangedListeners.sort((a, b) => a.lower - b.lower);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} binFreqListener The bin frequency listener to remove.
|
||||
* @param {number} binFreqListener.freqBin the frequency bin the update listener to be removed from is in.
|
||||
* @param {visualizerBinUpdateListener} binFreqListener.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link removeVisualizerBinUpdateListener}, this method removes the given listener from a range of bins.
|
||||
*
|
||||
* @param {object} rangedListener The ranged listener to remove.
|
||||
* @param {number} rangedListener.lower The lower bound of bins to remove the listener from (inclusive).
|
||||
* @param {number} rangedListener.upper The upper bound of bin to remove the listener from (inclusive.)
|
||||
* @param {visualizerRangedUpdateListener} rangedListener.listener The update listener to remove from the bins.
|
||||
* @returns {boolean} True if and only if the given listener was removed.
|
||||
*/
|
||||
removeVisualizerRangedUpdateListener({ lower, upper, listener }) {
|
||||
const rangedListener = {
|
||||
lower: lower,
|
||||
upper: upper,
|
||||
listener: listener
|
||||
};
|
||||
const removeIndex = this.#rangedListeners.indexOf(rangedListener);
|
||||
if (removeIndex < 0) return false;
|
||||
this.#binnedListeners.splice(removeIndex, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} listeners The listeners that this visualizer update manager should be set to use.
|
||||
* @param {visualizerBinUpdateListener[][]} listeners.binned The bin listeners.
|
||||
* @param {object[]} listeners.ranged The array of ranged listeners.
|
||||
* @param {number} listeners.ranged[].lower The lower bound of the listener.
|
||||
* @param {number} listeners.ranged[].upper The upper bound of the listener.
|
||||
* @param {visualizerRangedUpdateListener} listeners.ranged[].listener The listener for the previously mentioned ranges.
|
||||
* @returns {boolean} true if and only if successfully loaded the new listeners.
|
||||
*/
|
||||
loadListeners(listeners) {
|
||||
if (listeners.binned.length !== this.#binnedListeners.length) return false;
|
||||
this.#binnedListeners = listeners.binned;
|
||||
this.#rangedListeners = listeners.ranged.sort((a, b) => a.lower - b.lower);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {object} All the listeners, both for binned, and ranged listeners. See {@link VisualizerUpdateManager#loadListeners} to see the structure of the returned object.
|
||||
*/
|
||||
retrieveListeners() {
|
||||
return {
|
||||
binned: this.#binnedListeners,
|
||||
ranged: this.#rangedListeners
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears this manager of all listeners.
|
||||
*/
|
||||
clearBinnedListeners() {
|
||||
this.#binnedListeners.forEach(bin => {
|
||||
bin.length = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbinds this update manager from the initial visualizer. Effectively meaning this manager will no longer be used.
|
||||
*/
|
||||
unbindVisualizer() {
|
||||
this.#visualizer.removeUpdateListener(this.#visualizerListener);
|
||||
}
|
||||
}
|
5
src/visualization/visualization.js
Normal file
5
src/visualization/visualization.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import Visualizer from "./Visualizer.js";
|
||||
import VisualizerUpdateManager from "./VisualizerUpdateManager.js";
|
||||
|
||||
/**@module */
|
||||
export { Visualizer, VisualizerUpdateManager };
|
Reference in New Issue
Block a user