Search code examples
javascriptaudioweb-audio-api

Web audio api, stop sound gracefully


The web audio api furnish the method .stop() to stop a sound. I want my sound to decrease in volume before stopping. To do so I used a gain node. However I'm facing weird issues with this where some sounds just don't play and I can't figure out why.

Here is a dumbed down version of what I do:

https://jsfiddle.net/01p1t09n/1/

You'll hear that if you remove the line with setTimeout() that every sound plays. When setTimeout is there not every sound plays. What really confuses me is that I use push and shift accordingly to find the correct source of the sound, however it seems like it's another that stop playing. The only way I can see this happening is if AudioContext.decodeAudioData isn't synchronous. Just try the jsfiddle to have a better understanding and put your headset on obviously.

Here is the code of the jsfiddle:

  let url = "https://raw.githubusercontent.com/gleitz/midi-js-soundfonts/gh-pages/MusyngKite/acoustic_guitar_steel-mp3/A4.mp3";
  let soundContainer = {};
  let notesMap = {"A4": [] };
  let _AudioContext_ = AudioContext || webkitAudioContext;
  let audioContext = new _AudioContext_();

  var oReq = new XMLHttpRequest();
  oReq.open("GET", url, true);
  oReq.responseType = "arraybuffer";
  oReq.onload = function (oEvent) {
    var arrayBuffer = oReq.response; 
    makeLoop(arrayBuffer);
  };
  oReq.send(null);

  function makeLoop(arrayBuffer){
     soundContainer["A4"] = arrayBuffer;
     let currentTime = audioContext.currentTime;
     for(let i = 0; i < 10; i++){
        //playing at same intervals
            play("A4", currentTime + i * 0.5);
        setTimeout( () => stop("A4"), 500 + i * 500); //remove this line you will hear all the sounds.
     }
  }

  function play(notePlayed, start) {    

      audioContext.decodeAudioData(soundContainer[notePlayed], (buffer) => {
      let source; 
      let gainNode; 
        source = audioContext.createBufferSource(); 
        gainNode = audioContext.createGain();
        // pushing notes in note map
        notesMap[notePlayed].push({ source, gainNode });
        source.buffer = buffer;                   
        source.connect(gainNode);
        gainNode.connect(audioContext.destination);
        gainNode.gain.value = 1;
        source.start(start);
       });
    }

      function stop(notePlayed){    
        let note = notesMap[notePlayed].shift();

        note.source.stop();
     }


This is just to explain why I do it like this, you can skip it, it's just to explain why I don't use stop()

The reason I'm doing all this is because I want to stop the sound gracefully, so if there is a possibility to do so without using setTimeout I'd gladly take it.

Basically I have a map at the top containing my sounds (notes like A1, A#1, B1,...).

soundMap = {"A": [], "lot": [], "of": [], "sounds": []};

and a play() fct where I populate the arrays once I play the sounds:

  play(sound) { 
    // sound is just { soundName, velocity, start}   
    let source; 
    let gainNode; 
    // sound container is just a map from soundname to the sound data.
    this.audioContext.decodeAudioData(this.soundContainer[sound.soundName], (buffer) => {
      source = this.audioContext.createBufferSource(); 
      gainNode = this.audioContext.createGain();
      gainNode.gain.value = sound.velocity;
      // pushing sound in sound map
      this.soundMap[sound.soundName].push({ source, gainNode });
      source.buffer = buffer;                   
      source.connect(gainNode);
      gainNode.connect(this.audioContext.destination);
      source.start(sound.start);
     });
  }

And now the part that stops the sounds :

  stop(sound){   
    //remember above, soundMap is a map from "soundName" to {gain, source} 
    let dasound = this.soundMap[sound.soundName].shift();
    let gain = dasound.gainNode.gain.value - 0.1;

    // we lower the gain via incremental values to not have the sound stop abruptly
    let i = 0;
    for(; gain > 0; i++, gain -= 0.1){ // watchout funky syntax
      ((gain, i) => {
        setTimeout(() => dasound.gainNode.gain.value = gain, 50 * i );
      })(gain, i)
    }
    // we stop the source after the gain is set at 0. stop is in sec
    setTimeout(() => note.source.stop(), i * 50);
  }

Solution

  • Aaah, yes, yes, yes! I finally found a lot of things by eventually bothering to read "everything" in the doc (diagonally). And let me tell you this api is a diamond in the rough. Anyway, they actually have what I wanted with Audio param :

    The AudioParam interface represents an audio-related parameter, usually a parameter of an AudioNode (such as GainNode.gain). An AudioParam can be set to a specific value or a change in value, and can be scheduled to happen at a specific time and following a specific pattern.

    It has a function linearRampToValueAtTime()

    And they even have an example with what I asked !

    // create audio context
    var AudioContext = window.AudioContext || window.webkitAudioContext;
    var audioCtx = new AudioContext();
    
    // set basic variables for example
    var myAudio = document.querySelector('audio');
    var pre = document.querySelector('pre');
    var myScript = document.querySelector('script');
    
    pre.innerHTML = myScript.innerHTML;
    
    var linearRampPlus = document.querySelector('.linear-ramp-plus');
    var linearRampMinus = document.querySelector('.linear-ramp-minus');
    
    // Create a MediaElementAudioSourceNode
    // Feed the HTMLMediaElement into it
    var source = audioCtx.createMediaElementSource(myAudio);
    
    // Create a gain node and set it's gain value to 0.5
    var gainNode = audioCtx.createGain();
    
    // connect the AudioBufferSourceNode to the gainNode
    // and the gainNode to the destination
    gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
    source.connect(gainNode);
    gainNode.connect(audioCtx.destination);
    
    // set buttons to do something onclick
    linearRampPlus.onclick = function() {
      gainNode.gain.linearRampToValueAtTime(1.0, audioCtx.currentTime + 2);
    }
    
    linearRampMinus.onclick = function() {
      gainNode.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 2);
    }
    

    Working example here

    They also have different type of timings, like exponential instead of linear ramp which I guess would fit this scenario more.