0

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;
Marco Mazzai
  • 95
  • 2
  • 16
  • 1
    Your code to actually play the sound is buried inside useEffect which is likely triggered after a call to useState (via the toggleButton) function. That's a lot of "wait for react to get around to it" delay. Can you structure your code such that the audio objects are stored globally or in a context object such that clicking the button invokes the Audio apis directly? – selbie Jul 11 '22 at 07:09
  • I'm not 100% sure if this is the root cause of your sound playing delay. It's just a hypothesis. – selbie Jul 11 '22 at 07:10
  • I'm not sure. I tried also a "minimal" version with no useEffect, and no UI-rendering, and the problem is still there. – Marco Mazzai Jul 11 '22 at 07:47
  • Maybe you could post the "minimal" version? aka [mcve] which is well liked on SO. – selbie Jul 11 '22 at 07:54
  • I edited to the minimal version – Marco Mazzai Jul 11 '22 at 08:26
  • I don't know if this is relevant but FWIW: `setInterval(foo, delay)` will run the function `foo` only after `delay` milliseconds have passed. This doesn't mean that `foo` function will be run immediately after each `delay` milliseconds interval. – Aman Godara Jul 13 '22 at 12:14

2 Answers2

2

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);
    }
}
Markus
  • 5,976
  • 5
  • 6
  • 21
  • I made an edit in code to bring it closer to the author's original code. Added a `useEffect` to initialize `ac`, this `useEffect` will run after component has been rendered. Just in case if this application is server-side-rendered, then initializing `audioContext` on server would become a problem, hence this `useEffect` will help. – Aman Godara Jul 14 '22 at 06:45
  • @Aman Godara: Thank you for pointing this out. There may be even more possible improvements regarding react. Main focus of my answer is the timing part. – Markus Jul 14 '22 at 10:24
  • @Markus Thanks a lot! This is pretty close to what I'm trying to achieve. There is still a 'click' every time the oscillator stops. You can notice it with a bigger BPM like 120... Is this because of `setTimeout`? – Marco Mazzai Jul 14 '22 at 18:56
  • @Marco Mazzai The click that you perceive is when the oscillator's sine curve is stopped in the middle of a wave. Natural sounds don't stop immediately. You can add a `GainNode` and configure a decay ramp or you can use an `AudioBufferSourceNode` with a natural sample sound of your choice instead of using the `OscillatorNode`. Here is a good explanation of what is happening: https://alemangui.github.io/ramp-to-value – Markus Jul 15 '22 at 10:05
  • @Markus Would this solution work using `useState` as well? It seems like `thisBeep` won't get update on re-render with `useState`. – Marco Mazzai Jul 18 '22 at 07:05
  • @Marco Mazzai Not shure if I get your point. The solution uses `useState`, doesn't it? – Markus Jul 18 '22 at 15:48
  • @Markus Yes, I explained it badly. ;) The solution works, and `thisBeep` gets updated. I was wondering if it is possible to use a `useState` for `thisBeep` and `nextBeep` as well instead of normal constants. Will the app have too many re-renders? I wonder if it's normal practice to have both `useState` constants and "normal" constants. – Marco Mazzai Jul 18 '22 at 19:43
  • 1
    @Marco Mazzai I would only use the `useState` hook if I want the UI to be updated. Does [this](https://stackoverflow.com/a/55198556/18667225) answer your question? If you need more help, you should create a new question regarding this very topic. I'm shure you can even strip out the web audio part to have a minimal example. – Markus Jul 19 '22 at 06:33
0

This a minor bug, one possible solution could be thinking about setInterval a bit. setInterval function runned with a delay ..

You can try to call your timer function outside the setInterval .

And for more information you can read this.

setinterval-function-without-delay-the-first-time

In short answer what I mean is that if you try to use setInterval with 200 ms interval , the first time that your function called is not exactly 200 ms :)

Nice solution:

(function foo() {
  ...
  setTimeout(foo, delay);
})();