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)
See https://editor.p5js.org/neill0/sketches/HrK5SeTTI
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.
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.rampTo → Param.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.
The throw is conditional, on two counts - it is not "any ramp, always."
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.)
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.
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.)
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."
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.