Completed untested versions of playlist and playlist song classes.

Restructured for improved development and deployment pipeline.

Added webpack and configurations for development and production.

Began adding JSDocs.

Added eslint.
This commit is contained in:
Harrison Deng 2022-04-15 00:06:09 -05:00
parent 7474a0f6f5
commit ff3325b5db
19 changed files with 7015 additions and 325 deletions

35
.eslintrc.json Normal file
View File

@ -0,0 +1,35 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:jsdoc/recommended"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"jsdoc"
],
"rules": {
"indent": [
"error",
4
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
]
}
}

5
.vscode/settings.json vendored Normal file
View File

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

9
app.js
View File

@ -1,9 +0,0 @@
const express = require('express')
const app = express()
const port = process.env.PORT || 5000
app.use(express.static("pub"));
app.listen(port, () => {
console.log(`Static express server listening on port ${port}`)
});

6711
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,14 @@
},
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.17.3"
"devDependencies": {
"css-loader": "^6.7.1",
"eslint": "^8.13.0",
"eslint-plugin-jsdoc": "^39.2.1",
"style-loader": "^3.3.1",
"webpack": "^5.72.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.8.1",
"webpack-merge": "^5.8.0"
}
}

Binary file not shown.

View File

@ -1,54 +0,0 @@
"use strict";
function VisualizerCore(mediaSource, fftSize = 1024) {
this._stream = mediaSource;
this._analyzing = false;
this._updateListeners = [];
this._audioCtx = new window.AudioContext();
this._source = null; // Prepare for either a MediaStream or a MediaElement.
try {
this.source = this._audioCtx.createMediaStreamSource(this._stream);
} catch (e) {
this._source = this._audioCtx.createMediaElementSource(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;
this.analyze = function () {
if (this._analyzing) {
return;
}
this._analyzing = true;
requestAnimationFrame(this.update);
};
let vcore = this; // since calling from requestAnimationFrame means "this" is no longer set to produced object.
this.update = function (timestamp) {
if (!vcore._analyzing) return;
requestAnimationFrame(vcore.update);
if (!vcore.lastUpdate) {
vcore.lastUpdate = timestamp;
}
let delta = timestamp - vcore.lastUpdate;
vcore._analyzer.getByteFrequencyData(vcore._buffer);
vcore._updateListeners.forEach(listener => {
listener(delta, vcore._buffer);
});
};
this.stop = function () {
this._analyzing = false;
};
this.addUpdateListener = function (listener) {
this._updateListeners.push(listener);
};
this.getNumberOfBins = function () {
return this._buffer.length;
};
}

View File

@ -1,32 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<title>AudioShowKit visual test</title>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<script defer src="audioshowkit.js"></script>
<script defer src="patterns/HorizontalBar.js"></script>
<script defer src='examples.js'></script>
</head>
<body>
<h1>Hit start to see demo!</h1>
<button id="startbtn">Start.</button>
<h2>Horizontal Bar Pattern</h2>
<p>Below is a canvas that has all bin values being drawn live using the built-in horizontal bar pattern.</p>
<canvas id="horizontal">Canvases don't seem to be supported! Please try a different browser.</canvas>
<h2>Binding frequency bin values to element styles</h2>
<p>Has never been this easy! Here we bind the width of a div to a frequency bin.</p>
<div id="widthDiv"></div>
<p>Here we bind a frequency to the colour of a div.</p>
<div id="colourDiv"></div>
</body>
</html>

View File

@ -1,32 +0,0 @@
"use strict";
// We will see if the visualizers core and event systems are working correctly.
let startBtn = document.getElementById("startbtn");
startBtn.addEventListener("click", async (ev) => {
let mediaStream = new Audio("Elektronomia - Collide.mp3");
mediaStream.addEventListener("canplaythrough", (ev) => {
let visCore = new VisualizerCore(mediaStream, 128);
let coreAndEventCanvas = document.getElementById("horizontal");
coreAndEventCanvas.width = 640;
coreAndEventCanvas.height = 200;
mediaStream.play();
visCore.analyze();
bindHorizontalBar(coreAndEventCanvas, visCore); // Pre-made simple horizontal bar visualizer.
let widthDiv = document.getElementById("widthDiv");
widthDiv.style.height = "20px";
widthDiv.style.backgroundColor = "orange";
visCore.addUpdateListener((delta, bins) => {
widthDiv.style.width = bins[3] + "px";
})
let colourDiv = document.getElementById("colourDiv");
colourDiv.style.height = "20px";
visCore.addUpdateListener((delta, bins) => {
colourDiv.style.backgroundColor = "rgb(" + bins[0] + "," + bins[1] + ", " + bins[4] + ")";
})
});
});

View File

@ -1,21 +0,0 @@
"use strict";
function bindHorizontalBar(canvasElement, visualizerCore) {
let _width = canvasElement.width;
let _height = canvasElement.height;
let _canvasCtx = canvasElement.getContext("2d");
let _visualizerCore = visualizerCore;
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;
});
};
_visualizerCore.addUpdateListener(update);
}

View File

@ -1 +1,52 @@
// TODO: Reorganize such that audioshowkit.js uses VisualizerCore.js.
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

@ -1,54 +1,4 @@
"use strict";
function VisualizerCore(mediaSource, fftSize = 1024) {
this._stream = mediaSource;
this._analyzing = false;
this._updateListeners = [];
this._audioCtx = new window.AudioContext();
this._source = null; // Prepare for either a MediaStream or a MediaElement.
try {
this.source = this._audioCtx.createMediaStreamSource(this._stream);
} catch (e) {
this._source = this._audioCtx.createMediaElementSource(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;
this.analyze = function () {
if (this._analyzing) {
return;
}
this._analyzing = true;
requestAnimationFrame(this.update);
};
let vcore = this; // since calling from requestAnimationFrame means "this" is no longer set to produced object.
this.update = function (timestamp) {
if (!vcore._analyzing) return;
requestAnimationFrame(vcore.update);
if (!vcore.lastUpdate) {
vcore.lastUpdate = timestamp;
}
let delta = timestamp - vcore.lastUpdate;
vcore._analyzer.getByteFrequencyData(vcore._buffer);
vcore._updateListeners.forEach(listener => {
listener(delta, vcore._buffer);
});
};
this.stop = function () {
this._analyzing = false;
};
this.addUpdateListener = function (listener) {
this._updateListeners.push(listener);
};
this.getNumberOfBins = function () {
return this._buffer.length;
};
}
// TODO: Detect annotated elements.
// TODO: Set up global scoping.

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,26 +1,165 @@
export default function PlaylistSong(url, name, author, playlist) {
this._displayName = name;
this._author = author;
this._url = url;
this._mediaStream = null;
this._playlist = playlist;
import SongPlaylist from "./SongPlaylist";
this.getAudio = function () {
if (this.mediaStream) {
return this.mediaStream;
/**
* A song with metadata that can be used as part of a {@link SongPlaylist}.
*/
export default class PlayListSong {
/**
* 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.
* @param {SongPlaylist} playlist the {@link SongPlaylist} this song is part of.
*/
constructor(url, name, author, playlist) {
this._displayName = name;
this._author = author;
this._url = url;
this._audio = null;
this._playlist = playlist;
/**
* Whether or not this song is ready to be played.
*/
this.ready = false;
}
/**
* @callback AudioEventCallback
* @param {HTMLAudioElement} audio
*/
/**
*
* @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.
*/
getAudio(onReady) {
if (this._audio) {
if (this.ready) {
if (onReady) onReady(this._audio);
return this._audio;
}
return this._audio;
}
this.mediaStream = new Audio(url);
};
this.getDisplayName = function () {
this._audio = new Audio(this._url);
this._audio.addEventListener("canplaythrough", () => {
this.ready = true;
if (onReady) {
onReady(this._audio);
}
});
return this._audio;
}
/**
*
* @returns {string} representing the name of the song to be displayed.
*/
getDisplayName = function () {
return this._displayName;
};
this.getAuthor = function () {
/**
*
* @returns {string} representing the author of the song.
*/
getAuthor = function () {
return this._getAuthor;
};
this.getUrl = function () {
/**
*
* @returns {string} representing the url at which the file for this song can be found.
*/
getUrl = function () {
return this._url;
};
this.getPlaylist = function () {
/**
*
* @returns {SongPlaylist} the playlist this song is part of.
*/
getPlaylist = function () {
return this._playlist;
};
/**
*
* @returns {boolean} true if and only if there is audio data that is either already loaded or is being loaded.
*/
audioInstantiated() {
return this._audio ? true : false;
}
/**
* Begins audio playback as soon as possible.
*/
play() {
this.getAudio((audio) => {
audio.play();
});
}
/**
* Pauses the audio playback, unless the audio data has yet to be instantiated.
*/
pause() {
if (!this.audioInstantiated()) return;
this.getAudio((audio) => {
audio.pause();
});
}
/**
*
* @returns {number} the volume on a scale of 0 to 1.
*/
getVolume() {
return this.getAudio().volume;
}
/**
*
* @param {number} volume a normalized volume on a scale of 0 to 1.
*/
setVolume(volume) {
this.getAudio().volume = volume;
}
/**
*
* @returns {number} the number of seconds into the song.
*/
getCurrentTime() {
return this.getAudio().currentTime;
}
/**
*
* @param {number} currentTime the time position in the song to jump to in seconds.
*/
setCurrentTime(currentTime) {
this.getAudio().currentTime = currentTime;
}
/**
*
* @returns {number} the duration of the song.
*/
getDuration() {
return this.getAudio().duration;
}
/**
* Unloads the audio data.
*/
unloadAudio() {
if (!this.audioInstantiated()) return;
this._audio.pause();
this._audio = null;
this.ready = false;
}
}

View File

@ -0,0 +1,78 @@
import PlaylistSong from "./PlaylistSong";
/**
* A playlist that holds a multitude of songs.
*/
export default class SongPlaylist {
/**
* Instantiates a playlist for songs.
*
* @param {string} name The name of the playlist.
*/
constructor(name) {
this._list = [];
this._name = name;
this._current = 0;
}
/**
*
* @returns {string} the name of the string.
*/
getName() {
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];
}
songsWithName(name) {
return this._list.filter((item) => item.getDisplayName() == name);
}
add(url, name, author) {
this._list.push(new PlaylistSong(url, name, author, this, this._list.length));
}
remove(index) {
if (index >= this._list.length) {
return null;
}
let removed = this._list.splice(index, 1);
if (removed.length > 0) {
return removed[0];
}
}
findSongIndex(name) {
// TODO: Could probably be optimized.
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();
});
}
}

21
webpack.common.js Normal file
View File

@ -0,0 +1,21 @@
export default {
entry: {
audioshowkit: "./src/audioshowkit.js"
},
module: {
rules: [
{
test: /\.html$/i,
loader: "html-loader",
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
}
]
},
output: {
filename: "[name].js",
path: "./dist"
}
};

13
webpack.dev.js Normal file
View File

@ -0,0 +1,13 @@
import merge from "webpack-merge";
import webpackCommon from "./webpack.common";
devConfig = {
mode: "development",
devtools: "inline-source-map",
devServer: {
static: "./dist"
}
}
export default merge(webpackCommon, devConfig);

12
webpack.prod.js Normal file
View File

@ -0,0 +1,12 @@
import merge from "webpack-merge"
import webpackCommon from "./webpack.common"
prodConfig = {
mode: "production",
output: {
filename: "[name].js",
path: "./public"
}
}
export default merge(webpackCommon, prodConfig);