Advertisement
Game Audio

Procedural Heal Sounds in Godot with AudioStreamGenerator (No Audio Files Needed)

Building a heal cue inside Godot with AudioStreamGenerator: buffer filling, partial detune, envelopes and retriggering, with no audio files in the project.

The heal cue works in the editor. You fire it three times with a gap between triggers and it sounds fine: three short chiming tails, no artifacts.

Then the build goes to a phone, the player grabs a pickup at a sprint, and the second trigger pops. A click, a hole where the sustain should be, and the tail of the first cue still resolving underneath. Nothing in the synthesis changed. The buffer ran dry across a frame spike and the audio thread padded the gap with silence.

That gap is the real problem with procedural cues in Godot, and it is not the waveform. It is keeping a queue full while the rest of the game spends your frame time.

AudioStreamGenerator Is a Buffer, Not a Synth

The resource produces nothing on its own. It is an empty queue with a clock. You set two properties, hand it to an AudioStreamPlayer, call play(), then keep writing frames into the AudioStreamGeneratorPlayback that get_stream_playback() returns. Stop writing and playback runs out of material.

mix_rate is the sample rate you intend to write. AudioServer.get_mix_rate() avoids a resample stage between your output and the mixer. 22050 halves the arithmetic, and this cue tops out near 1046 Hz, so nothing you generate lives above an 11 kHz Nyquist limit; you lose room for hiss and breath layers, not for the cue.

buffer_length is the latency, and the default 0.1 seconds is about 100 ms. That is acceptable for a pickup, since the sound is not tied to a button press. For anything the player expects to feel instant, 0.04 to 0.06 seconds works better. Shorter buffers need more frequent top-ups and forgive less frame jitter. Both properties must be set before play(); changing buffer_length on a playing stream does nothing.

Top Up Every Frame Using the Number the Playback Gives You

The first instinct is to push a fixed block per tick. A 60 fps frame at 44100 Hz is 735 frames, so 735 every _process feels correct until the frame times bend. One 30 ms hitch and you needed twice that. Derive the request from delta, add a frame of slack, and clamp it to what the playback will accept:

var want := int(delta * mix_rate) + 1
var frames := mini(want, playback.get_frames_available())

get_frames_available() is the only trustworthy measure of room left. When it sits at its maximum frame after frame, the stream has already starved and playback has been padding silence. That is the click in the opening scene: a step from whatever amplitude the last good frame held to zero and back, which the ear hears as a broadband tick no matter how smooth the waveform was.

When the call returns fewer frames than you asked for, you are behind, not ahead. Fill what is offered. A partial top-up is inaudible; an empty buffer is not.

Building the Cue: Partials, Detune, Envelope

Partials That Read as a Heal

Three or four sines on a major triad is enough. C5 at 523.25 Hz, E5 at 659.25 Hz, G5 at 783.99 Hz, and C6 at 1046.50 Hz as a thin top layer. Amplitudes of 1.0, 0.5, 0.35 and 0.2 peak near 2.05, so scale the voice by roughly 0.45 before it reaches the output; otherwise the master limiter does it with less control over the transients.

If you want it to shimmer rather than chime, the layering in a shimmer without samples drops onto the same four partials: it is a matter of detuning the top layer and letting it drift, not of adding oscillators.

Detune Per Trigger, Not Per Voice

Give every partial a small random offset each time the cue fires. Plus or minus 6 cents is plenty. Two triggers at identical frequencies and identical phase sum coherently, up to +6 dB, which is how a double pickup turns into a clipped pop instead of a louder chime. A few cents of spread turns that coherent sum into a slow beat.

The offsets have to be randomized per trigger, not fixed per voice. At 523 Hz, 6 cents is about 1.8 Hz, so over a 380 ms tail two cues drift less than one cycle apart. A fixed offset merely moves the coherent-sum problem to a different pair of triggers.

An Exponential Envelope Derived From the Mix Rate

Attack: a 6 ms linear ramp is 264 samples at 44100 Hz, long enough to avoid a hard edge and short enough that the transient survives. Decay: multiply the envelope by a constant each sample. At 44100 Hz, 0.9996 gives a time constant near 57 ms and puts the tail at about -58 dB around 380 ms. Reusing it at 22050 Hz doubles the tail, so derive it: exp(-1.0 / (tau * mix_rate)).

The fill loop stays short. voices is an array of small objects holding phase, freq, amp and detune:

func _fill(delta: float) -> void:
    var want := int(delta * mix_rate) + 1
    var frames := mini(want, playback.get_frames_available())
    buf.resize(frames)
    for i in frames:
        var s := 0.0
        for v in voices:
            v.phase = fmod(v.phase + TAU * v.freq * v.detune / mix_rate, TAU)
            s += sin(v.phase) * v.amp
        env *= decay
        var out := s * env * headroom
        buf[i] = Vector2(out, out)
    playback.push_buffer(buf)

The Vector2 duplicates the mono signal into both channels. For a positional cue, write a stereo buffer instead and leave panning to the AudioStreamPlayer2D or AudioStreamPlayer3D that owns the generator.

Ending the cue cleanly means letting the queue drain. When the envelope falls below about 1e-4, stop generating and wait one buffer length before calling player.stop(). Stopping on the frame the envelope crosses zero truncates whatever was still in the buffer, which is the same step discontinuity you were trying to avoid.

The Click Is a Step, and It Has Three Sources

Nearly every retrigger artifact in a generator-based cue comes from one of three places:

  • A starved buffer. The queue empties mid-waveform and playback substitutes silence, usually without warning.
  • An envelope cut mid-tail. Amplitude jumps to zero while the waveform is still at 0.2 or 0.3.
  • A retrigger that overwrites a sounding voice. The new cue writes over a waveform that was mid-cycle.

Phase handling decides whether the third case clicks. On a trigger from silence, reset every partial phase to 0 and set the envelope to 0 on the same sample. Carry the phase over instead and each trigger starts at a different point on the sine: the attack shape drifts, the level across the first 20 ms moves by a couple of dB, and two triggers land at arbitrary relative phase. That reads as inconsistency rather than a click, which makes it harder to find later.

What does click is resetting the phase while the previous voice is still above about 0.01 in amplitude; the waveform jumps from its current value to zero on that sample. If you want overlapping retriggers, run the old voice in a second slot, let it finish its envelope, and rely on the per-trigger detune to keep the two from summing coherently. Two slots cover a pickup.

Fixed values are also the base you would sweep to produce variants from one base sound: detune range, top-layer amplitude and decay constant change the character most, and they cost nothing at runtime.

Keeping the Per-Frame Cost Low Enough for a Phone

Four partials at 44100 Hz is 176,400 sine evaluations and 176,400 fmod calls per second, before the envelope multiply and the float conversion into the buffer. A desktop audio thread does not notice. A low-end Android device at 30 fps may, especially when the main thread is already near its budget and the audio callback competes for the same core.

The cheapest lever is the mix rate: 22050 Hz halves everything and costs nothing audible in a cue whose highest partial is 1046 Hz. Next is a 2048-sample sine table with linear interpolation, which replaces each sin() with an array read and two multiplies; at 523 Hz the error sits far under the mix noise floor. Third is dropping the C6 layer on lower quality tiers.

Two habits matter more than any of those. Reuse one PackedVector2Array across frames instead of allocating per tick, because 735 elements at 60 fps is 44,100 allocations a second. And write the block with push_buffer() rather than push_frame() in a loop; the per-call overhead is small once and not small 44,000 times.

Web exports add a wrinkle. The browser audio context often stays suspended until the first input event, so a generator started in _ready() may produce nothing audible until the player clicks. Gate the first trigger on the first input.

For the wider vocabulary around this cue, the companion piece on procedural sound effects in Godot covers noise layers, filters and the rest of the graph. Start with the buffer regardless. Three sines, a short envelope and a full queue will beat an elaborate graph that starves every other second.

Advertisement