import VisuComponentMono from '../utils/VisuComponentMono.js';
import CanvasUtils from '../utils/CanvasUtils.js';
import ColorUtils from '../utils/ColorUtils.js';
const MAX_CANVAS_WIDTH = 32000;
class Timeline extends VisuComponentMono {
/** @summary Timeline displays a scrolling audio waveform.
* @author Arthur Beaulieu
* @since 2020
* @augments VisuComponentMono
* @description <blockquote>Will display a waveform that scrolls over playback. If provided, BPM is visualised as
* vertical bars with emphasis on main beats according to time signature. It is interactive and will update the player's
* current time value to match the dragged one. This class extends VisuComponentMono only because it performs an offline
* analysis on audio and the stereo information are already held in audio buffer.</blockquote>
* @param {object} options - The timeline options
* @param {string} options.type - The component type as string
* @param {object} options.player - The player to take as processing input (if inputNode is given, player source will be ignored)
* @param {object} options.renderTo - The DOM element to render canvas in
* @param {number} options.fftSize - The FFT size for analysis. Must be a power of 2. High values may lead to heavy CPU cost
* @param {object} [options.audioContext=null] - The audio context to base analysis from
* @param {object} [options.inputNode=null] - The audio node to take source instead of player's one
* @param {object} [options.beat=null] - The beat configuration
* @param {object} [options.beat.offset=null] - offset before first beat
* @param {object} [options.beat.bpm=null] - The track bpm
* @param {object} [options.beat.timeSignature=null] - The track time signature to put emphasis on main beats
* @param {object} [options.wave] - The wave options
* @param {object} [options.wave.align='center'] - The alignment of the wave, can be either 'top', 'center' or 'bottom'
* @param {object} [options.colors] - Timeline color potions
* @param {object} [options.colors.background='#1D1E25'] - Canvas background color in Hex/RGB/HSL
* @param {object} [options.colors.track='#12B31D'] - The timeline color in Hex/RGB/HSL
* @param {object} [options.colors.mainBeat='#56D45B'] - The main beat triangles color in Hex/RGB/HSL
* @param {object} [options.colors.subBeat='#FF6B67'] - The sub beat triangles color in Hex/RGB/HSL
* @param {object[]} [options.hotCues=[]] - Hotcues sorted array to load waveform with. Each array item must contain a time key with its value **/
constructor(options) {
super(options);
this._colors = {
background: options.colors ? options.colors.background || ColorUtils.defaultBackgroundColor : ColorUtils.defaultBackgroundColor,
track: options.colors ? options.colors.track || ColorUtils.defaultDarkPrimaryColor : ColorUtils.defaultDarkPrimaryColor,
mainBeat: options.colors ? options.colors.mainBeat || ColorUtils.defaultPrimaryColor : ColorUtils.defaultPrimaryColor,
subBeat: options.colors ? options.colors.subBeat || ColorUtils.defaultAntiPrimaryColor : ColorUtils.defaultAntiPrimaryColor,
loop: options.colors ? options.colors.loop || ColorUtils.defaultLoopColor : ColorUtils.defaultLoopColor,
loopAlpha: options.colors ? options.colors.loopAlpha || ColorUtils.defaultLoopAlphaColor : ColorUtils.defaultLoopAlphaColor
};
this._canvas.style.backgroundColor = this._colors.background;
this._canvasSpeed = options.speed ? options.speed : 5.0; // Time in seconds
this._beat = {
offset: options.beat ? options.beat.offset : null,
bpm: options.beat ? options.beat.bpm : null,
timeSignature: options.beat ? options.beat.timeSignature : null,
};
this._wave = {
align: options.wave ? options.wave.align || 'center' : 'center',
scale: options.wave ? options.wave.scale || .95 : .95
};
// HotCues and beats arrays
this._hotCues = [...options.hotCues] || [];
this._beatsArray = [];
this._beatCount = '0.0';
// Loop utils
this._loopEntry = null;
this._loopEnd = null;
this._loopBuffer = null;
this._isLooping = false;
this._loopStartedAt = 0;
this._playerPausedAt = 0;
this._audioBuffer = null; // Store audio buffer to avoid multiple loading of file during loop process
// Offline canvas -> main canvas is divided with 32k px wide canvases
this._canvases = [];
this._cueCanvases = [];
this._beatCanvases = [];
this._loopCanvases = [];
// Drag canvas utils
this._isDragging = false;
this._wasPlaying = false;
this._draggedTime = 0;
this._startDrag = {
x: 0,
y: 0
};
if (this._player.src !== '') {
this._getPlayerSourceFile();
}
}
/* --------------------------------------------------------------------------------------------------------------- */
/* -------------------------------------- VISUCOMPONENTMONO OVERRIDES ----------------------------------------- */
/* --------------------------------------------------------------------------------------------------------------- */
/** @method
* @name _fillAttributes
* @private
* @override
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Internal method to fill internal properties from options object sent to constructor.</blockquote>
* @param {object} options - The frequency circle options
* @param {string} options.type - The component type as string
* @param {object} options.player - The player to take as processing input (if inputNode is given, player source will be ignored)
* @param {object} options.renderTo - The DOM element to render canvas in
* @param {number} options.fftSize - The FFT size for analysis. Must be a power of 2. High values may lead to heavy CPU cost
* @param {object} [options.audioContext=null] - The audio context to base analysis from
* @param {object} [options.inputNode=null] - The audio node to take source instead of player's one **/
_fillAttributes(options) {
super._fillAttributes(options);
this._offlineCtx = null;
this._offlineBuffer = null;
// Local event binding
this._trackLoaded = this._trackLoaded.bind(this);
this._onProgress = this._onProgress.bind(this);
this._mouseDown = this._mouseDown.bind(this);
this._mouseMove = this._mouseMove.bind(this);
this._mouseUp = this._mouseUp.bind(this);
}
/** @method
* @name _addEvents
* @private
* @override
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Add component events (resize, play, pause, dbclick).</blockquote> **/
_addEvents() {
super._addEvents();
this._player.addEventListener('loadedmetadata', this._trackLoaded, false);
this._player.addEventListener('timeupdate', this._onProgress, false);
this._canvas.addEventListener('mousedown', this._mouseDown, false);
if (!this._player.paused) {
this._isPlaying = true;
requestAnimationFrame(this._processAudioBin);
}
}
/** @method
* @name _removeEvents
* @private
* @override
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Remove component events (resize, play, pause, dbclick).</blockquote> **/
_removeEvents() {
super._removeEvents();
this._player.removeEventListener('loadedmetadata', this._trackLoaded, false);
this._player.removeEventListener('timeupdate', this._onProgress, false);
}
/** @method
* @name _onResize
* @private
* @override
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>On resize event callback.</blockquote> **/
_onResize() {
super._onResize();
this._fillData();
this._clearCanvas();
this._drawTimeline(this._player.currentTime);
}
_clearCanvas(clearBeat, clearHotCue, clearLoop) {
super._clearCanvas();
// Clear beat bars canvas
if (clearBeat) {
for (let i = 0; i < this._beatCanvases.length; ++i) {
this._beatCanvases[i].getContext('2d').clearRect(0, 0, this._beatCanvases[i].width, this._beatCanvases[i].height);
}
}
// Clear hot cue canvas
if (clearHotCue) {
for (let i = 0; i < this._cueCanvases.length; ++i) {
this._cueCanvases[i].getContext('2d').clearRect(0, 0, this._cueCanvases[i].width, this._cueCanvases[i].height);
}
}
// Clear loop canvas
if (clearLoop) {
for (let i = 0; i < this._loopCanvases.length; ++i) {
this._loopCanvases[i].getContext('2d').clearRect(0, 0, this._loopCanvases[i].width, this._loopCanvases[i].height);
}
}
}
/** @method
* @name _dblClick
* @private
* @override
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>On double click event callback.</blockquote> **/
_dblClick() {
// Required to revoke fullscreen toggle from parent class, as it interferes with drag feature
}
/** @method
* @name _processAudioBin
* @private
* @override
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Real time method called by WebAudioAPI to process PCM data. Here we make a 8 bit frequency
* and time analysis.</blockquote> **/
_processAudioBin() {
if (this._isPlaying === true || this._isLooping === true) {
// So UI keeps being update while player is virtually paused
if (this._isLooping === true) {
this._player.currentTime = this._loopEntry.time + (this._playerPausedAt + this._audioCtx.currentTime - this._loopStartedAt) % (this._loopEnd.time - this._loopEntry.time);
}
// Draw timeline and request new process in raf
this._clearCanvas();
this._drawTimeline(this._player.currentTime);
requestAnimationFrame(this._processAudioBin);
}
}
/* ---------- Timeline internal methods ---------- */
_startLoopSequence(immediateLoop) {
if (immediateLoop) {
this._player.currentTime = this._loopEntry.time;
}
const workingBuffer = this._audioBuffer.slice();
this._audioCtx.decodeAudioData(workingBuffer, buffer => {
this._loopBuffer = this._audioCtx.createBufferSource();
this._loopBuffer.buffer = buffer;
this._loopBuffer.connect(this._audioCtx.destination);
this._loopBuffer.loop = true;
this._loopBuffer.loopStart = this._loopEntry.time;
this._loopBuffer.loopEnd = this._loopEnd.time;
this._loopBuffer.start(0, this._player.currentTime);
this._player.pause();
this._loopStartedAt = this._audioCtx.currentTime;
this._playerPausedAt = this._player.currentTime;
this._isLooping = true;
this._processAudioBin();
});
}
/** @method
* @name _trackLoaded
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Player callback on track loaded.</blockquote> **/
_trackLoaded() {
cancelAnimationFrame(this._processAudioBin);
this._clearCanvas(); // Clear previous canvas
// Do XHR to request file and parse it
this._getPlayerSourceFile();
}
/** @method
* @name _onProgress
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>On progress callback.</blockquote> **/
_onProgress() {
this._clearCanvas();
this._drawTimeline(this._player.currentTime || 0);
}
/** @method
* @name _mouseDown
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Mouse down callback.</blockquote>
* @param {object} event - The mouse down event **/
_mouseDown(event) {
const rect = event.target.getBoundingClientRect();
// X coord must be relative to cuent canvas. Check half width to center coord, then add center position, module MAX_CANVAS_WIDTH
const x = ((event.clientX - rect.left) - (this._canvas.width / 2) + ((this._player.currentTime / this._canvasSpeed) * this._canvas.width)) % MAX_CANVAS_WIDTH;
const y = event.clientY - rect.top;
const hotCue = this._hotCueClicked(x, y);
if (hotCue) {
this._player.currentTime = hotCue.time;
this._clearCanvas();
this._drawTimeline(this._player.currentTime);
} else {
this._isDragging = true;
this._startDrag.x = event.clientX;
this._startDrag.y = event.clientY;
// Save previous playback status and pause only if required
if (this._player.paused === false) {
this._wasPlaying = true;
this._player.pause();
}
// Subscribe to drag events
this._canvas.addEventListener('mousemove', this._mouseMove, false);
this._canvas.addEventListener('mouseup', this._mouseUp, false);
this._canvas.addEventListener('mouseout', this._mouseUp, false);
}
}
/** @method
* @name _mouseDown
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Mouse move callback.</blockquote>
* @param {object} event - The mouse move event **/
_mouseMove(event) {
// Only perform drag code if mouse down was previously fired
if (this._isDragging === true) {
const variation = (this._startDrag.x - event.clientX);
const timeOffset = ((variation * this._canvasSpeed) / this._canvas.width) * 2;
this._draggedTime = this._player.currentTime + timeOffset;
this._clearCanvas();
this._drawTimeline(this._draggedTime);
}
}
/** @method
* @name _mouseUp
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Mouse up callback.</blockquote> **/
_mouseUp() {
this._isDragging = false;
this._startDrag.x = 0;
this._startDrag.y = 0;
this._player.currentTime = this._draggedTime || this._player.currentTime;
this._draggedTime = null;
// Restore playback status
if (this._wasPlaying === true) {
this._wasPlaying = false;
this._player.play();
}
// Remove drag events
this._canvas.removeEventListener('mousemove', this._mouseMove, false);
this._canvas.removeEventListener('mouseup', this._mouseUp, false);
this._canvas.removeEventListener('mouseout', this._mouseUp, false);
}
/** @method
* @name _processAudioFile
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Perform an offline analysis on whole track.</blockquote>
* @param {object} response - HTTP response for audio track to extract buffer from **/
_processAudioFile(response) {
this._audioBuffer = response.slice();
// Set offline context according to track duration to get its full samples
this._offlineCtx = new OfflineAudioContext(2, this._audioCtx.sampleRate * this._player.duration, this._audioCtx.sampleRate);
this._offlineSource = this._offlineCtx.createBufferSource();
this._audioCtx.decodeAudioData(response, buffer => {
this._offlineSource.buffer = buffer;
this._offlineSource.connect(this._offlineCtx.destination);
this._offlineSource.start();
this._offlineCtx.startRendering().then(renderedBuffer => {
this._offlineBuffer = renderedBuffer;
this._fillData();
this._drawTimeline(this._player.currentTime || 0);
}).catch(function(err) {
console.log('Rendering failed: ' + err);
});
});
}
/** @method
* @name _fillData
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Generate merged data from audio buffer.</blockquote> **/
_fillData() {
if (this._offlineBuffer) {
// Clear any previous canvas
this._canvases = [];
this._cueCanvases = [];
this._beatCanvases = [];
this._loopCanvases = [];
// Compute useful values
const data = this._genScaledMonoData(this._offlineBuffer);
const step = (this._canvasSpeed * this._offlineBuffer.sampleRate) / this._canvas.width;
const totalLength = Math.round((this._offlineBuffer.duration / this._canvasSpeed) * this._canvas.width);
// Draw full track on offline canvas
for (let i = 0; i < totalLength; i += MAX_CANVAS_WIDTH) {
// Create canvas with width of the reduced-in-size buffer's length.
const canvas = document.createElement('CANVAS');
const ctx = canvas.getContext('2d');
const cueCanvas = document.createElement('CANVAS');
const beatCanvas = document.createElement('CANVAS');
const loopCanvas = document.createElement('CANVAS');
let width = totalLength - i;
width = (width > MAX_CANVAS_WIDTH) ? MAX_CANVAS_WIDTH : width;
// Update offline canvas dimension
canvas.width = width;
canvas.height = this._canvas.height;
cueCanvas.width = width;
cueCanvas.height = this._canvas.height;
beatCanvas.width = width;
beatCanvas.height = this._canvas.height;
loopCanvas.width = width;
loopCanvas.height = this._canvas.height;
// Clear offline context
ctx.clearRect(0, 0, totalLength, this._canvas.height);
// Draw the canvas
for (let j = 0; j < width; ++j) {
const offset = Math.floor((i + j) * step);
let max = 0.0; // The max value to draw
// Update maximum value in step range
for (let k = 0; k < step; ++k) {
if (data[offset + k] > max) {
max = data[offset + k];
}
}
// Set waveform color according to sample intensity
ctx.fillStyle = ColorUtils.lightenDarkenColor(this._colors.track, (max * 190)); // 190, not 255 to avoid full white on sample at max value
// Update max to scale in half canvas height
max = Math.floor(max * (this._canvas.height * this._wave.scale));
if (this._wave.align === 'center') {
// Fill up and down side of timeline
ctx.fillRect(j, this._canvas.height / 2, 1, -(max / 2));
ctx.fillRect(j, this._canvas.height / 2, 1, (max / 2));
// Add tiny centered line
ctx.fillRect(j, (this._canvas.height / 2) - 0.5, 1, 1);
} else if (this._wave.align === 'top') {
ctx.fillRect(j, 1, 1, max);
} else if (this._wave.align === 'bottom') {
ctx.fillRect(j, this._canvas.height - 1, 1, -max);
}
}
// Store canvas to properly animate Timeline on progress
this._canvases.push(canvas);
this._cueCanvases.push(cueCanvas);
this._beatCanvases.push(beatCanvas);
this._loopCanvases.push(loopCanvas);
}
if (this._beat.bpm !== null && this._beat.offset !== null) {
this._fillBeatBars({
totalWidth: (this._offlineBuffer.duration / this._canvasSpeed) * this._canvas.width,
beatWidth: ((1 / (this._beat.bpm / 60)) / this._canvasSpeed) * this._canvas.width,
beatOffset: (this._beat.offset / this._canvasSpeed) * this._canvas.width
});
}
this._drawHotCues(); // Load hot cues if any
}
}
_fillBeatBars(options) {
let beatOffset = options.beatOffset;
let canvasIndex = 0; // The offline canvas to consider
// We floor because last beat is pretty irrelevant
for (let i = 0; i < Math.floor(options.totalWidth / options.beatWidth); ++i) {
// We reached MAX_CANVAS_WIDTH, using next offline canvas
if ((i * options.beatWidth + beatOffset) >= MAX_CANVAS_WIDTH + (canvasIndex * MAX_CANVAS_WIDTH)) {
// Increment offline canvas to use
++canvasIndex;
// When changing canvas, the beatOffset is dependant to last beat saved position.
for (let j = 1; j < canvasIndex; ++i) {
// We iterate for each canvas, and sums the offset per canvas so they cumulates
beatOffset += options.beatWidth - ((MAX_CANVAS_WIDTH * j) - (this._beatsArray[this._beatsArray.length - 1].xPos % (MAX_CANVAS_WIDTH * j)));
}
}
// Draw beat bar, x position is loop index times a space between beats, plus the beat offset,
// modulo max canvas width to fit in offline canvases
this._drawBeatBar(i, (i * options.beatWidth) + beatOffset, canvasIndex);
}
}
/** @method
* @name _drawBeatBar
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Draw a beat bar with its triangle with color that depends on main beat or sub beat.</blockquote>
* @param {object} beatCount - The beat number from first
* @param {object} canvas - The canvas to draw in
* @param {object} ctx - The associated context
* @param {number} j - The y value **/
_drawBeatBar(beatCount, x, canvasIndex) {
const canvas = this._beatCanvases[canvasIndex];
const ctx = canvas.getContext('2d');
// Determine beat bar color
if (beatCount % this._beat.timeSignature === 0) {
ctx.fillStyle = 'white';
} else {
ctx.fillStyle = 'grey';
}
// Beat bar drawing
ctx.fillRect(x % MAX_CANVAS_WIDTH, 9, 1, this._canvas.height - 18);
// Determine beat triangle color
if (beatCount % this._beat.timeSignature === 0) {
ctx.fillStyle = this._colors.mainBeat;
} else {
ctx.fillStyle = this._colors.subBeat;
}
// Upper triangle
CanvasUtils.drawTriangle(canvas, {
x: x % MAX_CANVAS_WIDTH,
y: 1,
radius: 6,
top: 10
});
// Down triangle
CanvasUtils.drawTriangle(canvas, {
x: x % MAX_CANVAS_WIDTH,
y: this._canvas.height - 1,
radius: 6,
top: this._canvas.height - 10
});
// Update beats array with new beat bar
this._beatsArray.push({
primaryBeat: (beatCount % this._beat.timeSignature === 0),
beatCount: beatCount,
xPos: x,
time: x * this._canvasSpeed / this._canvas.width,
canvasIndex: canvasIndex
});
}
/** @method
* @name _genScaledMonoData
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Merged L/R Sub sample channel data to compute average value, depending on bar count.</blockquote>
* @param {object} buffer - Audio buffer
* @return {number[]} Array of height per sub samples **/
_genScaledMonoData(buffer) {
const dataL = buffer.getChannelData(0);
const dataR = buffer.getChannelData(1);
const output = [];
for (let i = 0; i < dataL.length; ++i) {
output.push((Math.abs(dataL[i]) + Math.abs(dataR[i])) / 2);
}
return output;
}
/** @method
* @name _drawTimeline
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Draw timeline with a given progress.</blockquote>
* @param {number} time - Track current time **/
_drawTimeline(time) {
const center = Math.floor(time * this._canvas.width / this._canvasSpeed);
let leftEdgeIndex = Math.floor((center - (this._canvas.width / 2)) / MAX_CANVAS_WIDTH);
if (leftEdgeIndex < 0) {
leftEdgeIndex = 0;
}
let rightEdgeIndex = Math.floor((center + (this._canvas.width / 2)) / MAX_CANVAS_WIDTH);
if (rightEdgeIndex >= this._canvases.length) {
rightEdgeIndex = this._canvases.length - 1;
}
for (let i = leftEdgeIndex; i <= rightEdgeIndex; ++i) {
this._ctx.drawImage(this._canvases[i], (this._canvas.width / 2) - center + (MAX_CANVAS_WIDTH * i), 0);
this._ctx.drawImage(this._beatCanvases[i], (this._canvas.width / 2) - center + (MAX_CANVAS_WIDTH * i), 0);
this._ctx.drawImage(this._cueCanvases[i], (this._canvas.width / 2) - center + (MAX_CANVAS_WIDTH * i), 0);
this._ctx.drawImage(this._loopCanvases[i], (this._canvas.width / 2) - center + (MAX_CANVAS_WIDTH * i), 0);
}
// Draw centered vertical bar
this._ctx.fillStyle = ColorUtils.defaultAntiPrimaryColor;
this._ctx.fillRect(this._canvas.width / 2, 1, 3, this._canvas.height - 2);
this._ctx.strokeStyle = 'black';
this._ctx.lineWidth = 1;
this._ctx.strokeRect(this._canvas.width / 2, 1, 3, this._canvas.height - 2);
// Draw beat count next to centered line
if (this._beatsArray.length > 0) {
let label = '0.0';
for (let i = 0; i < this._beatsArray.length; ++i) {
if (time <= this._beatsArray[i].time) {
let measureCount = Math.floor((this._beatsArray[i].beatCount - 1) / this._beat.timeSignature) + 1;
let timeCount = (this._beatsArray[i].beatCount - 1) % this._beat.timeSignature;
label = `${measureCount}.${timeCount === -1 ? 1 : timeCount + 1}`;
break;
}
}
let top = 14;
if (this._wave.align === 'top') {
top = this._canvas.height - 4;
}
CanvasUtils.drawBeatCount(this._canvas, {
label: label,
x: (this._canvas.width / 2) + 8,
y: top
});
}
}
_drawHotCues() {
for (let i = 0; i < this._hotCues.length; ++i) {
this._drawHotCue(this._hotCues[i]);
}
}
_drawHotCue(hotCue) {
let top = 2;
if (this._wave.align === 'top') {
top = this._canvas.height - 20;
}
CanvasUtils.drawHotCue(this._cueCanvases[hotCue.canvasIndex], {
x: hotCue.xPos - (hotCue.canvasIndex * MAX_CANVAS_WIDTH) + (18 / 2),
y: top,
size: 18,
label: hotCue.label || hotCue.number,
color: hotCue.color
});
}
_hotCueClicked(x, y) {
if (y > 2 && y < 20) {
for (let i = 0; i < this._hotCues.length; ++i) {
let xPos = this._hotCues[i].xPos - (this._hotCues[i].canvasIndex * MAX_CANVAS_WIDTH);
if (x > xPos && x < (xPos + 18)) {
return this._hotCues[i];
}
}
}
return false;
}
_drawLoop() {
if (this._loopEntry) {
const ctx = this._loopCanvases[this._loopEntry.canvasIndex].getContext('2d');
ctx.fillStyle = this._colors.loop;
CanvasUtils.drawTriangle(this._loopCanvases[this._loopEntry.canvasIndex], {
x: this._loopEntry.xPos % MAX_CANVAS_WIDTH + 1,
y: 1,
radius: 9,
top: 14
});
CanvasUtils.drawTriangle(this._loopCanvases[this._loopEntry.canvasIndex], {
x: this._loopEntry.xPos % MAX_CANVAS_WIDTH + 1,
y: this._loopCanvases[this._loopEntry.canvasIndex].height - 1,
radius: 9,
top: this._loopCanvases[this._loopEntry.canvasIndex].height - 14
});
}
if (this._loopEnd) {
const ctx = this._loopCanvases[this._loopEntry.canvasIndex].getContext('2d');
ctx.fillStyle = this._colors.loop;
CanvasUtils.drawTriangle(this._loopCanvases[this._loopEnd.canvasIndex], {
x: this._loopEnd.xPos % MAX_CANVAS_WIDTH + 1,
y: 1,
radius: 9,
top: 14
});
CanvasUtils.drawTriangle(this._loopCanvases[this._loopEnd.canvasIndex], {
x: this._loopEnd.xPos % MAX_CANVAS_WIDTH + 1,
y: this._loopCanvases[this._loopEnd.canvasIndex].height - 1,
radius: 9,
top: this._loopCanvases[this._loopEnd.canvasIndex].height - 14
});
}
if (this._loopEntry && this._loopEnd) {
const ctx = this._loopCanvases[this._loopEntry.canvasIndex].getContext('2d');
ctx.fillStyle = this._colors.loopAlpha;
if (this._loopEntry.canvasIndex === this._loopEnd.canvasIndex) {
ctx.fillRect(this._loopEntry.xPos, 30, this._loopEnd.xPos - this._loopEntry.xPos, this._loopCanvases[this._loopEntry.canvasIndex].height - 60);
}
}
}
/** @method
* @name _getPlayerSourceFile
* @private
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Fetch audio file using xmlHTTP request.</blockquote> **/
_getPlayerSourceFile() {
const request = new XMLHttpRequest();
request.open('GET', this._player.src, true);
request.responseType = 'arraybuffer';
request.onload = () => { this._processAudioFile(request.response); };
request.send();
}
/* --------------------------------------------------------------------------------------------------------------- */
/* ---------------------------------------- TIMELINE PUBLIC METHODS ------------------------------------------- */
/* */
/* These methods allow the caller to update the beat info (on change track for example), or to add/remove a hot */
/* cue in the timeline, or to configure loop entry and exit */
/* --------------------------------------------------------------------------------------------------------------- */
/** @method
* @name updateBeatInfo
* @public
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Update the beat values. To be calle don change track</blockquote>
* @param {object} options - Track beat options
* @param {number} [options.beat.offset=null] - offset before first beat
* @param {number} [options.beat.bpm=null] - The track bpm
* @param {number} [options.beat.timeSignature=null] - The track time signature to emphasis main beats **/
updateBeatInfo(options) {
this._beat = {
offset: options.offset,
bpm: options.bpm,
timeSignature: options.timeSignature
};
}
/** @method
* @name setHotCuePoint
* @public
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Define a HotCue point. It will be attached to the nearest bar. It will only be
* attached if no hotcue is registered on the targeted bar.</blockquote>
* @return {object} The hotcue object with its information **/
setHotCuePoint(options) {
let matchingBeat = this.getClosestBeat();
// Search for existing hotcue at the target bar
let existingHotCue = null;
for (let i = 0; i < this._hotCues.length; ++i) {
if (this._hotCues[i].beatCount === matchingBeat.beatCount) {
existingHotCue = this._hotCues[i];
break;
}
}
// Only append hotcue if it's not already registered, return existing hot cue otherwise
if (!existingHotCue) {
// Save hot cue and return to the sender
matchingBeat.number = this._hotCues.length + 1; // Attach hotcue number
matchingBeat.time = matchingBeat.xPos * this._canvasSpeed / this._canvas.width; // Save the bar timecode into the hotcue object
matchingBeat.label = this._hotCues.length + 1; // Default label
if (options.label) {
matchingBeat.label = options.label;
}
if (options.color) {
matchingBeat.color = options.color;
}
// Otherwise save hot cue in stack
this._hotCues.push(matchingBeat);
// Draw hotcues if any
this._clearCanvas();
this._drawHotCue(matchingBeat);
this._drawTimeline(this._player.currentTime);
return matchingBeat;
} else {
return existingHotCue;
}
}
updateHotCuePoint(hotCue, options) {
for (let i = 0; i < this._hotCues.length; ++i) {
if (this._hotCues[i].beatCount === hotCue.beatCount) {
if (options.label) {
this._hotCues[i].label = options.label;
}
if (options.color) {
this._hotCues[i].color = options.color;
}
}
}
this._clearCanvas(false, true);
this._drawHotCues();
this._drawTimeline(this._player.currentTime);
}
removeHotCuePoint(hotcue) {
for (let i = 0; i < this._hotCues.length; ++i) {
if (this._hotCues[i].beatCount === hotcue.beatCount) {
this._hotCues.splice(i, 1);
this._clearCanvas(false, true);
this._drawHotCues();
this._drawTimeline(this._player.currentTime);
break;
}
}
}
setLoopEntryPoint() {
this._loopEntry = this.getClosestBeat();
this._clearCanvas(false, false, true);
this._drawLoop();
this._drawTimeline(this._player.currentTime);
}
setLoopEndPoint(beatDuration) {
if (this._loopEntry) {
// Determine end by closest beat
if (!beatDuration) {
let matchingBeat = this.getClosestBeat();
// Only save end if not equal to entry and is located after in time
if (matchingBeat !== this._loopEntry && this._loopEntry.time < matchingBeat.time) {
this._loopEnd = matchingBeat;
}
} else { // Determine end by a beat count after loop entry
if (this._loopEntry.beatCount + beatDuration < this._beatsArray.length) {
this._loopEnd = this._beatsArray[this._loopEntry.beatCount + beatDuration];
} else {
this._loopEnd = this._beatsArray[this._beatsArray.length - 1];
}
}
this._clearCanvas(false, false, true);
this._drawLoop();
this._drawTimeline(this._player.currentTime);
//this._startLoopSequence(!beatDuration);
}
}
exitLoop() {
//this._loopBuffer.stop();
//this._player.play();
this._loopEntry = null;
this._loopEnd = null;
this._loopBuffer = null;
this._isLooping = false;
this._loopStartedAt = 0;
this._playerPausedAt = 0;
this._clearCanvas(false, false, true);
this._drawTimeline(this._player.currentTime);
}
/** @method
* @name getClosestBeat
* @public
* @memberof Timeline
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote></blockquote> **/
getClosestBeat(timeOnly = false) {
// The center coordinate when this method is called
const center = Math.floor(this._player.currentTime * this._canvas.width / this._canvasSpeed);
let matchingBeat = {};
// Find nearest beat to process
for (let i = 0; i < this._beatsArray.length; ++i) {
// We now have the upper beat, compare with previous one to find nearest
if (this._beatsArray[i].xPos > center) {
// Take previous bar if click was closer to it
if (i - 1 > 0 && (this._beatsArray[i].xPos - center) > (center - this._beatsArray[i - 1].xPos)) {
matchingBeat = this._beatsArray[i - 1];
break;
} else { // Take curent bar otherwise
matchingBeat = this._beatsArray[i];
break;
}
}
}
// Only return time if requested
if (timeOnly) {
return matchingBeat.time;
}
return matchingBeat;
}
}
export default Timeline;