adamrenklint/bap

View on GitHub
lib/Clock.js

Summary

Maintainability
A
2 hrs
Test Coverage
A
99%
var PositionModel = require('./PositionModel');
var Dilla = require('dilla');
var instanceOfType = require('./types/instanceOfType');
var debounce = require('lodash.debounce');
var memoize = require('meemo');

var inited = false;
function init (context) {
  inited = true;
  var source = context.createBufferSource();
  source.buffer = context.createBuffer(1, 22050, 44100);
  source.start(0);
}

var Clock = PositionModel.extend({

  type: 'clock',

  props: {
    playing: ['boolean', true, false],
    sequence: 'sequenceInstance',
    tempo: ['number', true, 0],
    step: 'function',
    looped: ['number', true, 0],
    _lastQueuedPosition: ['string', true, '0.0.00'],
    _stopByFold: 'boolean'
  },

  dataTypes: {
    sequenceInstance: instanceOfType('sequenceInstance', ['sequence', 'pattern'])
  },

  initialize: function () {
    PositionModel.prototype.initialize.call(this);

    this.on('change:playing', this._onChangePlaying);
    this.on('change:sequence', this._onChangeSequence);
    this.on('change:position', this._onChangePosition);
    this.on('change:tempo', this._onChangeTempo);

    this.on('change:bar', this._scheduleSteps);
    this.on('change:beat', this._scheduleSteps);

    this.engine = new Dilla(this.context);
    // Dilla uses node/events on/addListener,
    // which doesn't take context, so need to bind this
    this.listenTo(this.engine, 'tick', this._onEngineTick.bind(this));
    this.listenTo(this.engine, 'step', this._onEngineStep.bind(this));
    // Dilla also does not have an off method, so need to bind alias
    this.engine.off = this.engine.removeAllListeners.bind(this.engine);

    this.listenTo(this.vent, 'clock:start', this.start);
    this.listenTo(this.vent, 'clock:pause', this.pause);
    this.listenTo(this.vent, 'clock:stop', this.stop);
    // TODO: remove this hack: a dirty way of using a global event bus :/
    this.listenTo(this.vent, 'clock:tempo', this._applySequenceTempo);
  },

  _canStartPlaying: function () {
    return global.document.readyState !== 'loading' && !this.vent.loading;
  },

  start: function (sequence) {

    if (!inited) {
      init(this.context);
    }

    if (!this._canStartPlaying()) {
      return setTimeout(function () {
        this.start(sequence);
      }.bind(this), 10);
    }

    if (sequence) {
      this.sequence = sequence;
    }

    if (!this.sequence) {
      return console.warn('Could not start bap.clock, no sequence has been defined');
    }

    if (this._stopByFold) {
      this.position = '1.1.01';
      this._stopByFold = false;
    }

    setTimeout(this.engine.start.bind(this.engine), 1);
    this.playing = true;

    // restarting a previously schedule sequence
    if (!sequence && this.sequence && this.position !== '0.0.00') {
      // hack to avoid dropped notes, i.e. notes that were already scheduled
      // and played eagerly before a pause, and now needs to be scheduled again
      this._lastQueuedPosition = this.position;
      this.engine.setPosition(this.position);
      this._updateTempo(this.position);
    }
  },

  pause: function (sequence) {
    if (!sequence || sequence === this.sequence) {
      this.engine.pause();
      this.playing = false;
      // this.tempo = 0;
      this._lastQueuedPosition = '0.0.00';
    }
  },

  stop: function (sequence) {
    if (!sequence || sequence === this.sequence) {
      this.engine.stop();
      this.playing = false;
      this.position = '1.1.01';
      // this.tempo = 0;
      this.looped = 0;
      this._lastQueuedPosition = '0.0.00';
    }
  },

  _scheduleSteps: function () {
    this.engine.set('lookahead', this._lookaheadSteps());
  },

  _lookaheadSteps: function () {
    var bar = this.bar || 1;
    var beat = this.beat || 1;
    var beatsPerBar = this.sequence && this.sequence.beatsPerBar || 4;
    var bars = this.sequence && this.sequence.bars || 1;
    var isLooping = this.sequence && this.sequence.loop;
    var handler = isLooping && bars <= 17 ? Clock.possibleSteps : Clock.uncachedPossibleSteps;
    return handler(bar, beat, bars, beatsPerBar);
  },

  _onChangePlaying: function () {
    var method = this.playing ? 'start' : 'pause';
    this[method]();
    if (this.sequence) {
      this.sequence.playing = this.playing;
    }
  },

  _onChangeSequence: function () {
    var old = this._previousAttributes.sequence;
    if (old) {
      this.stopListening(old);
    }

    this.listenTo(this.sequence, 'change:bars', this._onChangeSequence);
    this.listenTo(this.sequence, 'change:beatsPerBar', this._onChangeSequence);

    this.engine.setBeatsPerBar(this.sequence.beatsPerBar);
    this.engine.setLoopLength(this.sequence.bars);
    this.tempo = this.sequence.tempo;
    this.looped = 0;
    this._scheduleSteps();
  },

  _onChangePosition: function () {
    if (this.position !== '0.0.00' && this.position !== this.engine._position) {
      this.engine.setPosition(this.position);
    }
  },

  _onChangeTempo: function () {
    this.engine.setTempo(this.tempo);
  },

  _onEngineTick: function (tick) {
    if (!this.playing || tick.position === '0.0.00') {
      return;
    }
    else if (this._stopByFold) {
      this.pause();
      this.tick = 96;
    }
    else {
      this.position = tick.position;
      this.tempo = this.engine.tempo();
    }
  },

  _onEngineStep: function (step) {
    if (!this.playing || !this.sequence) {
      return;
    }
    else if (step.id === 'lookahead' && !this._foldingOverLoop(step.position)) {
      this._updateTempo(step.position);
      this._queueNotesForStep(step.position, step.time);
      this._lastQueuedPosition = step.position;
    }
  },

  _updateTempo: function (position) {
    if (!this.sequence) {
      this.tempo = 120;
    }
    else if (position && this.sequence.tempoAt) {
      var fragments = Clock.fragments(position);
      var ticks = Clock.ticksForTempoChange(this.tempo);
      var shouldLookAhead = fragments[2] >= (96 - ticks) && fragments[1] === this.sequence.beatsPerBar;
      var checkBar = shouldLookAhead ? fragments[0] + 1 : fragments[0];
      if (checkBar > this.sequence.bars && this.sequence.loop) {
        checkBar = 1;
      }
      this.tempo = this.sequence.tempoAt(checkBar);
    }
    else {
      this.tempo = this.sequence.tempo;
    }
  },

  _foldingOverLoop: function (position) {
    var previous = Clock.paddedPosition(this._lastQueuedPosition);
    var next = Clock.paddedPosition(position);
    if (next < previous) {
      this.looped++;
    }
    this._stopByFold = !this.sequence.loop && next < previous;
    return this._stopByFold;
  },

  _queueNotesForStep: function (position, time) {
    if (!this.sequence) { return; }

    var fragments = Clock.fragments(position);
    var notes = this.sequence.notes(fragments[0], fragments[1], fragments[2]);
    notes = Array.isArray(notes) ? notes : notes[fragments[0]];

    var step = typeof this.step === 'function' ? this.step : null;

    notes.forEach(function (note) {
      if (!step || step && step(note, time) !== false) {
        note.start(time);
      }
    });
  },

  _applySequenceTempo: function (target) {
    // this is very strange, still
    // and clock.tempo is potentially out of date
    // NEED TO MAKE THIS FLOW CONCRETE, and TEST IT
    target.tempo = this.engine.tempo();
  }
});

Clock.uncachedPossibleSteps = function (bar, beat, bars, beats) {

  var steps = new Array(192);
  var index = 0;

  function push () {
    var tick = 0;
    while (++tick < 97) {
      steps[index] = [[bar, beat, tick < 10 ? '0' + tick : tick].join('.')];
      index++;
    }
  }
  push();

  if (++beat > beats) {
    beat = 1;
    if (++bar > bars) {
      bar = 1;
    }
  }
  push();

  return steps;
};

Clock.possibleSteps = memoize(Clock.uncachedPossibleSteps, function () {
  return [].slice.call(arguments).join('//');
});

Clock.ticksForTempoChange = memoize(function (tempo) {
  var rate = tempo / 100;
  var multiplier = rate > 2 ? rate - 1 : 1;
  var ticks = Math.ceil((rate * multiplier) * 4);
  return ticks < 2 ? 2 : ticks;
});

module.exports = Clock;