Search code examples
reactjsreact-hookstimerweb-audio-apimetronome

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


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;

Solution

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