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, bysetTimeouteach time instead of usingsetIntervall. This makes sure that the beat always is aligned to the time frame that is used by theAudioContext. Here is a simplified example based on your code: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: