I'm buiding a webpage that will be used by drummers to practise patterns.
In order to do so, I must have:
The problem lies in syncing the score scrolling to the metronome. For the moment, I use purely empirical values because when I've tried to calculate the distances and used settimeout to scroll the score I couldn't get a satisfying result.
Please note:
The script does the job but is quite approximate. In order for the script to be usable, there should be a very good synchronisation between the metronome and the notes reaching the reference bar.
I've tried calculating the distance that should be offset each millisecond but settimeout won't allow for less than 15 to 17 ms and it varying is not accurate enough.
Below is the full code for the project.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DrumChallenge.com</title>
<style>
#div_patterns {
display: inline-block;
margin: 20px;
}
#div_tempo {
display: inline-block;
margin: 20px;
}
#drumscore {
position: relative;
width: 100%;
height: 200px;
}
#drumscore canvas {
position: absolute;
top: 0px;
left: 0px;
}
canvas {
border: 1px solid black;
width: 100%;
height: 200px;
}
</style>
</head>
<body>
<div id="params">
<div id="div_patterns"></div>
<div id="div_tempo">
<span>Tempo :</span>
<input type="text" id="value_tempo" value="60" />
<span>Delai :</span>
<span id="value_delai"></span>
<span>Timer :</span>
<span id="value_timer"></span>
</div>
<button id="launch" class="launch" type="button">Lancer challenge</button>
</div>
<div id="drumscore">
<canvas id="score"></canvas>
<canvas id="scoreCanvas"></canvas>
</div>
<script src="metronome.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const metronome = new Metronome(audioContext);
var Current_Audio = false;
var Challenge_Launched = false;
var Current_Animation = false;
var Timer_Challenge = false;
var Timer_General = false;
var Tempo = 60;
var Delai;
var Distance_Between_Notes = 30;
var General_Timer = 0;
var NextNoteTime = 0;
//
//
//
const LaunchButton = document.getElementById('launch');
LaunchButton.addEventListener('click', function(){
if (Challenge_Launched == false){
CreerChallenge();
Challenge_Launched = true;
const bpm = parseInt(InputTempo.value, 10);
metronome.setTempo(bpm);
metronome.start();
Timer_General = setInterval(SetGeneralTimer, 1000);
NextNoteTime = performance.now();
//drawNotes();
requestAnimationFrame(drawNotes);
} else {
Challenge_Launched = false;
clearTimeout(Timer_Challenge);
clearInterval(Timer_General);
metronome.stop();
//cancelAnimationFrame(Current_Animation);
}
});
//
//
//
function SetTempo(){
Tempo = InputTempo.value;
Delai = (60000 / Tempo).toFixed(2);
document.getElementById('value_tempo').innerHTML = Tempo;
document.getElementById('value_delai').innerHTML = Delai + 'ms';
metronome.setTempo(Tempo);
/*if (Challenge_Launched){
clearTimeout(Timer_Challenge);
//Timer_Challenge = setTimeout(drawNotes, Delai);
}*/
}
//
//
//
const InputTempo = document.getElementById('value_tempo');
InputTempo.addEventListener('blur', function(){
SetTempo()
});
SetTempo()
//
//
//
const drumscore = document.getElementById('drumscore');
//
// Canvas et contexte de la partition
//
const score = document.getElementById('score');
const scorectx = score.getContext('2d');
scorectx.canvas.width = drumscore.offsetWidth;
scorectx.canvas.height = drumscore.offsetHeight;
//
// Canvas et contexte des notes
//
const canvas = document.getElementById('scoreCanvas');
const ctx = canvas.getContext('2d');
ctx.canvas.width = drumscore.offsetWidth;
ctx.canvas.height = drumscore.offsetHeight;
//
// Lignes de la partition
//
const ScoreLines = [60,80,100,120,140];
//
//
//
const Elements = [
{Name: 'Snare', Line: ScoreLines[2]}
];
//
// Patterns
//
const Patterns = [
{
Name: 'Rll',
Element: 'Snare',
Notes: ['R', 'l', 'l'],
Checkbox: false,
Label: false,
Checked: false,
},
{
Name: 'rrL',
Element: 'Snare',
Notes: ['r', 'r', 'L'],
Checkbox: false,
Label: false,
Checked: false,
}
];
//
// Affichage patterns
//
const DivPatterns = document.getElementById('div_patterns');
Patterns.forEach(pattern => {
pattern.Checkbox = document.createElement('input');
pattern.Checkbox.type = "checkbox";
pattern.Label = document.createElement('label')
pattern.Label.htmlFor = pattern.Name;
pattern.Label.appendChild(document.createTextNode(pattern.Name));
DivPatterns.appendChild(pattern.Checkbox);
DivPatterns.appendChild(pattern.Label);
});
//
// Sounds
//
const Sounds = [
{
Element: 'Snare',
Type: 'Normal',
URL: '',
Audio: new Audio('snare_normal.wav')
},
{
Element: 'Snare',
Type: 'Ghost',
Audio: new Audio('snare_ghost.wav')
}
];
//
// Notes à afficher
//
const measures = 20;
const noteWidth = 10;
const noteHeight = 10;
const scrollSpeed = 3;
//
// Main Droite ou Gauche
//
const isAccented = str => str === str.toUpperCase();
const isRightHand = str => str.toUpperCase() === 'R';
//
// Créer le challenge
//
var notes = [];
var current_pattern;
//
// Dessiner la partition
//
function CreerChallenge() {
notes = [];
for (var i=0 ; i<measures ; i++){
current_pattern = Patterns[Math.floor(Math.random() * 2)];
for (var j=0 ; j<current_pattern.Notes.length ; j++){
notes.push({
x: canvas.width / 2 + 180 + (notes.length * Distance_Between_Notes) + 1,
y: isRightHand(current_pattern.Notes[j]) ? ScoreLines[2] - 5 : ScoreLines[2] + 5,
w: isAccented(current_pattern.Notes[j]) ? 7 : 4,
h: isAccented(current_pattern.Notes[j]) ? 5 : 3,
Audio: isAccented(current_pattern.Notes[j]) ? new Audio('snare_normal.wav') : new Audio('snare_ghost.wav')
})
}
}
console.log(notes);
}
//
// Dessiner la partition
//
function drawScore() {
scorectx.clearRect(0, 0, canvas.width, canvas.height);
scorectx.strokeStyle = "#A0A0A0";
ScoreLines.forEach(Line => {
scorectx.beginPath();
scorectx.moveTo(0, Line);
scorectx.lineTo(canvas.width, Line);
scorectx.stroke();
});
scorectx.beginPath();
scorectx.moveTo(canvas.width / 2, ScoreLines[0]);
scorectx.lineTo(canvas.width / 2, ScoreLines[ScoreLines.length-1]);
scorectx.stroke();
}
//
//
//
function nextNote() {
const secondsPerBeat = 60.0 / Tempo;
NextNoteTime += 1000 / Distance_Between_Notes;
}
//
// Dessiner et animer les notes
//
function drawNotes() {
NextNoteTime = performance.now();
ctx.clearRect(0, 0, canvas.width, canvas.height);
notes.forEach(note => {
//ctx.fillRect(note.x, note.y, note.w, note.w);
ctx.beginPath();
ctx.ellipse(note.x, note.y, note.w, note.h, Math.PI, 0, 2 * Math.PI);
ctx.fill();
if (note.x > canvas.width / 2 - 5 && note.x <= canvas.width / 2){
Current_Audio = note.Audio;
note.Audio.play();
}
//note.x -= scrollSpeed;
note.x -= Tempo / 15;
});
//const endTime = performance.now()
//console.log(`Call to doSomething took ${endTime - startTime} milliseconds ` + endTime)
//Current_Animation = requestAnimationFrame(drawNotes);
if (Challenge_Launched){
//Timer_Challenge = setTimeout(drawNotes, 1);
Timer_Challenge = setTimeout(() => requestAnimationFrame(drawNotes), 1);
}
}
function SetGeneralTimer(){
const startTime = performance.now()
General_Timer++;
document.getElementById('value_timer').innerHTML = General_Timer
const endTime = performance.now()
//console.log(`Started ` + startTime + ' | ' + (endTime - startTime) + ' | ' + General_Timer)
}
drawScore();
});
</script>
</body>
</html>
And the metronome class:
class Metronome {
constructor(context) {
this.context = context;
this.isPlaying = false;
this.current16thNote = 0;
this.tempo = 60;
this.lookahead = 25.0;
this.scheduleAheadTime = 0.1;
this.nextNoteTime = 0.0;
this.timerID = null;
}
nextNote() {
const secondsPerBeat = 60.0 / this.tempo;
this.nextNoteTime += 0.25 * secondsPerBeat;
this.current16thNote++;
if (this.current16thNote === 16) {
this.current16thNote = 0;
}
}
scheduleNote(beatNumber, time) {
const osc = this.context.createOscillator();
const envelope = this.context.createGain();
osc.frequency.value = (beatNumber % 4 === 0) ? 1000 : 800;
envelope.gain.value = 1;
envelope.gain.exponentialRampToValueAtTime(0.001, time + 0.2);
osc.connect(envelope);
envelope.connect(this.context.destination);
osc.start(time);
osc.stop(time + 0.2);
}
scheduler() {
console.log(this.nextNoteTime);
console.log(this.context.currentTime);
console.log(this.scheduleAheadTime);
while (this.nextNoteTime < this.context.currentTime + this.scheduleAheadTime) {
this.scheduleNote(this.current16thNote, this.nextNoteTime);
this.nextNote();
}
this.timerID = setTimeout(this.scheduler.bind(this), this.lookahead);
}
start() {
if (!this.isPlaying) {
this.isPlaying = true;
this.current16thNote = 0;
this.nextNoteTime = this.context.currentTime;
this.scheduler();
}
}
stop() {
this.isPlaying = false;
clearTimeout(this.timerID);
}
setTempo(newTempo) {
this.tempo = newTempo;
}
}
The fact the update time varies shouldn't be an issue if you interpolate the offset value instead of relying on a static step amount. What I'd do is look at the total elapsed time from the beginning at each step rather than rely on incrementing at each update. The reason being is that if the step value is off just a little, it'll compound and throw it further off the longer you go.
With a tempo of 60bpm, then every 1 second the score should scroll 30px to the next note. Correct?
let seconds = elapsedTimeMS / 1000;
let offsetPixels = seconds * 30;
// update score offset here
By tracking the time index when the music starts and subtracting that from the current time index when it updates you can get the elapsed time. And now it doesn't matter how fast or slow it updates, it'll remain accurate. Visually, as long as you update every 60ms or less it should appear smooth I think.