Most game sound effects are WAV files that you load, play, and forget. That works for static sounds, but it becomes a limitation when you need something that changes at runtime: a click whose pitch rises with each combo, a beep whose frequency tracks a player's health, or a footstep that adapts to movement speed without pre-rendering twenty variations.
Godot's AudioStreamGenerator solves this by letting you generate audio data directly in code and push it to the audio buffer in real time. Instead of loading a file, you compute each sample mathematically and feed it to the playback system [citation:1]. The result is a sound that can change every time it plays, using parameters that come from the game state rather than from the asset folder.
This article covers the mechanics of getting a working procedural sound in Godot. The design principles behind what makes a click, a beep, or an impact sound convincing are covered in How to Make Game Sound Effects, and this article assumes you already know the effect you want to generate.
How AudioStreamGenerator Works
AudioStreamGenerator is not a sound in itself. It is a
placeholder that expects a script to provide the actual
audio data [citation:2]. You attach it to an
AudioStreamPlayer node, and then you call
get_stream_playback() to get an
AudioStreamGeneratorPlayback object, which is what you
actually push frames to.
Each frame is a Vector2 containing the left and right
channel values. The values range from -1.0 to 1.0, and for
mono sounds you use the same value for both channels
[citation:6]. The playback object has a method called
push_frame() that accepts one of these frames
at a time.
The generator has two important properties:
mix_rate (the sample rate in Hz) and
buffer_length (how many seconds of audio the
buffer holds). The default mix rate is 44100 Hz, which is
CD quality, but Godot's documentation notes that if you are
using GDScript you should consider a lower rate like 22050
Hz to reduce CPU usage [citation:1]. For simple sound
effects like clicks and beeps, 22050 Hz is more than
enough.
Setting Up the Node Structure
The setup in the Godot editor is minimal. Add an AudioStreamPlayer node to your scene. In the Inspector, set its Stream property to a new AudioStreamGenerator resource. You can leave the mix rate at the default or change it to 22050 for lighter CPU load.
The script then needs to do three things: get the playback
object, call play() on the player, and start
filling the buffer. The order matters. The player must be
playing before you can get a valid playback reference.
var playback: AudioStreamGeneratorPlayback
@onready var sample_hz: float = $AudioStreamPlayer.stream.mix_rate
func _ready() -> void:
$AudioStreamPlayer.play()
playback = $AudioStreamPlayer.get_stream_playback()
fill_buffer()
The sample_hz variable holds the mix rate from
the generator resource, which you will need for the phase
calculations later.
The Buffer and Why It Matters
The audio buffer is a fixed-size queue of frames waiting to
be played. The buffer_length property controls
how many seconds of audio it can hold. A larger buffer
means the script has more time to generate frames before
the buffer runs empty, but it also introduces more latency
between when you push a frame and when it is actually
heard [citation:2].
The critical method is
get_frames_available(), which returns how many
frames you can push without overflowing the buffer. If you
push more than the buffer can hold, the excess is discarded.
If you do not push enough, the audio cracks or stutters
because the playback runs out of data [citation:6].
A common pattern is to call fill_buffer() every
frame in _process(), and inside it, loop
through get_frames_available() frames,
computing and pushing one sample each time. This keeps the
buffer topped up without overfilling it.
Generating a Sine Wave
The simplest procedural sound is a sine wave at a fixed frequency. This is the "hello world" of audio programming and establishes the pattern that everything else builds on.
func fill_buffer() -> void:
var frames_available: int = playback.get_frames_available()
var increment: float = pulse_hz / sample_hz
var phase: float = 0.0
for i in range(frames_available):
var sample: float = sin(phase * TAU)
playback.push_frame(Vector2(sample, sample))
phase = fmod(phase + increment, 1.0)
The math is straightforward. pulse_hz is the
frequency of the sound (440 Hz for the standard A note).
increment is how much the phase advances per
sample. The phase wraps around at 1.0 using
fmod, which prevents floating-point precision
from degrading over time.
A continuous sine wave at full volume is not useful as a sound effect on its own. It needs an envelope, which is what turns a mathematical tone into a click, a beep, or a blip.
Adding an Exponential Decay Envelope
A sound effect is a short burst of energy, not a sustained note. The standard way to shape this in code is with an exponential decay multiplier, where the amplitude starts at full and drops rapidly toward zero over the duration of the sound.
The formula for an exponential decay is
exp(-t * decay_rate), where
t is the time since the sound started and
decay_rate controls how fast it fades. A
higher decay rate produces a shorter, punchier sound. A
lower rate produces a longer tail.
func generate_click(frequency: float, duration: float, decay: float) -> PackedVector2Array:
var total_frames: int = int(sample_hz * duration)
var frames: PackedVector2Array = PackedVector2Array()
frames.resize(total_frames)
for i in range(total_frames):
var t: float = float(i) / sample_hz
var envelope: float = exp(-t * decay)
var value: float = sin(TAU * frequency * t) * envelope
frames[i] = Vector2(value, value)
return frames
This produces a pre-computed array of frames that you can push to the playback when the sound is triggered. For a click, a frequency around 1200 Hz with a decay rate around 50 and a duration of 0.025 seconds produces a sharp, punchy tick [citation:12].
The sin(TAU * frequency * t) formulation is
slightly different from the phase accumulator approach
above, but it produces the same result. The phase
accumulator is more efficient for continuous generation;
the time-based formula is clearer for pre-computing a fixed
buffer.
Pushing Frames in Response to Events
For sound effects triggered by game events, you do not want a continuous tone. You want to play a short burst when something happens. The pattern is to pre-generate the frame array once, then push it to the playback when the event fires.
The push_buffer() method accepts a
PackedVector2Array directly, which is more efficient than
pushing frames one at a time in a loop, especially from
C# or GDExtension code [citation:6]. In GDScript the
performance difference is less dramatic, but it is still
the cleaner approach for pre-computed sounds.
func play_click() -> void:
if playback.get_frames_available() < click_frames.size():
return # Buffer too full, skip or wait
playback.push_buffer(click_frames)
The check on get_frames_available() prevents
the buffer from overflowing if multiple sounds are triggered
in rapid succession. If the buffer does not have room for
the entire sound, you can either skip playing it or wait
until the next frame and try again.
What This Approach Is Good For
Procedural audio in Godot is not a replacement for pre-recorded sound effects. It is a tool for cases where the sound needs to change at runtime or where generating it on the fly is more practical than storing a file.
- UI clicks and beeps. Short tonal sounds that need to vary with context, like a menu click that changes pitch based on selection depth, or a notification whose tone depends on the event type.
- Rhythm and timing sounds. Metronomes, beat indicators, and timing cues where the pitch or accent changes with the beat [citation:18].
- Prototypes and game jams. When you need a sound immediately and do not want to search for a WAV file, generating a simple tone in code is faster.
- Parameter-driven effects. Sounds whose frequency, duration, or decay are determined by game state, such as a proximity warning whose pitch rises as danger gets closer.
For a sound that is always the same, a WAV file is simpler and more efficient. The value of procedural generation comes from variation and runtime control.
The Performance Tradeoff
Godot's documentation is explicit about the performance characteristics of AudioStreamGenerator: it is best used from C# or a compiled language via GDExtension, and if you use it from GDScript you should consider a lower mix rate like 11025 or 22050 Hz [citation:1].
The reason is that GDScript is slower at the kind of tight per-sample loops that audio generation requires. At 44100 Hz, you need to generate 44100 frames per second of audio, and each frame involves a sine calculation, an envelope calculation, and a push to the buffer. In GDScript, this can become a bottleneck if you have many procedural sounds playing at once.
The practical mitigations are straightforward. Use a lower mix rate for procedural sounds; 22050 Hz is adequate for simple clicks and beeps. Pre-compute frame arrays where possible instead of generating them in real time. Avoid running multiple procedural sounds simultaneously if you are on GDScript.
A Minimal Working Example
This script produces a short click every time the spacebar is pressed, using a pre-computed frame buffer.
extends AudioStreamPlayer
var playback: AudioStreamGeneratorPlayback
var click_frames: PackedVector2Array
func _ready() -> void:
var generator: AudioStreamGenerator = stream as AudioStreamGenerator
var sample_hz: float = generator.mix_rate
play()
playback = get_stream_playback()
var frequency: float = 1200.0
var duration: float = 0.025
var decay: float = 50.0
var total: int = int(sample_hz * duration)
click_frames.resize(total)
for i in range(total):
var t: float = float(i) / sample_hz
var value: float = sin(TAU * frequency * t) * exp(-t * decay)
click_frames[i] = Vector2(value, value)
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_accept"):
if playback.get_frames_available() >= click_frames.size():
playback.push_buffer(click_frames)
The node should be an AudioStreamPlayer with an AudioStreamGenerator set as its stream. Adjust the frequency, duration, and decay values to change the character of the click. A lower frequency and slower decay produces a wood block sound; a higher frequency and faster decay produces a sharper tick [citation:12].
Where to Go from Here
Once the basic sine-and-envelope pattern is working, the same structure extends to more complex sounds. Noise is generated by replacing the sine calculation with a random value, which produces the basis for explosions and impacts. Square and sawtooth waves are simple variations on the sine formula.
The sound design principles for those waveforms are covered in How to Make 8-Bit Sound Effects and the broader layering logic in How to Create Explosion Sound Effects Using White Noise and Filters. The Godot implementation is the same: compute the samples, apply an envelope, push them to the buffer.
If you want a starting point for the sound itself before writing the code, you can generate a click or beep in the browser and export it as a WAV file, then compare the waveform shape against what your code produces. The visual comparison makes it easier to see whether the envelope and frequency are in the right range.
Create a Sound Effect to Reference
Open the SfxMaker generator and create a short click or beep, then use the WAV file as a reference for your procedural implementation in Godot.
Open SfxMaker Generator →Common Mistakes
-
Forgetting to call
play()before getting the playback. Theget_stream_playback()method returns null if the player is not playing. -
Pushing frames faster than the buffer drains.
Always check
get_frames_available()before pushing. Pushing into a full buffer discards the excess frames. - Using too high a mix rate in GDScript. 44100 Hz is fine in C# but can cause audio dropouts in GDScript. Use 22050 Hz for procedural sounds in scripts [citation:1].
- Generating continuous sound when you want a one-shot. A procedural sound that plays continuously is a tone, not a sound effect. Use pre-computed buffers and push them on events.
-
Ignoring phase wrapping. If the phase
value grows without wrapping, floating-point precision
degrades and the sound distorts over time. Use
fmodto keep it in range.
What to Check Before Shipping
Play the sound in the actual game context. A click that sounds correct in isolation can feel too quiet during combat or too sharp after dozens of repetitions. The advantage of procedural generation is that you can adjust the parameters at runtime, so use that: test whether a small pitch shift with each repetition makes the sound feel more natural, or whether the decay should shorten when the game is moving fast.
Also check the CPU cost. If the game is dropping frames when the procedural sound is active, reduce the mix rate or pre-compute more of the buffer. The performance profile is different from playing a WAV file, and it is worth verifying on the target hardware before relying on it heavily.
Procedural audio in Godot is a capability, not a requirement. Use it where it solves a problem that a static file cannot, and use a WAV file everywhere else. The two approaches sit side by side in the same project without conflict.