Search code examples
javascripthtmlweb-audio-api

WebAudio: setTargetAtTime() event is triggered Immediately, not at Scheduled Time


I have an Oscillator that is connected to a Gain Node, which is used to create a Volume Envelope, which SLOWLY decreases the Gain over time. At around 1.5 seconds after Start, I want to do the following at Second 2:

  • Cancel All scheduled (future) Envelope Gain Changes.
  • Create a new Schedule setTargetAtTime to "fade out" the sound.

The problem is that the "fade out" setTargetAtTime() seems to be triggered immediately, which makes a JUMP IN VOLUME. Yes, that is expected if the second param (startTime) is less than or equal to audioContext.currentTime. But it is NOT the case here.

function startSound() {
  //Audio context
  var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  var currentTime = audioCtx.currentTime;

  //Create Gain Node And Oscillator
  var gainNode = audioCtx.createGain();
  var oscillator = audioCtx.createOscillator();
  oscillator.type = "sine";
  oscillator.frequency.setValueAtTime(440, currentTime);

  //Connect Oscillator to Gain Node and Gain Node to audioCtx.destination
  oscillator.connect(gainNode);
  gainNode.connect(audioCtx.destination);
  oscillator.start(currentTime);

  //Create Gain (Volume Envelope)
  gainNode.gain.setValueAtTime(0.5, currentTime);
  gainNode.gain.linearRampToValueAtTime(0.5, currentTime + 0.5);
  gainNode.gain.linearRampToValueAtTime(0.25, currentTime + 2);
  gainNode.gain.linearRampToValueAtTime(0.000001, currentTime + 5);
  gainNode.gain.linearRampToValueAtTime(0.000001, currentTime + 999);

  //Approximately 1.5 seconds later, Cancel ALL Scheduled Envelope changes from second 2 and up, and create a FAST
  //"Fade Out" that lasts 0.1 seconds.
  var cancelAt = currentTime + 2;

  setTimeout(function () {
    StopEnvelope(audioCtx, gainNode, cancelAt);
  }, 1500);
}

function StopEnvelope(audioCtx, gainNode, cancelAt) {
  //This is never TRUE
  if (cancelAt <= audioCtx.currentTime) {
    console.log("cancelScheduledValues will happen immediately!");
  }

  gainNode.gain.cancelScheduledValues(cancelAt);
  gainNode.gain.setTargetAtTime(0.00001, cancelAt, 0.1);
}
<input type="button" value="Start Oscillator" onclick="startSound();" />

<p>
  You can hear a JUMP in Gain (Volume) at around 1.5 seconds. That is when <strong>gainNode.gain.setTargetAtTime(0.00001, cancelAt, 0.1);</strong> is executed.
  It's is like it is executed Immedately instead at cancelAt (at second 2).
</p>

<p style="color: red; font-weight: bold;">
  NOTE: this would be expected if cancelAt <= audioCtx.currentTime. But that is NOT the case here. </p>

What it's VERY STRANGE is that if I add a bit of TIME to the scheduled event, it seems to behave as expected. See line cancelAt += 0.0005;

function startSound() {
  //Audio context
  var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  var currentTime = audioCtx.currentTime;

  //Create Gain Node And Oscillator
  var gainNode = audioCtx.createGain();
  var oscillator = audioCtx.createOscillator();
  oscillator.type = "sine";
  oscillator.frequency.setValueAtTime(440, currentTime);

  //Connect Oscillator to Gain Node and Gain Node to audioCtx.destination
  oscillator.connect(gainNode);
  gainNode.connect(audioCtx.destination);
  oscillator.start(currentTime);

  //Create Gain (Volume Envelope)
  gainNode.gain.setValueAtTime(0.5, currentTime);
  gainNode.gain.linearRampToValueAtTime(0.5, currentTime + 0.5);
  gainNode.gain.linearRampToValueAtTime(0.25, currentTime + 2);
  gainNode.gain.linearRampToValueAtTime(0.000001, currentTime + 5);
  gainNode.gain.linearRampToValueAtTime(0.000001, currentTime + 999);

  //Approximately 1.5 seconds later, Cancel ALL Scheduled Envelope changes from second 2 and up, and create a FAST
  //"Fade Out" that lasts 0.1 seconds.
  var cancelAt = currentTime + 2;

  setTimeout(function () {
    StopEnvelope(audioCtx, gainNode, cancelAt);
  }, 1500);
}

function StopEnvelope(audioCtx, gainNode, cancelAt) {
  cancelAt += 0.0005;
  
  //This is never TRUE
  if (cancelAt <= audioCtx.currentTime) {
    console.log("cancelScheduledValues will happen immediately!");
  }

  gainNode.gain.cancelScheduledValues(cancelAt);
  gainNode.gain.setTargetAtTime(0.00001, cancelAt, 0.1);
}
<input type="button" value="Start Oscillator" onclick="startSound();" />

<p>
Now the "Fade Out" works as expected. The only change is that 0.0005 seconds is added to the <strong>cancelAt</strong> paramenter.
</p>

<p style="color: red; font-weight: bold;">
  NOTE: As before, cancelAt is NEVER less than or equal to  audioCtx.currentTime. </p>

Do you know what is the source of this strange behavior? Can this be fixed without that time hack?

Thank you very much! Danny bullo


Solution

  • The problem is that cancelScheduledValues() will remove all automations which are not yet finished at cancelTime. In your example that includes all the ramps despite of the first one which doesn't really do anything since it ramps the signal from 0.5 to 0.5. The second ramp is removed because it ends at second 2 and is therefore not done yet at second 2. This means as soon as you call cancelScheduledValues() the gain will snap back to 0.5 since that was the end value of the last remaining animation.

    This is also the reason why it seems to work if you increase cancelTime a little bit. When doing so the second ramp will not be affected by the call to cancelScheduledValues() and the effect is less audible.

    You can achieve the desired behavior by using cancelAndHoldAtTime() instead of cancelScheduledValues(). It will hold the last value and the next automation will start from there.