Search code examples
javascriptmiditiming

Preventing a BPM ticker from slowly drifting out of sync with a real metronome


I'm working on a music generator that takes a BPM value as input, after which it will start generating some chords, bass notes, and triggering a drum VSTi using MIDI signals.

In order to keep everything running at the correct number of beats per minutes, I'm using a wall clock timer that starts the clock at 0 when you hit play, and then starts counting 1/128th notes as "ticks" at a regular interval. Every time the function ticks over, I check how many ticks into the future we are by simply computing the number of ticks fit in the time-since-start:

class TrackManager {
  constructor(BPM) {
    this.tracks = ... 
    this.v128 = 60000/(BPM*32);
  }

  ...

  play() {
    this.tickCount = 0;
    this.playing = true;
    this.start = Date.now();
    this.tick();
  }

  tick() {
    if (!this.playing) return;

    // Compute the number of ticks that fit in the
    // amount of time passed since we started
    let diff = Date.now() - this.start;
    let tickCount = this.tickCount = (diff/this.v128)|0;

    // Inform each track that there is a tick update,
    // and then schedule the next tick.
    this.tracks.forEach(t => t.tick(this.tickCount));
    setTimeout(() => this.tick(), 2);
  }

  ...
}

Tracks generate music based on Steps which indicate their intended play length in terms of ticks (using .duration as persistent length indicator, and a .end that is set to a future tick value anytime a step gets played), with the playback code adding a correction to the number of ticks to play a step for, to make sure that if more ticks pass than expected (due to compound rounding errors for instance) the next step is played however-many-ticks-necessary less, to keep things in sync.

class Track {
  ...

  tick(tickCount) {
    if (this.step.end <= tickCount) {
      this.playProgramStep(tickCount);
    }
  }

  playProgramStep(tickCount) {
    // Ticks are guaranteed monotonically increasing,
    // but not guaranteed to be sequential, so if we
    // find a gap of N ticks, we need to correct the
    // play length of the next step by that many ticks:
    let correction = this.stopPreviousStep(tickCount);
    let step = this.setNextStep();
    if (step) {
      step.end = tickCount + step.duration - correction;
      this.playStep(step);
    }
  }

  stopPreviousStep(tickCount) {
    this.step.stop();
    return (tickCount - this.step.end);
  }

  ...
}

This works reasonably well, but there is still some drift left in the resulting track speed, particularly noticable when running a separate metronome (in my case, a drum pattern VSTi, which is told which pattern to play at which BPM, and is then left to do its own thing). While initially things sound just fine, after about a minute there is a slight but noticable desync between the BPM played by the metronome and the BPM that the generator is running at, and I'm not sure where that desync might still be coming from.

I would have expected the subtlest of desync at the tick level (which for 120 BPM is less than 16ms), which is well below noticable, but there seems to be a compound desync left in the code and I'm not sure where it would be. The ticks are generated off of the system clock, so I wouldn't expect a desync to kick in until JS runs into unstable-integer values for Date.now(), which we're not about to run into for another 285 or so millenia

What might still be causing desync?


Solution

  • Turns out that the computation of this.v128 can still lead to values that introduce drift. For example, 120 BPM yields a 15.625ms per tick, which is fairly reliable, but 118 BPM yields 15.889830508474576271186440677966[...]ms per tick, any rounding of which (to any number of significant digits) will end up yielding an increasingly incorrect tickCount calculation.

    The solution here is to keep all values involved in tick calculation integers, by replacing the this.v128 value with a this.tickFactor = BPM * 32; and then changing the tick() function to calculate tickCount as:

    tick() {
      if (!this.playing) return;
    
      // Compute the number of ticks that fit in the
      // amount of time passed since we started
      let diff = Date.now() - this.start;
    
      // first form a large integer, which JS can cope with just fine,
      // and only use division as the final operation.
      let tickCount = this.tickCount = ((diff*this.tickFactor)/60000)|0;
    
      // Inform each track that there is a tick update,
      // and then schedule the next tick.
      this.tracks.forEach(t => t.tick(this.tickCount));
      setTimeout(() => this.tick(), 2);
    }