mogelbrod
mogelbrod

Reputation: 2306

cancelScheduledValues(x) followed by setValueCurveAtTime(x) throws error

I'm using the WebAudio API to crossfade between multiple sources. Fades are queued using setValueCurveAtTime(curve, time, duration). The WebAudio spec indicates that any subsequent call to setValueCurveAtTime with overlapping durations is not allowed. So I'm calling cancelScheduledValues(time) before queuing up new fades. Both Firefox v68 and Chrome v77 throw errors on the second setValueCurveAtTime call however.

The attached snippet contains the minimum amount of code to trigger the errors in either browser. Click on Start to trigger the error. Note that it doesn't play any audio as it's not needed for the error to be thrown. The select dropdown allow control of the time argument to both functions. In Chrome v77 time=0 doesn't trigger an error.

Any ideas on how to get this to work in both browsers would be much appreciated!

Update: As Raymond Toy pointed out cancelScheduledValues(t) appears to cancel automations which started at t or later (not simply active during t). By using cancelScheduledValues(Math.max(t - duration, 0)) the code now appears to work in Chrome. Firefox still fails with a Operation is not supported error however.

<!DOCTYPE html>
<html>
  <body>
    <button id="start">Start</button>
    <select id="time">
      <option value="0">time=0</option>
      <option value="1">time=currentTime</option>
    </select>
    <pre id="log"></pre>

    <script>
      const select = document.querySelector('#time')
      const log = document.querySelector('#log')
      
      function start() {
        const ctx = new AudioContext()
        ctx.resume()
        const gain = ctx.createGain()
        gain.connect(ctx.destination)

        // Fade in
        gain.gain.setValueCurveAtTime(new Float32Array([0, 1]), 0, 1)

        setTimeout(() => {
          const time = select.options[select.selectedIndex].value === '0' ? 0 : ctx.currentTime

          // Replace fade in with fade out
          // THIS IS THE CALL THAT DOESN'T WORK =====
          // Doesn't work in Firefox nor Chrome:
          // gain.gain.cancelScheduledValues(time)
          // Doesn't work in Firefox:
          gain.gain.cancelScheduledValues(Math.max(time - 1 /* duration of previous fade */, 0))

          try {
            // ERROR IS THROWN HERE =================
            gain.gain.setValueCurveAtTime(new Float32Array([0, 1]), time, 1)
          } catch (error) {
            log.prepend(error.message + '\n')
            throw error
          }
          log.prepend('No error!\n')
        }, 100)
      }

      document.querySelector('#start').addEventListener('click', start)
    </script>
  </body>
</html>

Upvotes: 0

Views: 322

Answers (2)

mogelbrod
mogelbrod

Reputation: 2306

In the end I ended up ditching setValueCurveAtTime() in favour of multiple setValueAtTime calls:

function fade(gainNode, fadeIn, startTime = 0) {
  const duration = 1 // seconds
  const delta = 1 / 100 // number of volume changes per second
  const targetVolume = fadeIn ? 1 : 0
  const currentTime = audioContext.currentTime
  if (!startTime) { startTime = currentTime }

  // We can only read current volume for current startTime, so if queueing a fade
  // we'll have to assume it starts in the other end
  const startingVolume = startTime && startTime !== currentTime
    ? 1 - targetVolume
    : gainNode.gain.value

  // Offset to start at when startingVolume isn't 0 or 1
  let tOffset = equalPowerEasingInverse(startingVolume)
  if (fadeIn) tOffset = 1 - tOffset

  let t = 0 // time iterator [0..1]

  try {
    // Cancel any potentially overlapping fade automations
    gainNode.gain.cancelScheduledValues(Math.max(startTime - duration, 0))

    for (; t + tOffset <= 1; t += delta) {
      // Queue volume change
      gainNode.gain.setValueAtTime(
        equalPowerEasing(t + tOffset, fadeIn),
        startTime + t*duration
      )
    }

    // Ensure final value is exact
    gainNode.gain.setValueAtTime(targetVolume, startTime + t*duration)
  } catch (error) {
    if (/Failed to execute 'setValueCurveAtTime'|Operation is not supported/.test(error.message)) {
      // Ignore Chrome + Firefox errors
    } else {
      throw error
    }
  }
}

function equalPowerEasing(t, invert = true) {
  if (invert) t = 1 - t
  return Math.cos(t * 0.5 * Math.PI)
}

function equalPowerEasingInverse(x) {
  return Math.acos(x) / 0.5 / Math.PI
}

Upvotes: 0

Raymond Toy
Raymond Toy

Reputation: 6048

My reading of cancelScheduledValues shows that this is working as intended. The event time for a setValueCurveAtTime(curve, time, duration) is time. cancelScheduledValues(t2) removes all events whose event time is t2 or greater. In your test case, time = 0, and t2 is currentTime which is greater than 0. Thus, nothing is removed from the timeline. The second call to setValueCurveAtTime inserts a new event that does overlap the previous one. Hence, you get an error.

Having said that, I think this is kind of unexpected. This could be an error in the WebAudio spec.

Upvotes: 1

Related Questions