Go to file
Daniel Samson 7bcc12b45b
make fade_in/fade_out idempotent — repeat calls toward the same target are no-ops
Without this, scripts that re-fire fade_in on every Area2D body_entered (or
similar repeating signal) restart the fade clock each call, which can stutter
audibly when a per-song crossfade is also running. Now a fade_in while already
fading to the user's set level — or fade_out while already fading to silent —
is a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:32:06 +01:00
src make fade_in/fade_out idempotent — repeat calls toward the same target are no-ops 2026-04-29 20:32:06 +01:00
LICENSE add MIT license 2026-04-29 16:33:33 +01:00
README.md make fade_in/fade_out idempotent — repeat calls toward the same target are no-ops 2026-04-29 20:32:06 +01:00
default_bus_layout.tres ship a sample bus layout with Music and FX 2026-04-29 15:09:10 +01:00
gd-dynamic-sound-icon.jpg add logo and proper icon, update @icon refs 2026-04-29 17:07:36 +01:00
gd-dynamic-sound-icon.jpg.import add logo and proper icon, update @icon refs 2026-04-29 17:07:36 +01:00
icon.svg Initial commit: dynamic music & FX players 2026-04-29 14:46:48 +01:00
icon.svg.import Initial commit: dynamic music & FX players 2026-04-29 14:46:48 +01:00
logo.jpg add logo and proper icon, update @icon refs 2026-04-29 17:07:36 +01:00
logo.jpg.import add logo and proper icon, update @icon refs 2026-04-29 17:07:36 +01:00
plugin.cfg Initial commit: dynamic music & FX players 2026-04-29 14:46:48 +01:00
plugin.gd gate plugin on Godot 4.6+; drop stale gd-options autoload registrations 2026-04-29 16:45:43 +01:00
plugin.gd.uid Initial commit: dynamic music & FX players 2026-04-29 14:46:48 +01:00

README.md

gd-dynamic-sound

gd-dynamic-sound

A Godot 4 audio addon for dynamic music (intensity-layered, seamless playlist transitions, fade in/out) and overlapping one-shot SFX — both built on AudioStreamPolyphonic.

Requires Godot 4.6 or newer. Older versions will load the addon but log an error from plugin.gd on enable; some class_names may also fail to parse on engines lacking 4.6 GDScript features.

Installation

  1. Copy the addons/gd-dynamic-sound/ folder into your project's addons/ directory.
  2. Enable the plugin in Project Settings → Plugins.
  3. (Optional, recommended) Wire up the Music and FX buses — see below.

Audio buses

When a player enters the tree in the editor, it auto-routes to a named bus if one exists:

  • DynamicSoundPlayer family → looks for a bus named Music
  • DynamicSoundFXPlayer family → looks for a bus named FX

If the named bus doesn't exist, the player stays on Master. User-customised buses are never overridden — the auto-route only fires when bus is still the default Master.

A ready-made layout containing both buses (each routing into Master) ships with the addon at addons/gd-dynamic-sound/default_bus_layout.tres. To use it:

  • Whole-project replacement: copy it to your project root as default_bus_layout.tres, or point Godot at the addon's copy via Project Settings → General → Audio → Buses → Default Bus Layout.
  • Merge into an existing layout: open Godot's audio bus panel and add Music and FX buses manually, both sending to Master.

Concepts

DynamicSound (Resource)

A single piece of audio with up to three intensity layers that play simultaneously and are blended at runtime.

All assigned layers must have identical lengths. The three streams play in parallel; if Intensity2 ends a beat earlier than Intensity1, the blend becomes silence on that layer for the remainder of the song and the player's end-of-song timing (which keys off Intensity1.get_length()) misfires. The resource pushes a warning in the editor when you assign mismatched-length streams, and DynamicSoundPlayer pushes a runtime error when it tries to play one. Trim or pad your stems to match before exporting.

Property Type Description
Intensity1 AudioStream Least-intense layer (e.g. ambient). Required — the player uses its length to time song advancement.
Intensity2 AudioStream Mid-intensity layer. Optional. Must match Intensity1's length if assigned.
Intensity3 AudioStream Most-intense layer. Optional. Must match Intensity1's length if assigned.
ReverbTail float Length (seconds) of the song's audio that is pure reverb tail. The next song is started this far ahead of the current song's full length, so the new opening covers the outgoing tail without a perceptible gap. Set to 0 to disable overlap (hard cut).

The resource exposes has_matching_layer_lengths() -> bool if you want to validate songs yourself (e.g. on load, in tests).

DynamicSoundPlaylist (Resource)

An ordered list of DynamicSounds.

Property Type Description
QueuedSongs Array[DynamicSound] Songs to play in order. The continuous player tracks an internal index; the resource is not mutated as songs play.

Nodes

Two node families, each with three positional variants:

Family Non-positional 2D 3D
Continuous music DynamicSoundPlayer DynamicSoundPlayer2D DynamicSoundPlayer3D
One-shot SFX DynamicSoundFXPlayer DynamicSoundFXPlayer2D DynamicSoundFXPlayer3D

The 2D variants apply distance attenuation from a 2D position. The 3D variants are fully spatialised — distance, directionality, and Doppler.

DynamicSoundPlayer family — continuous music

Plays through a playlist one song at a time, blending three intensity layers per song.

Properties

Property Type Default Description
Playlist DynamicSoundPlaylist null Songs to play in order. Read-only as songs play — the player tracks an internal index.
Intensity float (01) 0 Runtime mix between the three layers. 0 = layer 1 dominates, 1 = layer 3 dominates.
LoopMode enum All Single keeps repeating the current song. All advances through the playlist and wraps back to index 0 after the last song. Both modes loop forever; there's no "play once and stop" mode.
SkipMode enum UseReverbTail How advance_to_next_song() / advance_to_track() transition. UseReverbTail accelerates the outgoing song so its remaining time hits ReverbTail and the new song plays on top — relies on the song's audio having a natural tail. CrossFade programmatically fades the outgoing song down and the incoming song up over CrossFadeDuration seconds. Jump is a hard cut. Does not affect natural end-of-song advancement (that always uses reverb tail).
CrossFadeDuration float 1.0 Duration of the volume crossfade in seconds when SkipMode = CrossFade. Hidden in the inspector for the other modes.

When the node enters the tree in the editor, stream is auto-assigned an AudioStreamPolyphonic and bus is set to "Music" if a bus by that name exists in the project (otherwise it's left at "Master"). User-customised buses are not overridden.

Methods

Method Description
play_playlist() Starts (or resumes) the playlist. Use this when autoplay is false and you want to start playback programmatically.
pause() Pauses playback. No-op if not playing or already paused.
resume() Resumes playback after a pause.
is_song_playing() -> bool True iff a song's layers are active and not paused. Distinct from is_playing(), which stays true between songs because the polyphonic playback session itself is still live.
fade_in(duration: float) Animates volume_db up to the user's set level over duration seconds. Pure volume operation — does not start playback, unpause, or otherwise change playing state. The "set level" is the last value written to volume_db outside of an active fade, so fade_infade_outfade_in round-trips back to the original volume. If any fade is in flight (player-wide fade_out or per-song crossfade from SkipMode = CrossFade), the ramp continues from the current volume_db so audible state is preserved. Otherwise it snaps to silent and ramps up. Idempotent — calling it while already fading to the same target is a no-op (won't restart the fade clock). Pair with play_playlist() / resume() if the player isn't already playing.
fade_out(duration: float) Animates volume_db from its current value down to silent over duration seconds. Pure volume operation — does not stop the polyphonic session or pause. Call pause() / stop() yourself afterwards if you want playback to halt. Idempotent — calling it while already fading to silent is a no-op.
get_current_song_index() -> int Index of the song most recently kicked off in Playlist.QueuedSongs. During a reverb-tail crossover this becomes the incoming song the moment it starts, even though the outgoing song is still audible.
advance_to_next_song() Skip to the next song in the playlist (wraps from the end back to index 0). Transition style is controlled by SkipMode.
advance_to_track(index: int) Same as advance_to_next_song, but jumps to a specific index. Out-of-range indices are ignored. No-op when called with the index of the song already playing — won't restart it.

Behaviour

  • Autoplay. On _ready, plays the song at the current index only if autoplay is true (the inherited AudioStreamPlayer.autoplay property; default true). Set autoplay = false to defer playback to a play_playlist() or fade_in() call.
  • Reverb-tail transitions. When the current song is within ReverbTail seconds of its full length, the next song (chosen per LoopMode) is started so its leading audio covers the outgoing tail. Both songs are audible during the overlap window; the outgoing one stops when its full length has elapsed.
  • Volume. fade_in and fade_out animate volume_db directly — the property visibly changes each frame during a fade. Setting volume_db mid-fade will be overwritten on the next frame; let the fade complete (or stop it by triggering another fade) before nudging the property manually. During a reverb-tail overlap, both songs share the same volume/intensity mix.
  • Editor. All scripts are @tool, but _ready and _process early-return in the editor so audio doesn't play in the inspector.

DynamicSoundFXPlayer family — overlapping one-shot SFX

Each play_fx() call fires a fresh trigger of the assigned DynamicSound. Triggers overlap freely — the underlying AudioStreamPolyphonic cleans up each trigger when its audio ends.

Properties

Property Type Default Description
Sound DynamicSound null The sound to trigger. Layers with intensity blending if all three are set.
Intensity float (01) 0 Volume mix between layers. Only blended when all three layers are present; otherwise the first non-null layer plays at full volume.
PitchMode enum Constant Constant plays each trigger at pitch 1.0. Random picks a fresh pitch per trigger via 1.0 + randf_range(RandomMinPitchScale, RandomMaxPitchScale).
RandomMinPitchScale float -0.5 Minimum pitch offset (added to 1.0) when PitchMode = Random. Hidden in the inspector when PitchMode = Constant.
RandomMaxPitchScale float 0.5 Maximum pitch offset (added to 1.0) when PitchMode = Random. Hidden in the inspector when PitchMode = Constant.

When the node enters the tree in the editor, stream is auto-assigned an AudioStreamPolyphonic and bus is set to "FX" if a bus by that name exists in the project (otherwise it's left at "Master"). User-customised buses are not overridden.

For a constant non-1.0 pitch, set the player's built-in pitch_scale property — that multiplies with whatever PitchMode produces, so e.g. pitch_scale = 0.8 + PitchMode = Random with default range gives an effective pitch in [0.4, 1.2].

Methods

Method Description
play_fx() Triggers a fresh play of Sound. Pitch is sampled at trigger time per PitchMode. Volumes are computed at trigger time and locked in — later changes to Intensity or volume_db don't affect already-playing triggers.

Example: collision SFX

@onready var fire_fx : DynamicSoundFXPlayer2D = $FireFX

func _on_body_entered(body) -> void:
    fire_fx.play_fx()

Architecture

The six player classes are thin wrappers (~70 lines each) around two shared RefCounted cores:

  • DynamicSoundPlayerCore — continuous-music logic (advancement, fades, pause, intensity blending).
  • DynamicSoundFXPlayerCore — one-shot trigger logic.

Each wrapper declares its exports, sets up a core in _ready, and forwards public methods. The cores access their owner via duck-typed property lookups (_player.Playlist, _player.volume_db, etc.), which is what lets the same core drive non-positional, 2D, and 3D wrappers without inheritance.

Honourable mentions

This addon was heavily inspired by the Ovani Plugin 1.4. Please consider supporting OvaniSound — these players are designed to drive their dynamic sounds.