I made a metronome inspired by the famous Chris Wilson's article using React, Hooks, and the Web Audio API.
The metronome works but there's a delay between the moment I hit 'play' and the sound itself.
This is clearly noticeable if the BPM is very low (e.g. 40 BPM).
At first, I thought I needed to isolate the logic from the UI rendering using a Worker but now I start to think it's something else.
I think in the timer
function I need an else
calling sound
with a 0 value.
But I haven't found a solution yet.
Does anybody have an idea what's wrong and how to fix it?
Thanks!
import { useState } from 'react';
let ac;
let lastNote = 0;
let nextNote = 0;
let engine;
function App() {
const [isPlaying, setIsPlaying] = useState(false);
const [bpm] = useState(40);
const oneBeatInSeconds = 60000 / bpm / 1000;
ac = new AudioContext();
const sound = (ac: AudioContext, time: number, dur: number) => {
// creates the sound, connects it and decides when it starts and stops
const osc = ac.createOscillator();
osc.connect(ac.destination);
osc.start(time);
osc.stop(time + dur);
};
const timer = () => {
// Calculates how long it was in ms from loading the browser to clicking the play button
const diff = ac.currentTime - lastNote;
// Schedules the next note if the diff is larger then the setInterval
if (diff >= oneBeatInSeconds) {
nextNote = lastNote + oneBeatInSeconds;
lastNote = nextNote;
sound(ac, lastNote, 0.025);
}
ac.resume();
};
if (isPlaying) {
// If the metronome is playing resumes the audio context
ac.resume();
clearInterval(engine);
engine = setInterval(timer, oneBeatInSeconds);
} else {
// If the metronome is stopped, resets all the values
ac.suspend();
clearInterval(engine);
lastNote = 0;
nextNote = 0;
}
const toggleButton = () =>
isPlaying === true ? setIsPlaying(false) : setIsPlaying(true);
return (
<div className="App">
<div className="Bpm">
<label className="Bpm_label" htmlFor="Bpm_input">
{bpm} BPM
</label>
<input type="range" min="40" max="200" step="1" value={bpm} />
</div>
<button type="button" className="PlayButton" onClick={toggleButton}>
{!isPlaying ? 'play' : 'stop'}
</button>
</div>
);
}
export default App;
If you want to play the first beep at once you can directly schedule it in near future without using setInterval
. Additionally, it is better to run the function, that schedules the next beep, by setTimeout
each time instead of using setIntervall
. This makes sure that the beat always is aligned to the time frame that is used by the AudioContext
. Here is a simplified example based on your code:
import React, { useEffect, useState } from 'react';
const duration = 0.1;
const bpm = 40;
const shortDelta = 0.01;
const oneBeatInSeconds = 60000 / bpm / 1000;
let ac;
let nextBeep = 0;
function scheduleNextBeep() {
let thisBeep = nextBeep;
if (thisBeep > 0) {
// schedule the next beep short before it shall be played
nextBeep += oneBeatInSeconds;
setTimeout(scheduleNextBeep, (nextBeep - ac.currentTime) * 1000 - shortDelta);
// schedule this beep
const osc = ac.createOscillator();
osc.connect(ac.destination);
osc.start(thisBeep);
osc.stop(thisBeep + duration);
}
}
function App() {
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
ac = new AudioContext();
}, []);
function toggleButton() {
if (isPlaying) {
setIsPlaying(false);
nextBeep = 0;
} else {
setIsPlaying(true);
// schedule the first beep
nextBeep = ac.currentTime + shortDelta;
scheduleNextBeep();
}
}
return (
<div className="App">
<div className="Bpm">{bpm} BPM</div>
<button type="button" onClick={toggleButton}>
{isPlaying ? 'stop' : 'play'}
</button>
</div>
);
}
export default App;
Update 07/15/2022
As discussed in the comments you can improve the quality of the "beep" sound by using a nice sample wav instead of the OscillatorNode
. If you definitely need the oscillator for some reason you can apply an envelope to the beep like this:
function scheduleNextBeep() {
let thisBeep = nextBeep;
if (thisBeep > 0) {
// schedule the next beep short before it shall be played
nextBeep += oneBeatInSeconds;
setTimeout(scheduleNextBeep, (nextBeep - ac.currentTime) * 1000 - shortDelta);
// prepare this beep
const oscNode = ac.createOscillator();
const gainNode = ac.createGain();
oscNode.connect(gainNode);
gainNode.connect(ac.destination);
// set envelope of beep
gainNode.gain.value = 1.0;
gainNode.gain.setValueAtTime(1.0, thisBeep + duration * 0.7);
gainNode.gain.exponentialRampToValueAtTime(0.00001, thisBeep + duration);
// schedule this beep
oscNode.start(thisBeep);
oscNode.stop(thisBeep + duration);
}
}