click-track
Version:
JavaScript utility for click track events.
349 lines (341 loc) • 12.9 kB
JavaScript
'use strict';
var steEvents = require('ste-events');
function isTimer(obj) {
return typeof obj === "object" &&
typeof obj.onUpdate === "function" &&
typeof obj.offUpdate === "function" &&
typeof obj.deconstruct === "function";
}
var BasicTimer = /** @class */ (function () {
function BasicTimer(autostart, length, loop) {
if (autostart === void 0) { autostart = false; }
if (length === void 0) { length = Infinity; }
if (loop === void 0) { loop = true; }
this.autostart = autostart;
this.length = length;
this.loop = loop;
this.startTimeMarker = Date.now();
this.position = 0;
this.animationFrameId = undefined;
this.onTick = new steEvents.EventDispatcher();
if (autostart) {
this.start();
}
}
BasicTimer.prototype.start = function () {
this.startTimeMarker = Date.now();
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
this.animationFrameId = requestAnimationFrame(this.updateTime.bind(this));
};
BasicTimer.prototype.stop = function () {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
};
BasicTimer.prototype.updateTime = function () {
this.animationFrameId = undefined;
var now = Date.now();
var position = (now - this.startTimeMarker) / 1000;
if (position !== this.position) {
this.position = position;
if (this.position > this.length) {
if (!this.loop) {
this.position = this.length;
}
else {
this.position = this.position % this.length;
}
}
try {
this.onTick.dispatch(this, position);
}
finally {
// Regardless of success, keep timer going
this.animationFrameId = requestAnimationFrame(this.updateTime.bind(this));
}
}
};
BasicTimer.prototype.onUpdate = function (cb) {
this.onTick.subscribe(cb);
};
BasicTimer.prototype.offUpdate = function (cb) {
this.onTick.unsubscribe(cb);
};
BasicTimer.prototype.deconstruct = function () {
this.onTick.clear();
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
};
return BasicTimer;
}());
// Processes cues and produces a tuple of two arrays: one that contains just the time indexes, and another that contains the original data, but with holes.
function separateCueSequence(cues) {
var leanMap = [];
var dataMap = [];
cues.forEach(function (cue, i) {
if (typeof cue === "number") {
leanMap[i] = cue;
return;
}
leanMap[i] = cue[0];
dataMap[i] = cue[1];
});
return [leanMap, dataMap];
}
var UniversalTimer = /** @class */ (function () {
function UniversalTimer(getTimeFn) {
this.getTimeFn = getTimeFn;
this.position = 0;
this.onTick = new steEvents.EventDispatcher();
this.animationFrameId = undefined;
this.animationFrameId = requestAnimationFrame(this.updateTime.bind(this));
}
UniversalTimer.prototype.updateTime = function () {
this.animationFrameId = undefined;
var position = this.getTimeFn();
try {
if (position !== this.position) {
this.position = position;
this.onTick.dispatch(this, position);
}
}
finally {
// Regardless of success, keep timer going
this.animationFrameId = requestAnimationFrame(this.updateTime.bind(this));
}
};
UniversalTimer.prototype.onUpdate = function (cb) {
this.onTick.subscribe(cb);
};
UniversalTimer.prototype.offUpdate = function (cb) {
this.onTick.unsubscribe(cb);
};
UniversalTimer.prototype.deconstruct = function () {
this.onTick.clear();
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
};
return UniversalTimer;
}());
// C represents the type of data to be used for cue events
var ClickTrack = /** @class */ (function () {
function ClickTrack(options) {
this.tempo = 60;
this.tempoBPS = 1;
this.offset = 0;
this.length = Infinity;
this.hasOwnTimer = false;
this.cues = [];
this.cueData = [];
this.currentBeat = -1;
this.previousBeat = -1;
this.currentCue = -1;
this.previousCue = -1;
this.events = new steEvents.NonUniformEventList();
this.dragSamples = new Array();
this.fixDrag = false;
this.dragOffset = 0;
if (options.tempo !== undefined) {
// Validate tempo
if (options.tempo === 0 || options.tempo < 0) {
throw new Error("Invalid tempo (" + options.tempo + "), must be greater than 0.");
}
this.tempo = options.tempo;
this.tempoBPS = this.tempo / 60;
}
if (options.offset !== undefined) {
this.offset = options.offset;
}
if (options.fixDrag) {
this.fixDrag = true;
this.dragSamples = new Array(10).fill(0);
}
// Setup cues and cue data
if (options.cues !== undefined) {
var _a = separateCueSequence(options.cues), cueLean = _a[0], cueData = _a[1];
this.cues = cueLean;
this.cueData = cueData;
}
// Setup timer
if (isTimer(options.timerSource)) {
// Custom timer
this.timer = options.timerSource;
this.length = Infinity;
}
else if (options.timerSource === undefined) {
// Basic timer
this.timer = new BasicTimer(options.autostart, options.length, options.loop);
this.hasOwnTimer = true;
if (options.length !== undefined) {
this.length = options.length;
}
}
else if (typeof options.timerSource === "function") {
// universal timer using function
this.timer = new UniversalTimer(options.timerSource);
this.hasOwnTimer = true;
}
else {
throw new Error('Constructing ClickTrack: Unknown value type for timerSource option.');
}
// Bind the tick event
this.timer.onUpdate(this.setTime.bind(this));
}
// Adds event listener
ClickTrack.prototype.on = function (event, fn) {
this.events.get(event).subscribe(fn);
};
// Adds event listener to only handle once
ClickTrack.prototype.once = function (event, fn) {
var _this = this;
this.events.get(event).subscribe(function () {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
_this.events.get(event).unsubscribe(fn);
fn.apply(_this, args);
});
};
// Removes event listener
ClickTrack.prototype.off = function (event, fn) {
this.events.get(event).unsubscribe(fn);
};
ClickTrack.prototype.start = function () {
if (this.timer instanceof BasicTimer) {
this.timer.start();
}
else {
throw new Error('Click must be master to support start()');
}
};
ClickTrack.prototype.deconstruct = function () {
this.timer.offUpdate(this.setTime.bind(this));
if (this.hasOwnTimer) {
this.timer.deconstruct();
}
delete this.cues;
delete this.cueData;
};
// Sets the time in seconds
ClickTrack.prototype.setTime = function (timer, time) {
// Adjust for offset
var offsetTime = time - this.offset;
// Set previous click pointer to current click before we change current click
this.previousBeat = this.currentBeat;
// Calculate current beat
this.currentBeat = offsetTime * this.tempoBPS;
// Process beat events only if listeners are listening
if (this.events.get("beat").count) {
this.tickClickEvents(this.previousBeat, this.currentBeat);
}
// Process cue events only if cues exist and listeners are listening
if (this.cues && (this.events.get("cue").count || this.events.get("firstCue").count || this.events.get("lastCue").count)) {
this.tickCueEvents(this.previousBeat, this.currentBeat);
}
};
Object.defineProperty(ClickTrack.prototype, "beat", {
get: function () {
return this.currentBeat;
},
enumerable: true,
configurable: true
});
ClickTrack.prototype.tickCueEvents = function (fromBeat, toBeat) {
// Start scanning for current cue from previous cue, only if track moved forward. Otherwise scan from the beginning.
var calcCueFrom = toBeat > fromBeat ? this.previousCue : 0;
// Where the calculated cue index will be stored
var calcCue;
// This for loop will set calcCue to the index of the next cue
for (
// Start iteration at current cue marker, but if less than 0 (-1) then start counter at 0
calcCue = Math.max(0, calcCueFrom);
// Iterate until cue marker is greater than current time, or until no more cues
this.cues[calcCue] < toBeat + this.dragOffset && calcCue < this.cues.length;
// Increment by one
calcCue++)
;
// Subtract 1 to get the current cue index
calcCue -= 1;
// Set previous cue pointer before changing current cue
this.previousCue = this.currentCue;
// Set current cue
this.currentCue = calcCue;
// At the beginning
if (this.currentCue === -1) {
return;
}
// Nothing changed
if (this.currentCue === this.previousCue) {
return;
}
// Moved backwards
if (this.currentCue < this.previousCue) {
return;
}
// Traverse each cue from last 'til current
for (var i = this.previousCue + 1; i <= this.currentCue; i++) {
var cueData = this.cueData[i] || null;
var time = this.cues[i] / this.tempoBPS;
var drag = toBeat - this.cues[i];
if (this.fixDrag) {
this.dragSamples.unshift(drag);
this.dragSamples.pop();
}
var event_1 = {
time: time,
beat: this.cues[i],
data: cueData,
cue: i,
drag: drag,
};
this.events.get("cue").dispatchAsync(this, event_1);
if (i === 0) {
this.events.get("firstCue").dispatchAsync(this, event_1);
}
if (i === this.cues.length - 1) {
this.events.get("lastCue").dispatchAsync(this, event_1);
}
}
if (this.fixDrag) {
// Get average offset, but keep it between -1 and 1
this.dragOffset = Math.min(1, Math.max(-1, this.dragSamples.reduce(function (a, c) { return a + c; }, 0) / this.dragSamples.length / 2));
}
};
ClickTrack.prototype.tickClickEvents = function (fromBeat, toBeat) {
// We are assuming a backwards scrub happened, so we won't produce any events
// In some cases, this might mean we looped back to the beginning.
if (fromBeat > toBeat) {
return;
}
// Beats are the same
if (fromBeat === toBeat) {
return;
}
var fromBeatInt = Math.max(0, Math.ceil(fromBeat));
var toBeatInt = Math.floor(toBeat);
// Beat hasn't advanced a whole number yet
if (fromBeatInt > toBeatInt) {
return;
}
var clickEvents = [];
for (var i = fromBeatInt; i <= toBeatInt; i++) {
var time = i / this.tempoBPS;
clickEvents.push({
time: time,
beat: i,
drag: toBeat - i,
});
}
// Loop through clicksBetween and dispatch
for (var i = 0; i < clickEvents.length; i++) {
this.events.get("beat").dispatchAsync(this, clickEvents[i]);
}
};
return ClickTrack;
}());
module.exports = ClickTrack;