Initial commit: dynamic music & FX players

Continuous music players (DynamicSoundPlayer, 2D, 3D) with intensity-blended
layers, playlist queue, Loop, fade in/out, pause/resume. One-shot SFX players
(DynamicSoundFXPlayer, 2D, 3D) for overlapping triggers. All variants share
RefCounted core classes via duck-typed property access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Samson
2026-04-29 14:46:48 +01:00
co-authored by Claude Opus 4.7
commit cb47d89377
28 changed files with 737 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
@icon("res://addons/gd-dynamic-audio/src/dynamic_sound_icon.png")
@tool
class_name DynamicSound
## The DynamicSound Holds onto all your music variants.
extends Resource
@export_category("Sound Files")
## This should be set to your least intense music file.
@export var Intensity1 : AudioStream;
## This should be set to your semi-intense music file.
@export var Intensity2 : AudioStream;
## This should be set to your most intense music file.
@export var Intensity3 : AudioStream;
## Set this to how long the sound should loop over itself. [br]
## This will make any songs with a Reverb Tail loop perfectly.
@export var ReverbTail : float;
+1
View File
@@ -0,0 +1 @@
uid://dfma4so3silpl
+13
View File
@@ -0,0 +1,13 @@
class_name DynamicSoundConstants
## Used intenally by the sound players
enum NextState {
None = 0,
StartedLoop = 1,
StartedDifferent = 2
}
enum PitchMode {
Constant = 0,
Random = 1
}
+1
View File
@@ -0,0 +1 @@
uid://b6uidt4yqrvqa
+30
View File
@@ -0,0 +1,30 @@
@tool
@icon("res://addons/gd-dynamic-sound/icon.svg")
class_name DynamicSoundFXPlayer
## A one-shot SFX player that supports overlapping plays.
##
## Each call to [method play_fx] triggers a fresh play of the assigned
## [DynamicSound], layering on top of any plays still in flight. Use this for
## events like collision impacts, footsteps, or weapon fires — situations where
## the same effect can legitimately overlap itself.
extends AudioStreamPlayer
## The [DynamicSound] to trigger. Each [method play_fx] call plays a fresh instance.
@export var Sound : DynamicSound;
## Volume mix between intensity layers. Only blended when all three layers are set.
@export_range(0, 1) var Intensity : float = 0;
var _core : DynamicSoundFXPlayerCore;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
stream = AudioStreamPolyphonic.new();
func _ready() -> void:
_core = DynamicSoundFXPlayerCore.new(self);
_core.ready();
## Trigger a one-shot of [member Sound]. Layers with any in-flight triggers.
func play_fx() -> void:
if _core: _core.play_fx();
+1
View File
@@ -0,0 +1 @@
uid://gt0jwyt1aexo
+29
View File
@@ -0,0 +1,29 @@
@tool
@icon("res://addons/gd-dynamic-sound/icon.svg")
class_name DynamicSoundFXPlayer2D
## 2D positional variant of [DynamicSoundFXPlayer].
##
## Each [method play_fx] call triggers a fresh play of the assigned
## [DynamicSound], layered with any plays still in flight, distance-attenuated
## from a 2D position relative to the listener.
extends AudioStreamPlayer2D
## The [DynamicSound] to trigger. Each [method play_fx] call plays a fresh instance.
@export var Sound : DynamicSound;
## Volume mix between intensity layers. Only blended when all three layers are set.
@export_range(0, 1) var Intensity : float = 0;
var _core : DynamicSoundFXPlayerCore;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
stream = AudioStreamPolyphonic.new();
func _ready() -> void:
_core = DynamicSoundFXPlayerCore.new(self);
_core.ready();
## Trigger a one-shot of [member Sound]. Layers with any in-flight triggers.
func play_fx() -> void:
if _core: _core.play_fx();
+1
View File
@@ -0,0 +1 @@
uid://cwd887g4jf2nu
+29
View File
@@ -0,0 +1,29 @@
@tool
@icon("res://addons/gd-dynamic-sound/icon.svg")
class_name DynamicSoundFXPlayer3D
## 3D positional variant of [DynamicSoundFXPlayer].
##
## Each [method play_fx] call triggers a fresh play of the assigned
## [DynamicSound], layered with any plays still in flight, fully spatialised
## with distance attenuation, directionality, and Doppler.
extends AudioStreamPlayer3D
## The [DynamicSound] to trigger. Each [method play_fx] call plays a fresh instance.
@export var Sound : DynamicSound;
## Volume mix between intensity layers. Only blended when all three layers are set.
@export_range(0, 1) var Intensity : float = 0;
var _core : DynamicSoundFXPlayerCore;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
stream = AudioStreamPolyphonic.new();
func _ready() -> void:
_core = DynamicSoundFXPlayerCore.new(self);
_core.ready();
## Trigger a one-shot of [member Sound]. Layers with any in-flight triggers.
func play_fx() -> void:
if _core: _core.play_fx();
+1
View File
@@ -0,0 +1 @@
uid://h131u8q1cumh
+36
View File
@@ -0,0 +1,36 @@
class_name DynamicSoundFXPlayerCore
## Shared logic for the one-shot [DynamicSoundFXPlayer] family.
##
## Owned by [DynamicSoundFXPlayer], [DynamicSoundFXPlayer2D], and [DynamicSoundFXPlayer3D].
## Operates on its owner via duck-typed property access — the owner must expose
## [code]Sound[/code], [code]Intensity[/code], [code]volume_db[/code], [code]play()[/code],
## and [code]get_stream_playback()[/code].
extends RefCounted
var _player;
var _playback : AudioStreamPlaybackPolyphonic;
func _init(player) -> void:
_player = player;
func ready() -> void:
if Engine.is_editor_hint():
return;
_player.play();
_playback = _player.get_stream_playback();
func play_fx() -> void:
var sound : DynamicSound = _player.Sound;
if sound == null or _playback == null:
return;
var layers : Array[AudioStream] = [];
if sound.Intensity1 != null: layers.append(sound.Intensity1);
if sound.Intensity2 != null: layers.append(sound.Intensity2);
if sound.Intensity3 != null: layers.append(sound.Intensity3);
var real_intensity : float = _player.Intensity if layers.size() == 3 else 0.0;
for i in layers.size():
var id : int = _playback.play_stream(layers[i]);
if id == AudioStreamPlaybackPolyphonic.INVALID_ID:
continue;
var vol_lin : float = max((.5 - abs(float(i) / 2 - real_intensity)) / .5, 0.0);
_playback.set_stream_volume(id, linear_to_db(db_to_linear(_player.volume_db) * vol_lin));
+1
View File
@@ -0,0 +1 @@
uid://dcli0jyeagogq
+79
View File
@@ -0,0 +1,79 @@
@tool
@icon("res://addons/gd-dynamic-sound/icon.svg")
class_name DynamicSoundPlayer
## Continuous music player with intensity-blended layers and seamless playlist transitions.
##
## Plays through a [DynamicSoundPlaylist] one [DynamicSound] at a time. Each
## [DynamicSound] has up to three intensity layers that play simultaneously and
## are mixed by the [member Intensity] property at runtime.
extends AudioStreamPlayer
## Songs to play in order. Mutated as songs finish: each finished song is
## popped, and re-appended at the back when [member Loop] is on.
@export var Playlist : DynamicSoundPlaylist;
## Runtime mix between the three intensity layers. [code]0[/code] = least intense, [code]1[/code] = most intense.
@export_range(0, 1) var Intensity : float = 0:
set(value):
Intensity = value;
if _core: _core.apply_stream_volumes(volume_db);
## When true, finished songs are re-appended to the back of the queue so the playlist cycles forever.
@export var Loop : bool;
## How playback pitch is selected.
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
set(value):
PitchMode = value;
notify_property_list_changed();
## Minimum pitch scale when [member PitchMode] is Random.
@export var RandomMinPitchScale : float = -0.5;
## Maximum pitch scale when [member PitchMode] is Random.
@export var RandomMaxPitchScale : float = 0.5;
var _core : DynamicSoundPlayerCore;
func _set(property: StringName, value: Variant) -> bool:
if property == &"volume_db":
if _core: _core.apply_stream_volumes(value);
return false;
return false;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
stream = AudioStreamPolyphonic.new();
func _validate_property(property: Dictionary) -> void:
if property.name in ["RandomMinPitchScale", "RandomMaxPitchScale"]:
if PitchMode != DynamicSoundConstants.PitchMode.Random:
property.usage &= ~PROPERTY_USAGE_EDITOR;
func _ready() -> void:
_core = DynamicSoundPlayerCore.new(self);
_core.ready();
func _process(delta: float) -> void:
if _core: _core.process(delta);
## Pause playback. Call [method resume] to continue.
func pause() -> void:
if _core: _core.pause();
## Resume playback after a [method pause].
func resume() -> void:
if _core: _core.resume();
## True if a song is currently playing (and not paused). Distinct from [method is_playing],
## which stays true between songs because the polyphonic playback session is still active.
func is_song_playing() -> bool:
return _core != null and _core.is_song_playing();
## Start playback (if needed) and fade volume up from silent over [param duration] seconds.
func fade_in(duration: float) -> void:
if _core: _core.fade_in(duration);
## Fade volume to silent over [param duration] seconds, then stop playback.
func fade_out(duration: float) -> void:
if _core: _core.fade_out(duration);
+1
View File
@@ -0,0 +1 @@
uid://dsy7nja77teiq
+77
View File
@@ -0,0 +1,77 @@
@tool
@icon("res://addons/gd-dynamic-sound/icon.svg")
class_name DynamicSoundPlayer2D
## 2D positional variant of [DynamicSoundPlayer].
##
## Same playlist, intensity, fade, and pause behaviour — distance-attenuated
## from a 2D position relative to the listener.
extends AudioStreamPlayer2D
## Songs to play in order. Mutated as songs finish: each finished song is
## popped, and re-appended at the back when [member Loop] is on.
@export var Playlist : DynamicSoundPlaylist;
## Runtime mix between the three intensity layers. [code]0[/code] = least intense, [code]1[/code] = most intense.
@export_range(0, 1) var Intensity : float = 0:
set(value):
Intensity = value;
if _core: _core.apply_stream_volumes(volume_db);
## When true, finished songs are re-appended to the back of the queue so the playlist cycles forever.
@export var Loop : bool;
## How playback pitch is selected.
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
set(value):
PitchMode = value;
notify_property_list_changed();
## Minimum pitch scale when [member PitchMode] is Random.
@export var RandomMinPitchScale : float = -0.5;
## Maximum pitch scale when [member PitchMode] is Random.
@export var RandomMaxPitchScale : float = 0.5;
var _core : DynamicSoundPlayerCore;
func _set(property: StringName, value: Variant) -> bool:
if property == &"volume_db":
if _core: _core.apply_stream_volumes(value);
return false;
return false;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
stream = AudioStreamPolyphonic.new();
func _validate_property(property: Dictionary) -> void:
if property.name in ["RandomMinPitchScale", "RandomMaxPitchScale"]:
if PitchMode != DynamicSoundConstants.PitchMode.Random:
property.usage &= ~PROPERTY_USAGE_EDITOR;
func _ready() -> void:
_core = DynamicSoundPlayerCore.new(self);
_core.ready();
func _process(delta: float) -> void:
if _core: _core.process(delta);
## Pause playback. Call [method resume] to continue.
func pause() -> void:
if _core: _core.pause();
## Resume playback after a [method pause].
func resume() -> void:
if _core: _core.resume();
## True if a song is currently playing (and not paused).
func is_song_playing() -> bool:
return _core != null and _core.is_song_playing();
## Start playback (if needed) and fade volume up from silent over [param duration] seconds.
func fade_in(duration: float) -> void:
if _core: _core.fade_in(duration);
## Fade volume to silent over [param duration] seconds, then stop playback.
func fade_out(duration: float) -> void:
if _core: _core.fade_out(duration);
+1
View File
@@ -0,0 +1 @@
uid://blrdw81nc8yjx
+77
View File
@@ -0,0 +1,77 @@
@tool
@icon("res://addons/gd-dynamic-sound/icon.svg")
class_name DynamicSoundPlayer3D
## 3D positional variant of [DynamicSoundPlayer].
##
## Same playlist, intensity, fade, and pause behaviour — fully spatialised with
## distance attenuation, directionality, and Doppler.
extends AudioStreamPlayer3D
## Songs to play in order. Mutated as songs finish: each finished song is
## popped, and re-appended at the back when [member Loop] is on.
@export var Playlist : DynamicSoundPlaylist;
## Runtime mix between the three intensity layers. [code]0[/code] = least intense, [code]1[/code] = most intense.
@export_range(0, 1) var Intensity : float = 0:
set(value):
Intensity = value;
if _core: _core.apply_stream_volumes(volume_db);
## When true, finished songs are re-appended to the back of the queue so the playlist cycles forever.
@export var Loop : bool;
## How playback pitch is selected.
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
set(value):
PitchMode = value;
notify_property_list_changed();
## Minimum pitch scale when [member PitchMode] is Random.
@export var RandomMinPitchScale : float = -0.5;
## Maximum pitch scale when [member PitchMode] is Random.
@export var RandomMaxPitchScale : float = 0.5;
var _core : DynamicSoundPlayerCore;
func _set(property: StringName, value: Variant) -> bool:
if property == &"volume_db":
if _core: _core.apply_stream_volumes(value);
return false;
return false;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
stream = AudioStreamPolyphonic.new();
func _validate_property(property: Dictionary) -> void:
if property.name in ["RandomMinPitchScale", "RandomMaxPitchScale"]:
if PitchMode != DynamicSoundConstants.PitchMode.Random:
property.usage &= ~PROPERTY_USAGE_EDITOR;
func _ready() -> void:
_core = DynamicSoundPlayerCore.new(self);
_core.ready();
func _process(delta: float) -> void:
if _core: _core.process(delta);
## Pause playback. Call [method resume] to continue.
func pause() -> void:
if _core: _core.pause();
## Resume playback after a [method pause].
func resume() -> void:
if _core: _core.resume();
## True if a song is currently playing (and not paused).
func is_song_playing() -> bool:
return _core != null and _core.is_song_playing();
## Start playback (if needed) and fade volume up from silent over [param duration] seconds.
func fade_in(duration: float) -> void:
if _core: _core.fade_in(duration);
## Fade volume to silent over [param duration] seconds, then stop playback.
func fade_out(duration: float) -> void:
if _core: _core.fade_out(duration);
+1
View File
@@ -0,0 +1 @@
uid://doxkfmr2k6nxa
+154
View File
@@ -0,0 +1,154 @@
class_name DynamicSoundPlayerCore
## Shared logic for the continuous [DynamicSoundPlayer] family.
##
## Owned by [DynamicSoundPlayer], [DynamicSoundPlayer2D], and [DynamicSoundPlayer3D].
## Operates on its owner via duck-typed property access — the owner must expose
## [code]Playlist[/code], [code]Intensity[/code], [code]Loop[/code], [code]volume_db[/code],
## [code]stream_paused[/code], [code]play()[/code], [code]stop()[/code], and
## [code]get_stream_playback()[/code].
extends RefCounted
const _FADE_SILENT_DB : float = -80.0;
var _player;
var Ids : Array[int];
var _playback : AudioStreamPlaybackPolyphonic;
var _song_length : float = 0.0;
var _song_elapsed : float = 0.0;
var _paused : bool = false;
var _fade_db : float = 0.0;
var _fade_from_db : float = 0.0;
var _fade_to_db : float = 0.0;
var _fade_duration : float = 0.0;
var _fade_elapsed : float = 0.0;
var _fading : bool = false;
var _stop_when_faded : bool = false;
func _init(player) -> void:
_player = player;
func ready() -> void:
if Engine.is_editor_hint():
return;
var playlist : DynamicSoundPlaylist = _player.Playlist;
if playlist == null or playlist.QueuedSongs.is_empty():
return;
_player.play();
_playback = _player.get_stream_playback();
_play_song(playlist.QueuedSongs[0]);
func process(delta: float) -> void:
if Engine.is_editor_hint():
return;
if _playback == null or _paused:
return;
if _fading:
_fade_elapsed += delta;
var t : float = clampf(_fade_elapsed / _fade_duration, 0.0, 1.0);
_fade_db = lerp(_fade_from_db, _fade_to_db, t);
apply_stream_volumes(_player.volume_db);
if t >= 1.0:
_fading = false;
if _stop_when_faded:
_stop_after_fade();
return;
if _song_length <= 0.0:
return;
_song_elapsed += delta;
if _song_elapsed >= _song_length:
_advance_song();
func apply_stream_volumes(value: float) -> void:
if _playback == null:
return;
var effective_db : float = value + _fade_db;
var realIntensity : float;
if Ids.size() == 3:
realIntensity = _player.Intensity;
else:
realIntensity = 0;
for i in Ids.size():
# set volume based on intensity; with a single layer it's just full volume
_playback.set_stream_volume(Ids[i], linear_to_db(db_to_linear(effective_db) * max((.5 - abs(float(i)/2 - realIntensity))/.5, 0)));
func pause() -> void:
if _playback == null or _paused:
return;
_player.stream_paused = true;
_paused = true;
func resume() -> void:
if not _paused:
return;
_player.stream_paused = false;
_paused = false;
func is_song_playing() -> bool:
return not Ids.is_empty() and not _paused;
func fade_in(duration: float) -> void:
if _playback == null:
_player.play();
_playback = _player.get_stream_playback();
if _paused:
resume();
var playlist : DynamicSoundPlaylist = _player.Playlist;
if Ids.is_empty() and playlist != null and not playlist.QueuedSongs.is_empty():
_play_song(playlist.QueuedSongs[0]);
_start_fade(_FADE_SILENT_DB, 0.0, duration, false);
func fade_out(duration: float) -> void:
if _playback == null:
return;
if _paused:
resume();
_start_fade(_fade_db, _FADE_SILENT_DB, duration, true);
func _start_fade(from_db: float, to_db: float, duration: float, stop_after: bool) -> void:
_fade_from_db = from_db;
_fade_to_db = to_db;
_fade_db = from_db;
_fade_duration = max(duration, 0.0001);
_fade_elapsed = 0.0;
_fading = true;
_stop_when_faded = stop_after;
apply_stream_volumes(_player.volume_db);
func _stop_after_fade() -> void:
for id in Ids:
_playback.stop_stream(id);
Ids.clear();
_song_length = 0.0;
_song_elapsed = 0.0;
_player.stop();
_playback = null;
_fade_db = 0.0;
_stop_when_faded = false;
func _advance_song() -> void:
for id in Ids:
_playback.stop_stream(id);
Ids.clear();
_song_length = 0.0;
_song_elapsed = 0.0;
var playlist : DynamicSoundPlaylist = _player.Playlist;
if playlist == null or playlist.QueuedSongs.is_empty():
return;
var finished : DynamicSound = playlist.QueuedSongs.pop_front();
if _player.Loop:
playlist.QueuedSongs.append(finished);
if playlist.QueuedSongs.is_empty():
return;
_play_song(playlist.QueuedSongs[0]);
func _play_song(song : DynamicSound) -> void:
if song.Intensity1 != null:
Ids.append(_playback.play_stream(song.Intensity1));
if song.Intensity2 != null:
Ids.append(_playback.play_stream(song.Intensity2));
if song.Intensity3 != null:
Ids.append(_playback.play_stream(song.Intensity3));
_song_length = song.Intensity1.get_length() if song.Intensity1 != null else 0.0;
_song_elapsed = 0.0;
apply_stream_volumes(_player.volume_db);
+1
View File
@@ -0,0 +1 @@
uid://dxxvkqg40xqt5
+8
View File
@@ -0,0 +1,8 @@
@icon("res://addons/gd-dynamic-audio/src/dynamic_sound_playlist_icon.png")
@tool
class_name DynamicSoundPlaylist
## The DynamicSound Holds onto all your music variants.
extends Resource
@export_category("Playlist")
@export var QueuedSongs : Array[DynamicSound];
+1
View File
@@ -0,0 +1 @@
uid://0xy3r2caqdec