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.