1

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);
  }
Ced
  • 15,847
  • 14
  • 87
  • 146
  • 1
    _"The only way I can see this happening is if AudioContext.decodeAudioData isn't synchronous."_ You are correct, `.decodeAudioData` is not synchronous. – guest271314 Jan 06 '17 at 18:07
  • @guest271314 well, damn, I'll have to re-work everything – Ced Jan 06 '17 at 18:08
  • What is expected result of `setTimeout` call within `for` loop at jsfiddle, which is not called within a closure? `.decodeAudioData()` also returns a `Promise`, where `.then()` can be chained to get decoded audio data. – guest271314 Jan 06 '17 at 18:10
  • @guest271314 it's to lower the volume incrementally, check the last snippet, the `stop()` in my question. That is great that it returns a promise, I can work with that since I know in advance when the sounds have to stop. – Ced Jan 06 '17 at 18:16
  • Have you tried using `progress` or `timeupdate` event to decrease `.volume` of `AudioNode` from `1` to `0`? See [HTML5 audio streaming: precisely measure latency?](http://stackoverflow.com/questions/38768375/html5-audio-streaming-precisely-measure-latency). fwiw, `javascript` at [Is it possible to mix multiple audio files on top of each other preferably with javascript](http://stackoverflow.com/questions/40570114/is-it-possible-to-mix-multiple-audio-files-on-top-of-each-other-preferably-with) uses `.decodeAudioData().then()` pattern. – guest271314 Jan 06 '17 at 18:18
  • @guest271314 Those don't figure in the web audio api doc, I had no idea about those – Ced Jan 06 '17 at 18:23
  • 1
    _"Those don't figure in the web audio api doc"_ Which documentation are you referencing? See [Media events](https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Media_events) – guest271314 Jan 06 '17 at 18:25
  • @guest271314 the web audio api doc, there is no mention of it there : https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API . The doc you are linking is for another api, if I understand correctly, but it seems they are using the same underlying features so i'll try to use what you suggested. – Ced Jan 07 '17 at 21:28
  • @guest271314 if you are interested check the answer, I found **exactly** what I needed. – Ced Jan 07 '17 at 22:15
  • Can you create a stacksnippet, jsfiddle http://jsfiddle.net or plnkr http://plnkr.co to demonstrate? – guest271314 Jan 07 '17 at 22:20
  • 1
    @guest271314 sure, when I've set everything up, I didn't start yet but I'm about to – Ced Jan 07 '17 at 22:47
  • 1
    @guest271314 done, it was easy. I removed the whole sound container thing because it is a bit irrelevant. The jsfiddle is at the bottom of my answer, change the var at the top to change the progression of the gain decreasing – Ced Jan 07 '17 at 22:58

1 Answers1

2

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.

Ced
  • 15,847
  • 14
  • 87
  • 146
  • Nice example, but what about what you wanted to do -> "I want my sound to decrease in volume before stopping". Did you find any ways to stop the sound (source.stop()) right after the volume has been decrease around 0? I'm trying to detect the end of the linearRamp function. But unfortunately there is nothing about this in the DOC. I can set an interval and check for the gain.value X times then once the value is close to 0 I could stop the source. But I would prefer to stop the source at the very "real end" of the linearRamp process. Any ideas? – Faks Mar 01 '18 at 16:47
  • @Faks well if the duration is 0.3sec, you can stop after 0.3sec. I'm not sure it's gonna be perfect but that's what I did if I recall correctly. If you go a bit larger the sound should be at 0 anyway so there shouldn't be any issue , doesn't it ? Like stopping after 0.7sec. – Ced Mar 02 '18 at 12:30
  • Thanks ;) This is what I already did but this is not perfect, it would be better if the function itself could do a callback at the end fade. Also: Just an info for developers using Web Audio API with Cordova. On iOS, the linearRampToValueAtTime() function works but, if you have several sounds playing at the same time, this will cause random creaks/crunches/clicks/dirty sounds on your speakers. If your app target both Android and iOS, you should use 2 different functions, linearRampToValueAtTime() for Android, then the gainNode.gain.value (deprecated on Chrome 64) on a SetInterval on iOS. – Faks Mar 05 '18 at 10:38