How do I remove the delay between pushing the button and the first sound?

507 Views Asked by At

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;
2

There are 2 best solutions below

8
Markus On BEST ANSWER

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);
    }
}
0
Mojtaba Hoseinpour On

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);
})();