Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save nbogie/f307c9ced7e81a39487a35ff3b7f7e22 to your computer and use it in GitHub Desktop.

Select an option

Save nbogie/f307c9ced7e81a39487a35ff3b7f7e22 to your computer and use it in GitHub Desktop.
Root cause analysis of a p5.sound.js bug: cancelAndHoldAtTime is not a function` on Firefox

Root cause: cancelAndHoldAtTime is not a function on Firefox

Stack: p5 2.3.0 · p5.sound 0.3.0 · Tone.js 15.0.2 · Firefox

(AI usage disclosure: analysis performed with claude code + opus 4.8)

Minimum reproducible example

See https://editor.p5js.org/neill0/sketches/HrK5SeTTI

Summary

On Firefox, ramping an oscillator's frequency or amplitude - osc.freq() or osc.amp() - can throw an uncaught:

this._param.cancelAndHoldAtTime is not a function

this._param is the native Web Audio AudioParam. cancelAndHoldAtTime is part of the Web Audio spec, but Firefox has never implemented it (it is undefined on AudioParam.prototype). Tone.js's parameter-ramp machinery calls that native method in certain scheduling states (see "When it actually throws" below), so the ramp throws and the sketch dies.

The subtlety - and the real root cause - is that Tone ships a shim that already handles this exact Firefox gap, but p5.sound bypasses it. Tone bundles standardized-audio-context, whose wrapped AudioParam explicitly polyfills cancelAndHoldAtTime on Firefox and Safari. The shim only applies when Tone runs on a context it created; p5.sound hands Tone a raw native AudioContext instead, so the params are native and the polyfill never runs. See "Why Tone's bundled shim doesn't catch it" below.

The call chain

Take the most common entry point, Oscillator.freq():

1. p5.sound - src/sources/Oscillator.js:115

freq(f, rampTime = 0) {
  if (typeof f === "number") {
    this.node.frequency.rampTo(clamp(f, 0, 24000), rampTime);   // -> Tone Signal.rampTo
  }
  ...
}

this.node is a Tone.Oscillator; this.node.frequency is a Tone.Signal.

2. Tone - Signal.rampToParam.rampTo

node_modules/tone/build/esm/signal/Signal.js:123

rampTo(value, rampTime, startTime) {
    this._param.rampTo(value, rampTime, startTime);
}

node_modules/tone/build/esm/core/context/Param.js:379 - for a frequency unit this dispatches to exponentialRampTo (amplitude/linear params go to linearRampTo; both lead to the same place):

rampTo(value, rampTime = 0.1, startTime) {
    if (this.units === "frequency" || this.units === "bpm" || this.units === "decibels") {
        this.exponentialRampTo(value, rampTime, startTime);
    } else {
        this.linearRampTo(value, rampTime, startTime);
    }
    return this;
}

3. Tone - every *RampTo calls setRampPoint, which calls cancelAndHoldAtTime

node_modules/tone/build/esm/core/context/Param.js:232

setRampPoint(time) {
    time = this.toSeconds(time);
    let currentVal = this.getValueAtTime(time);
    this.cancelAndHoldAtTime(time);     // <-- Tone's wrapper, see step 4
    ...
}

(linearRampTo, exponentialRampTo, and targetRampTo all begin with this.setRampPoint(startTime).)

4. Tone - cancelAndHoldAtTime delegates to the NATIVE param

node_modules/tone/build/esm/core/context/Param.js:338

cancelAndHoldAtTime(time) {
    const computedTime = this.toSeconds(time);
    ...
    const before = this._events.get(computedTime);
    const after = this._events.getAfter(computedTime);
    if (before && EQ(before.time, computedTime)) {
        if (after) {
            this._param.cancelScheduledValues(after.time);
            this._events.cancel(after.time);
        } else {
            this._param.cancelAndHoldAtTime(computedTime);   // line 355  <-- THROWS on Firefox
            this._events.cancel(computedTime + this.sampleTime);
        }
    }
    ...
}

this._param is the real AudioParam. AudioParam.prototype.cancelAndHoldAtTime is undefined in Firefox, so this._param.cancelAndHoldAtTime(computedTime) throws TypeError: this._param.cancelAndHoldAtTime is not a function.

Note Tone also uses cancelScheduledValues (which Firefox does implement) in the sibling branches - only line 355 reaches the missing method. Which branch runs depends on the scheduled-event state at the moment of the ramp.

When it actually throws

The throw is conditional, on two counts - it is not "any ramp, always."

Which APIs reach it (tested)

Each API below was exercised in isolation on Firefox against p5.sound@0.3.0:

call reaches the native cancelAndHoldAtTime?
osc.freq(...) yes - throws
osc.amp(...) yes - throws
Delay.delayTime(...) no
Envelope.triggerAttack() / .play() no
Reverb.set(...) no - set() does this.node.decay = t (regenerates the impulse response), not a param ramp
Panner.pan(...) no
Panner3D.set(...) no

So in the example suite, the delay/reverb sketches fail because of the oscillators in them, not the effect APIs. (Filter freq()/res() were not tested and are not claimed either way.)

When the oscillator ramp trips it (timing)

The native call at line 355 runs only when, at the ramp's target time, there is already a scheduled event and nothing after it (before && EQ(before.time, computedTime) with after === null). The p5.sound Oscillator constructor seeds frequency and volume with a setValueAtTime at the then-current context time, so the first ramp collides with that seed event whenever no audio time has elapsed between construction and the ramp.

That condition is satisfied automatically while the context is suspended (currentTime frozen at 0, so construction and ramp share time 0), which is the common case - sketches build the oscillator and ramp before the user gesture resumes audio. But suspension is not itself the cause. Verified A/B on Firefox:

sequence (context running, currentTime ≈ 0.4) result
construct oscillator at t=0 (suspended), resume, let time advance, then ramp at t≈0.4 no throw (seed at 0 ≠ ramp at 0.4)
resume, let time advance, then construct and ramp in the same tick at t≈0.4 throws (seed and ramp coincide)

So it throws whenever the seed and the first ramp land on the same currentTime - guaranteed when suspended, also possible when running - and does not throw once time separates them. This is why 001-Oscillator-FrequencyAmplitude failed only intermittently (≈1 in 10) pre-fix rather than every run.

Minimal reproducible example

Creating an oscillator and changing one parameter is enough.

index.html:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <script src="https://cdn.jsdelivr.net/npm/p5@2.3.0/lib/p5.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/p5.sound@0.3.0/dist/p5.sound.min.js"></script>
</head>
<body>
  <script>
    function setup() {
      const osc = new p5.Oscillator();
      osc.freq(880); // throws on Firefox:
                     // Uncaught TypeError: this._param.cancelAndHoldAtTime is not a function
    }
  </script>
</body>
</html>

Open in Firefox and check the console; it throws at the osc.freq(...) line. Chromium runs it cleanly because it implements the native method.

(osc.amp(0.5) reproduces it the same way. The other ramp-based APIs do not - see "When it actually throws" above for the tested scope and the timing condition that must hold.)

Why Tone's bundled shim doesn't catch it

Tone.js depends on standardized-audio-context (15.0.2 pins ^25.3.70) and states its purpose is "maximum browser compatibility." That library's wrapped AudioParam handles this exact gap:

node_modules/standardized-audio-context/build/es2019/factories/audio-param-factory.js:26

cancelAndHoldAtTime(cancelTime) {
    // Bug #28: Firefox & Safari do not yet implement cancelAndHoldAtTime().
    if (typeof nativeAudioParam.cancelAndHoldAtTime === 'function') {
        ...
        nativeAudioParam.cancelAndHoldAtTime(cancelTime);
    } else {
        // fall back to cancelScheduledValues + replay the last ramp event
        nativeAudioParam.cancelScheduledValues(cancelTime);
        ...
    }
}

Tone reaches that wrapper only when it runs on a context it created. Tone's context factory builds a standardized (wrapped) context:

node_modules/tone/build/esm/core/context/AudioContext.js:7

import { AudioContext as stdAudioContext, ... } from "standardized-audio-context";
export function createAudioContext(options) {
    return new stdAudioContext(options);   // wrapped context -> wrapped params -> shim applies
}

and Tone's default getContext() (core/Global.js:20) lazily creates one of these. On such a context, osc.frequency is a wrapped param whose cancelAndHoldAtTime is the polyfill above - so default Tone usage runs fine on Firefox.

p5.sound overrides that context with a raw native one:

src/core/Utils.js:21

function getAudioContext() {
    if (!audioContext) {
        audioContext = new window.AudioContext();   // native context, NOT standardized
        ToneSetContext(audioContext);                // hand the native ctx to Tone
    }
    return audioContext;
}

ToneSetContext(nativeCtx) wraps the native context (Global.js:37), but the nodes/params created on it are native. So this._param is a native Firefox AudioParam with no cancelAndHoldAtTime, and the polyfill that would have run on a standardized param is bypassed. That is the chain that turns "Tone supports Firefox" into "this throws on Firefox."

Suggested fix

Don't hand Tone a raw native context - let the shim apply. Either let Tone create its own context (it builds a standardized-audio-context one whose params carry the cancelAndHoldAtTime polyfill), or, where p5.sound needs to own the context, construct it with standardized-audio-context's AudioContext instead of new window.AudioContext() before calling setContext(). This restores the polyfill for every ramp path and also covers the other Firefox/Safari gaps the shim handles - not just this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment