1

I want to create a virtual metronome and play a metronome sound with each step. Usual methods such as setTimeout()/setInterval() are not accurate enough. How can I make it work without delay or dropping beats?

Ali Ahmadi
  • 113
  • 1
  • 13
  • 1
    The answer is to use the `AudioBufferSourceNode` and call `.start()` with the `when` parameter to indicate exactly at which timestamp the metronome sound should start. https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode/start That will guarantee audio accuracy. If you also wanted to trigger some other code when this happens, that isn't possible. You would have to use other methods, like intervals, to do other things once the time has passed. You can use `audioContext.currentTime` to determine the current timestamp. – Brad Jun 24 '20 at 16:24
  • So there is no way to trigger some code after `AudioBufferSourceNode.start();`? – Ali Ahmadi Jun 24 '20 at 17:04
  • 1
    Not based on time, no. And even if you could, it wouldn't be helpful to you because its timing could be off. So, if you need to schedule audio, you need to do it early by calling `.start()` with a timestamp. If you want to display things on the screen, you can use `requestAnimationFrame` but note that this will be throttled or outright paused if the tab loses focus. – Brad Jun 24 '20 at 17:08
  • So Mr. Brad what do you suggest for triggering some codes in an exact time (the best way at all)? – Ali Ahmadi Jun 24 '20 at 18:04
  • You can't. You need to find another way. Post a question with specifically what you're trying to do. – Brad Jun 24 '20 at 18:56
  • I updated the answer. Hope this leads you in the right direction. – Moritz Roessler Jun 24 '20 at 21:37
  • Note that a reliable BPM counter usually doesn't tick at "that many beats per minute", since you'd only be able to trigger quarter/quaver notes. Instead you want want to look at the actual current time by running a timer at the highest possible rate, e.g. by using `requestAnimationFrame` and running a check to see whether to trigger a next division unit or not based on a knowledge of what _clock time_ your start happened on and the current time. – Mike 'Pomax' Kamermans Mar 10 '22 at 15:59
  • Related: [HTML5/jQuery metronome - performance problems](https://stackoverflow.com/questions/10211567/html5-jquery-metronome-performance-problems/68249713#68249713) and [making a Javascript metronome](https://stackoverflow.com/questions/75632384/making-a-javascript-metronome) – ggorlen Mar 06 '23 at 04:39

1 Answers1

1

Here's a function that beeps every 500ms.

function beep() {
    var snd = new Audio("data:audio/wav;base64,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qJrx4KOQjahZxWbcZgztj2c49nKmkId44S71j0c8eV9yDK6uPRzx5X18eDvjvQ6yKo9ZSS6l//8elePK/Lf//IInrOF/FvDoADYAGBMGb7FtErm5MXMlmPAJQVgWta7Zx2go+8xJ0UiCb8LHHdftWyLJE0QIAIsI+UbXu67dZMjmgDGCGl1H+vpF4NSDckSIkk7Vd+sxEhBQMRU8j/12UIRhzSaUdQ+rQU5kGeFxm+hb1oh6pWWmv3uvmReDl0UnvtapVaIzo1jZbf/pD6ElLqSX+rUmOQNpJFa/r+sa4e/pBlAABoAAAAA3CUgShLdGIxsY7AUABPRrgCABdDuQ5GC7DqPQCgbbJUAoRSUj+NIEig0YfyWUho1VBBBA//uQZB4ABZx5zfMakeAAAAmwAAAAF5F3P0w9GtAAACfAAAAAwLhMDmAYWMgVEG1U0FIGCBgXBXAtfMH10000EEEEEECUBYln03TTTdNBDZopopYvrTTdNa325mImNg3TTPV9q3pmY0xoO6bv3r00y+IDGid/9aaaZTGMuj9mpu9Mpio1dXrr5HERTZSmqU36A3CumzN/9Robv/Xx4v9ijkSRSNLQhAWumap82WRSBUqXStV/YcS+XVLnSS+WLDroqArFkMEsAS+eWmrUzrO0oEmE40RlMZ5+ODIkAyKAGUwZ3mVKmcamcJnMW26MRPgUw6j+LkhyHGVGYjSUUKNpuJUQoOIAyDvEyG8S5yfK6dhZc0Tx1KI/gviKL6qvvFs1+bWtaz58uUNnryq6kt5RzOCkPWlVqVX2a/EEBUdU1KrXLf40GoiiFXK///qpoiDXrOgqDR38JB0bw7SoL+ZB9o1RCkQjQ2CBYZKd/+VJxZRRZlqSkKiws0WFxUyCwsKiMy7hUVFhIaCrNQsKkTIsLivwKKigsj8XYlwt/WKi2N4d//uQRCSAAjURNIHpMZBGYiaQPSYyAAABLAAAAAAAACWAAAAApUF/Mg+0aohSIRobBAsMlO//Kk4soosy1JSFRYWaLC4qZBYWFRGZdwqKiwkNBVmoWFSJkWFxX4FFRQWR+LsS4W/rFRb/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////VEFHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU291bmRib3kuZGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMjAwNGh0dHA6Ly93d3cuc291bmRib3kuZGUAAAAAAAAAACU=");  
    snd.play();
}

setInterval(beep, 500)

That's however not very precise. The callback may be fired late (depending on a lot, this can range from a few ms to over 1000ms). To get around this you can use AudioContext. We'll use a simple Oscillator for the Metronomes sound.

You could either schedule multiple notes at once or use setInterval to run a function that schedules the next note that should be played.

function noteDurationToMs (bpm, dur, type) {
    return 60000 * 4 * dur * type / bpm
}

function scheduleNote(ac, time, dur) {
    var osc = ac.createOscillator();
    osc.connect( ac.destination );
    osc.start(time);
    osc.stop(time + dur);
}

const ac = new AudioContext();
let lastNote= ac.currentTime;
const step = noteDurationToMs(120, 1 / 4, 1) / 1000;
const lookAhead = step / 2;

let id, timer = ()=> {
  const diff = ac.currentTime - lastNote;
  if (diff >= lookAhead) {
   const nextNote = lastNote + step;
   scheduleNote(ac, nextNote, 0.025)
   lastNote = nextNote;
  }
}
a.addEventListener('click', () => {
    ac.resume();
    id = setInterval(timer, 15)
})
b.addEventListener('click', () => clearInterval(id))
<button id="a">Start</button>
<button id="b">Stop</button>

Note: for some reason AudioContext doesn't seem to be working in Stack Snippets. Here is a jsfiddle for the first code snippet. Here is one for the second snippet.

function noteDurationToMs (bpm, dur, type) {
    return 60000 * 4 * dur * type / bpm
}

function scheduleNote(ac, time, dur) {
    var osc = ac.createOscillator();
    osc.connect( ac.destination );
    
    
    osc.start(time);
    osc.stop(time + dur);
}

const ac = new AudioContext();
let to, lastNote = 0;

const Metronome = () => {
  const [bpm, setBpm] = React.useState(100);
  const [dur, setDur] = React.useState(1);  
  const [type, setType] = React.useState(1);
    const [run, setRun] = React.useState(false);    
  
  const handleChangeBPM = (e) => {
    setBpm(e.target.value)
  }
  
  const handleChangeDur = (e) => {
    setDur(e.target.value)
  }
  
  const handleChangeType = (e) => {
    setType(e.target.value)
  }
  
  const step = noteDurationToMs(bpm, dur, type) / 1000;
    const lookAhead = step / 2;
  
  const timer = () => {
    const diff = ac.currentTime - lastNote;
    if (diff >= lookAhead) {
     const nextNote = lastNote + step;
     scheduleNote(ac, nextNote, 0.025)
     lastNote = nextNote;
    }
  }
  
  const start = () => {
    ac.resume()
    setRun(true);
  }
  
  const stop = () => {
    clearInterval(to);
    setRun(false);
  }
  
  const toggle = () => {
    run?stop():start();
  }

    React.useEffect(() => {
    if (run) {
        clearInterval(to);
      to = setInterval(timer, step / 4);
    }
  });
  
    return <div>
    <label for="bpm">BPM:</label>

    
      <input id="bpm" type="number" onChange={handleChangeBPM} value={bpm} />
    
    <label for="dur">Duration:</label>
    
    <select id="dur" onChange={handleChangeDur} value={dur}>
      <option value={1}>Whole</option>
      <option value={1 / 2}>Half</option>
      <option value={1 / 4}>Quarter</option>
      <option value={1 / 8}>Eigth </option>
      <option value={1 / 16}>Sixteenth</option>
      <option value={1 / 32}>Thirtysecond</option>
    </select>
    
    <label for="typ">Type:</label>
    <select id="typ" onChange={handleChangeType} value={type}>
      <option value={1}>Regular</option>
      <option value={3 / 2}>Dotted</option>
      <option value={2 / 3}>Triplet</option>
    </select>
    <button onClick={toggle}>{!run?'Start':'Stop'}</button>
    <br />
    <label for="ms">MS:</label>
    <output id="ms">{noteDurationToMs(bpm, dur, type)}</output>
    </div>
}

ReactDOM.render(<Metronome />, document.querySelector("#app"))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<div id="app"></div>

To include your own edit: You are also able to use AudioBufferSourceNode and call .start(), with the when parameter to play an audio exactly at time but you would not be able to run a function with it.

AudioBufferSourceNode.start([when]);

Moritz Roessler
  • 8,542
  • 26
  • 51