index.js
var events = require('events');
var inherits = require('util').inherits;
var bopper = require('./vendor/bopper');
var ditty = require('./vendor/ditty');
var expr = require('dilla-expressions');
var memoize = require('meemo');
var checkValid = require('./lib/checkValid');
var positionHelper = require('./lib/positionHelper');
var loadTime = new Date().valueOf();
var memoSpacer = '//';
function Dilla (audioContext, options) {
if (!(this instanceof Dilla)){
return new Dilla(audioContext, options);
}
if (!audioContext || typeof audioContext !== 'object' || typeof audioContext.createScriptProcessor !== 'function') {
throw new Error('Invalid arguments: cannot init without AudioContext');
}
events.EventEmitter.call(this);
options = options || {};
this.context = audioContext;
this.clock = bopper(this.context);
this.scheduler = ditty();
this.expressions = expr;
this.expandNote = options.expandNote;
this._position = '0.0.00';
this._notes = {};
this.upstartWait = options.upstartWait || 250;
this.setTempo(options.tempo || 120);
this.setBeatsPerBar(options.beatsPerBar || 4);
this.setLoopLength(options.loopLength || 2);
this.clock.on('data', this.updatePositionFromClock.bind(this));
this.clock.pipe(this.scheduler).on('data', this.emitStep.bind(this));
this._keepAlive = this._keepAlive.bind(this);
}
inherits(Dilla, events.EventEmitter);
var proto = Dilla.prototype;
proto.updatePositionFromClock = function updatePositionFromClock (step) {
var position = this.getPositionFromTime(step.time);
if (this._position !== position) {
this._position = position;
this.emit('tick', { 'position': this._position, 'context': this.context });
}
};
proto.getPositionFromTime = function getPositionFromTime (time) {
var offset = (this.clock._state.cycleLength * this.clock._state.preCycle) * 1;
var position = this.clock.getPositionAt(time - offset);
return this.getPositionFromClockPosition(position);
};
proto.getPositionFromClockPosition = function getPositionFromClockPosition (position) {
checkValid.number('clockPosition', position);
if (position < 0) {
return '0.0.00';
}
var beatsPerLoop = this._loopLength * this._beatsPerBar;
var loops = Math.floor(position / beatsPerLoop) || 0;
position = position - (loops * beatsPerLoop);
var bars = Math.floor(position / this._beatsPerBar);
position = position - (bars * this._beatsPerBar);
var beats = Math.floor(position);
position = position - beats;
var ticks = Math.floor(position * 96) + 1;
if (ticks < 10) {
ticks = '0' + ticks;
}
return (bars + 1) + '.' + (beats + 1) + '.' + ticks;
};
proto.getClockPositionFromPosition = memoize(function getClockPositionFromPosition (position, beatsPerBar) {
var parts = position.split('.');
var bars = parseInt(parts[0], 10) - 1;
var beats = parseInt(parts[1], 10) - 1;
var ticks = parseInt(parts[2], 10) - 1;
return (bars * beatsPerBar) + beats + (ticks / 96);
}, function (position, beatsPerBar) {
return position + memoSpacer + beatsPerBar;
});
proto.getPositionWithOffset = function getPositionWithOffset (position, offset) {
return this.getPositionWithOffsetAndBeatsPerBar(position, offset, this.beatsPerBar());
};
proto.getPositionWithOffsetAndBeatsPerBar = memoize(function getPositionWithOffsetAndBeatsPerBar (position, offset, beatsPerBar) {
if (!checkValid.positionString(position)) {
throw new Error('Invalid argument: position is not a valid position string');
}
if (typeof offset !== 'number' || isNaN(offset) || offset % 1 !== 0) {
throw new Error('Invalid argument: offset is not a valid number');
}
if (!offset) {
return position;
}
var clockPosition = this.getClockPositionFromPosition(position, beatsPerBar);
var clockOffset = offset / 96;
return this.getPositionFromClockPosition(clockPosition + clockOffset);
}, function (position, offset) {
return position + memoSpacer + offset;
});
proto.getDurationFromTicks = function getDurationFromTicks (ticks) {
checkValid.positiveNumber('ticks', ticks);
return (1 / 96) * ticks;
};
proto.emitStep = function emitStep (step) {
var offset = step.offset = (this.clock._state.cycleLength * this.clock._state.preCycle) * 1;
var note = step.args;
step.time = step.time + offset;
step.clockPosition = step.position;
step.position = step.event === 'start' ? note.position : note.duration ? this.getPositionWithOffsetAndBeatsPerBar(note.position, note.duration, this.beatsPerBar()) : note.position;
if (step.event === 'stop' && step.position === note.position) {
return;
}
step.context = this.context;
this.emit('step', step);
};
proto.notesForSet = memoize(function notesForSet (id, notes, beatsPerBar, loopLength) {
var self = this;
notes.forEach(function (note, index) {
if (!Array.isArray(note) && typeof note === 'object' && !!note.position) {
notes[index] = [note.position, note];
}
});
notes = self.expressions(notes, {
'beatsPerBar': beatsPerBar,
'barsPerLoop': loopLength
});
var filtered = false;
notes.forEach(function (note, index) {
if (positionHelper.isPositionWithinBounds(note[0], loopLength, beatsPerBar)) {
if (self.expandNote) {
note = self.expandNote(note);
}
var normal = positionHelper.normalizeNote(note);
notes[index] = [self.getClockPositionFromPosition(normal.position, beatsPerBar), self.getDurationFromTicks(normal.duration || 0), null, null, normal];
}
else {
notes[index] = null;
filtered = true;
}
});
if (filtered) {
return notes.filter(function (note) { return !!note; });
}
return notes;
}, function (id, notes, beatsPerBar, loopLength) {
return id + memoSpacer + JSON.stringify(notes) + memoSpacer + beatsPerBar + memoSpacer + loopLength;
});
proto.set = function set (id, notes) {
var self = this;
if (typeof id !== 'string') {
throw new Error('Invalid argument: id is not a valid string');
}
if (!notes || !Array.isArray(notes)) {
throw new Error('Invalid argument: notes is not a valid array');
}
this._notes[id] = notes;
this.scheduler.set(id, this.notesForSet(id, notes, this.beatsPerBar(), this.loopLength()), this.beatsPerBar() * this.loopLength());
};
proto.get = function get (id) {
if (typeof id !== 'string') {
throw new Error('Invalid argument: id is not a valid string');
}
return (this.scheduler.get(id) || []).map(function (note) {
return note[4];
});
};
proto.channels = function channels () {
return this.scheduler.getIds();
};
proto.clear = function clear (id) {
var self = this;
if (id) {
if (typeof id !== 'string') {
throw new Error('Invalid argument: id is not a valid string');
}
this.set(id, []);
delete this._notes[id];
}
else {
this.scheduler.getIds().forEach(function (id) {
self.clear(id);
});
}
};
proto._keepAlive = function _keepAlive () {
if (this.clock._state.playing) {
window.__lastDillaPosition = this._position;
setTimeout(window.requestAnimationFrame.bind(null, this._keepAlive), 100);
}
};
proto.start = function start () {
var now = new Date().valueOf();
var waited = now - loadTime;
if (waited < this.upstartWait) {
return setTimeout(start.bind(this), this.upstartWait - waited);
}
if (!this.clock._state.playing) {
this.clock.start();
this._keepAlive();
this.emit('playing');
}
};
proto.pause = function pause () {
if (this.clock._state.playing) {
this.clock.stop();
this.emit('paused');
}
};
proto.stop = function stop () {
if (this.clock._state.playing) {
this.clock.stop();
this.emit('paused');
this.clock.setPosition(0);
this._position = '0.0.00';
}
};
proto.position = function position () {
return this._position;
};
proto.setPosition = function setPosition (position) {
if (!positionHelper.isPositionWithinBounds(position, this.loopLength(), this.beatsPerBar())) {
throw new Error('Invalid argument: position is not valid');
}
this._position = position;
this.clock.setPosition(this.getClockPositionFromPosition(position, this.beatsPerBar()));
};
proto.tempo = function tempo () {
return this.clock.getTempo();
};
proto.setTempo = function setTempo (tempo) {
if (typeof tempo !== 'number' || tempo < 0 || isNaN(tempo)) {
throw new Error('Invalid argument: tempo is not a valid number');
}
this.clock.setTempo(tempo);
};
proto.beatsPerBar = function beatsPerBar () {
return this._beatsPerBar;
};
proto.setBeatsPerBar = function setBeatsPerBar (beats) {
checkValid.positiveNumber('beats', beats);
this._beatsPerBar = beats;
this.updateScheduler();
};
proto.loopLength = function loopLength () {
return this._loopLength;
};
proto.setLoopLength = function setLoopLength (bars) {
checkValid.positiveNumber('bars', bars);
this._loopLength = bars;
this.updateScheduler();
};
proto.updateScheduler = function updateScheduler () {
var self = this;
Object.keys(self._notes).forEach(function (id) {
self.set(id, self._notes[id]);
});
};
module.exports = Dilla;