Advertisement
Game Audio

Generating a Heal Sound in the Browser with Web Audio API: Oscillator, Envelope, and Gain

A working heal cue in the browser with no files: three oscillators, one filter, and a gain envelope that does the healing. Includes the browser gotchas that break first attempts.

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 →
Advertisement