Advertisement
Game Audio

How to Generate Procedural Sound Effects in Godot with Code

A practical introduction to generating procedural sound effects in Godot using AudioStreamGenerator, including sine waves, exponential decay envelopes, and pushing audio frames to the playback buffer.

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. The get_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 fmod to 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.

Advertisement