A Play Heal button that produces nothing is the usual first result of this build. The console is clean, the oscillator was created and started, and the page stays silent. It is almost always one of two things: the AudioContext was never resumed, or the oscillator was stopped on the sample the gain reached zero.
Five nodes, no files: three oscillators at ratios of 1, 1.5 and 2, a filter for the shimmer, and a gain node holding a 560 ms envelope. Node creation order matters less than parameter scheduling order, and that second order is where first attempts break.
Silence Usually Means a Suspended Context
Every current browser starts an AudioContext suspended until a
user gesture reaches it. Nodes can be built, parameters
scheduled and oscillators started during that suspension, and
nothing is audible until resume() runs inside a
real interaction. A click, keypress or touch counts. A timer
callback or a promise does not.
Create one context for the page and keep it. Browsers cap how
many a document can hold, and a context per heal eventually
returns one that refuses to start. Resume once, check
ctx.state, then schedule against
ctx.currentTime. Scheduling while suspended is the
quieter half of the bug: the cue fires whenever the user finally
clicks.
Older Safari still exposes the webkitAudioContext
prefix, and a gesture does not always survive a failed resume,
so retry on the next pointer event. Broader context setup is
covered in
Web Audio API for browser games.
Three Oscillators at Ratios of 1, 1.5 and 2
Root, perfect fifth, octave. The stack reads as a major chord because the octave doubles the root and the fifth is the interval already sitting inside the triad. Two pitch classes, one direction upward.
440 Hz is a serviceable root: it survives laptop speakers, and its octave at 880 Hz stays short of the band where phone speakers sting. Ratios of 1, 1.5 and 2 give 440, 660 and 880 Hz. The 1.5 lands near 702 cents, about two above the equal-tempered fifth, which nobody hears inside a half-second cue.
- Root: triangle at 440 Hz, detune -4 cents, gain 0.5.
- Fifth: sine at 660 Hz, detune +3 cents, gain 0.26.
- Octave: sine at 880 Hz, detune +6 cents, gain 0.14.
The levels sum to 0.9, leaving headroom before the filter resonance and master gain add anything. An even third across three voices reads louder on paper and harsher in the mix, because the upper partials mask the body.
Stagger the starts by about 20 ms per voice. All three beginning on the same sample sounds like an organ; entering 20 ms apart sounds like a bloom, because the fifth and octave arrive after the attack has already told the ear something happened.
Cents, Not Hertz
Set detune in cents, never hertz. A cent is a ratio, so 4 cents means the same musical width at 220 Hz and at 880 Hz. The offsets also create movement: the root at -4 cents puts its second harmonic near 878 Hz, the octave at +6 sits near 883, and the 5 Hz between them is a slow beat one oscillator cannot make. Past 20 cents, the chord becomes a pad.
A Filter Sweep, Then the Shimmer
The filter is the one node whose automation is about brightness rather than level. A lowpass at Q 0.7, opened from 900 Hz to 4200 Hz across the first 120 ms and closed back to 1200 Hz by 550 ms, gives the cue a rise that still reads on a phone speaker.
A lowpass only attenuates, it cannot add harmonics, so the sweep is audible in proportion to what the oscillators put above the cutoff. The triangle root has usable partials at 1320, 2200 and 3080 Hz, which is the band the cutoff travels through. In a dense mix a bandpass at Q 1.2 near 1800 Hz cuts through better at the cost of body.
Resonance is the second dial. A Q near 3 puts roughly 9 dB of boost at the corner, and as the corner moves that boost sweeps upward through the harmonics and becomes the shimmer. It also eats headroom, so compensate at the master gain. Treating that upper band as the message is covered in synthesizing a shimmer from oscillators.
Open with a linear ramp and close with an exponential one: frequency reads logarithmically, so a linear opening seems to accelerate and an exponential close seems to stall before it lands.
The Envelope Is Four Calls and One Rule
setValueAtTime writes an anchor.
linearRampToValueAtTime draws a straight line from
the previous event. exponentialRampToValueAtTime
applies constant attenuation per second, which is why it behaves
like a natural decay while a linear ramp behaves like a fade.
The rule is blunt: an exponential ramp cannot reach zero. The
spec requires a non-zero target with the same sign as the
starting value, and browsers either reject the call or ignore
the automation. Target 0.0001, which is -80 dB, then write
setValueAtTime(0) after the ramp so the node
settles on a clean zero.
One envelope that works: setValueAtTime(0.0001) at
the trigger, linearRampToValueAtTime(level) over
8 ms, setValueAtTime(level) at 180 ms to hold the
body, exponentialRampToValueAtTime(0.0001) at
550 ms, setValueAtTime(0) at 560 ms. The short
attack avoids a click without rounding the transient, the hold
keeps the pitch identifiable, and the 370 ms tail is what reads
as healing rather than as a pickup.
Ordering breaks more builds than arithmetic does. A ramp needs a preceding event to know where it starts, and an exponential ramp without an anchor can begin from the node default, which for a gain is 1. Anchor first, ramp second, increasing times.
stop() Is a Cut, Not a Fade
OscillatorNode.stop() does not consult the
envelope. It terminates the signal at the requested time, and if
the gain is still audible the waveform jumps from level to
silence inside one sample. That step is broadband, so it arrives
as a click.
Fix it by scheduling, not by gain. Stop 20 to 30 ms after the envelope reaches its final zero: start at t, stop at t + 0.58. The oscillator runs for 30 ms at a gain of exactly zero, and the cut happens where nothing can be heard.
Node lifetime is the quieter bug. An oscillator starts once and
stops once; after onended it cannot restart, so five
heals in a fight need five sets of oscillators. Disconnect each
oscillator and its per-voice gain inside
onended. Skip that and the nodes stay referenced
through the graph: a long session accumulates hundreds, and the
symptom is a context whose latency creeps upward.
Guard the arguments too: stop() before
start() produces nothing, and a NaN timestamp either
throws or leaves the node silent depending on the browser.
Render Once with OfflineAudioContext
Rebuilding six nodes and their automation on every heal is fine at one cue per second and wasteful at ten, and it costs milliseconds exactly when the frame budget is tightest. OfflineAudioContext runs the same graph faster than real time and returns an AudioBuffer a BufferSource can replay. The build function does not change.
Two constructor details bite. Length is in sample frames, so 0.6 seconds at 44.1 kHz is 26460 frames. The sample rate is fixed at construction: rendering at 48 kHz and playing into a 44.1 kHz context is fine because the buffer resamples, but exporting at an unusual rate is how a file ends up at the wrong pitch.
The result is small: 0.6 seconds of mono 16-bit at 44.1 kHz is
roughly 53 KB, cheaper to cache than to synthesise again. Vary
repeats with playbackRate between 0.97 and 1.03
rather than rebuilding at a new pitch, since an oscillator stack
has no formants to smear.
Playback still needs a resumed context. Offline rendering does not require a gesture in most browsers, but the context that plays the buffer does. Turning the buffer into a file the build can ship is covered in generating sounds in JavaScript.
function buildHealCue(ctx, destination, startTime) {
const master = ctx.createGain();
master.gain.value = 0.9;
master.connect(destination);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.Q.value = 0.7;
filter.frequency.setValueAtTime(900, startTime);
filter.frequency.linearRampToValueAtTime(4200, startTime + 0.12);
filter.frequency.exponentialRampToValueAtTime(1200, startTime + 0.55);
filter.connect(master);
const voices = [
{ ratio: 1.0, detune: -4, type: 'triangle', level: 0.5 },
{ ratio: 1.5, detune: 3, type: 'sine', level: 0.26 },
{ ratio: 2.0, detune: 6, type: 'sine', level: 0.14 }
];
voices.forEach(function (voice, index) {
const offset = index * 0.02;
const osc = ctx.createOscillator();
osc.type = voice.type;
osc.frequency.value = 440 * voice.ratio;
osc.detune.value = voice.detune;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, startTime + offset);
gain.gain.linearRampToValueAtTime(voice.level, startTime + offset + 0.008);
gain.gain.setValueAtTime(voice.level, startTime + 0.18);
gain.gain.exponentialRampToValueAtTime(0.0001, startTime + 0.55);
gain.gain.setValueAtTime(0, startTime + 0.56);
osc.connect(gain).connect(filter);
osc.start(startTime + offset);
osc.stop(startTime + 0.58);
osc.onended = function () {
osc.disconnect();
gain.disconnect();
};
});
}
If the cue is still silent, check in this order: is
ctx.state running, is the start time ahead of
currentTime, is a gain parked at zero, did an
exponential ramp target zero.
Hear the Cue You Just Built
Start with a short attack, a fifth and octave above the root, and a 370 ms exponential tail. Retrigger it three times in a second and listen for clicks at the stop.
Open the Heal Sound Generator →