From df3e73b32e03e4941f5951425c46325c987a8546 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:31:37 +0100 Subject: [PATCH] add SkipMode enum (UseReverbTail / CrossFade / Jump) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit advance_to_next_song / advance_to_track now branch on the new SkipMode export: - UseReverbTail (default): existing behavior. Accelerate the outgoing song so remaining time hits ReverbTail, both songs play during the overlap, outgoing stops naturally. - CrossFade: programmatic volume crossfade. Outgoing fades from current volume to silent over CrossFadeDuration seconds (then stops), incoming starts silent and fades up to full over the same duration. Independent of any global fade. - Jump: hard cut. Outgoing song's streams are stopped immediately and removed from the active list before the incoming starts. For CrossFade we needed per-song fade state (the global _fade_db can't drive two songs in opposite directions at once), so _ActiveSong gained fade_db / fade_from_db / fade_to_db / fade_duration / fade_elapsed / fading / stop_after_fade fields. _apply_volumes_to_song now adds both the global and per-song fade_db to volume_db; process() advances each song's fade per-frame and stops songs whose fades reach the silent target with stop_after_fade set. CrossFadeDuration is hidden in the inspector via _validate_property unless SkipMode == CrossFade. The natural end-of-song advance is unchanged — it still uses the song's reverb tail. SkipMode only affects programmatic skips. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 ++- src/DynamicSoundConstants.gd | 16 +++++++++ src/DynamicSoundPlayer.gd | 14 ++++++++ src/DynamicSoundPlayer2d.gd | 14 ++++++++ src/DynamicSoundPlayer3d.gd | 14 ++++++++ src/DynamicSoundPlayerCore.gd | 66 +++++++++++++++++++++++++++++------ 6 files changed, 116 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4261cd4..fc56adb 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ Plays through a playlist one song at a time, blending three intensity layers per | `Playlist` | `DynamicSoundPlaylist` | `null` | Songs to play in order. Read-only as songs play — the player tracks an internal index. | | `Intensity` | `float` (0–1) | `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. @@ -79,7 +81,7 @@ When the node enters the tree in the editor, `stream` is auto-assigned an `Audio | `fade_in(duration: float)` | Starts playback (if needed), unpauses, and ramps volume from silent to `volume_db` over `duration` seconds. Works regardless of `autoplay`. | | `fade_out(duration: float)` | Ramps volume from current level to silent over `duration` seconds, then stops the polyphonic session. A subsequent `fade_in` reinitialises. | | `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). Triggers a smooth transition through the current song's reverb tail — accelerates the outgoing song so its remaining time hits `ReverbTail` immediately, then starts the new song on top. With `ReverbTail = 0` this is effectively a hard cut. | +| `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. | #### Behaviour diff --git a/src/DynamicSoundConstants.gd b/src/DynamicSoundConstants.gd index 93fc6e3..1a57778 100644 --- a/src/DynamicSoundConstants.gd +++ b/src/DynamicSoundConstants.gd @@ -14,3 +14,19 @@ enum PitchMode { Constant = 0, Random = 1 } + +## How [DynamicSoundPlayer] handles a programmatic skip ([code]advance_to_next_song[/code] +## or [code]advance_to_track[/code]). +## +## [b]UseReverbTail[/b] — accelerate the outgoing song so its remaining time hits +## [code]ReverbTail[/code], then start the new song on top. Both songs play at full +## volume during the overlap; the outgoing tail naturally decays in the audio.[br] +## [b]CrossFade[/b] — explicitly fade the outgoing song down and the incoming +## song up in volume over [code]CrossFadeDuration[/code] seconds.[br] +## [b]Jump[/b] — hard cut. Outgoing song stops immediately, incoming starts at +## full volume. +enum SkipMode { + UseReverbTail = 0, + CrossFade = 1, + Jump = 2 +} diff --git a/src/DynamicSoundPlayer.gd b/src/DynamicSoundPlayer.gd index 780476e..0782140 100644 --- a/src/DynamicSoundPlayer.gd +++ b/src/DynamicSoundPlayer.gd @@ -24,6 +24,16 @@ extends AudioStreamPlayer ## through the playlist and wraps from the last song back to the first. @export var LoopMode : DynamicSoundConstants.LoopMode = DynamicSoundConstants.LoopMode.All; +## How [method advance_to_next_song] and [method advance_to_track] transition between songs. +## Does not affect the natural end-of-song advance, which always uses the song's reverb tail. +@export var SkipMode : DynamicSoundConstants.SkipMode = DynamicSoundConstants.SkipMode.UseReverbTail: + set(value): + SkipMode = value; + notify_property_list_changed(); + +## Crossfade duration in seconds when [member SkipMode] is [code]CrossFade[/code]. +@export var CrossFadeDuration : float = 1.0; + var _core : DynamicSoundPlayerCore; func _set(property: StringName, value: Variant) -> bool: @@ -40,6 +50,10 @@ func _enter_tree() -> void: if bus == &"Master" and AudioServer.get_bus_index("Music") != -1: bus = "Music"; +func _validate_property(property: Dictionary) -> void: + if property.name == "CrossFadeDuration" and SkipMode != DynamicSoundConstants.SkipMode.CrossFade: + property.usage &= ~PROPERTY_USAGE_EDITOR; + func _ready() -> void: _core = DynamicSoundPlayerCore.new(self); _core.ready(); diff --git a/src/DynamicSoundPlayer2d.gd b/src/DynamicSoundPlayer2d.gd index e1c86ca..669957e 100644 --- a/src/DynamicSoundPlayer2d.gd +++ b/src/DynamicSoundPlayer2d.gd @@ -23,6 +23,16 @@ extends AudioStreamPlayer2D ## through the playlist and wraps from the last song back to the first. @export var LoopMode : DynamicSoundConstants.LoopMode = DynamicSoundConstants.LoopMode.All; +## How [method advance_to_next_song] and [method advance_to_track] transition between songs. +## Does not affect the natural end-of-song advance, which always uses the song's reverb tail. +@export var SkipMode : DynamicSoundConstants.SkipMode = DynamicSoundConstants.SkipMode.UseReverbTail: + set(value): + SkipMode = value; + notify_property_list_changed(); + +## Crossfade duration in seconds when [member SkipMode] is [code]CrossFade[/code]. +@export var CrossFadeDuration : float = 1.0; + var _core : DynamicSoundPlayerCore; func _set(property: StringName, value: Variant) -> bool: @@ -39,6 +49,10 @@ func _enter_tree() -> void: if bus == &"Master" and AudioServer.get_bus_index("Music") != -1: bus = "Music"; +func _validate_property(property: Dictionary) -> void: + if property.name == "CrossFadeDuration" and SkipMode != DynamicSoundConstants.SkipMode.CrossFade: + property.usage &= ~PROPERTY_USAGE_EDITOR; + func _ready() -> void: _core = DynamicSoundPlayerCore.new(self); _core.ready(); diff --git a/src/DynamicSoundPlayer3d.gd b/src/DynamicSoundPlayer3d.gd index cf0e731..71ee8a2 100644 --- a/src/DynamicSoundPlayer3d.gd +++ b/src/DynamicSoundPlayer3d.gd @@ -23,6 +23,16 @@ extends AudioStreamPlayer3D ## through the playlist and wraps from the last song back to the first. @export var LoopMode : DynamicSoundConstants.LoopMode = DynamicSoundConstants.LoopMode.All; +## How [method advance_to_next_song] and [method advance_to_track] transition between songs. +## Does not affect the natural end-of-song advance, which always uses the song's reverb tail. +@export var SkipMode : DynamicSoundConstants.SkipMode = DynamicSoundConstants.SkipMode.UseReverbTail: + set(value): + SkipMode = value; + notify_property_list_changed(); + +## Crossfade duration in seconds when [member SkipMode] is [code]CrossFade[/code]. +@export var CrossFadeDuration : float = 1.0; + var _core : DynamicSoundPlayerCore; func _set(property: StringName, value: Variant) -> bool: @@ -39,6 +49,10 @@ func _enter_tree() -> void: if bus == &"Master" and AudioServer.get_bus_index("Music") != -1: bus = "Music"; +func _validate_property(property: Dictionary) -> void: + if property.name == "CrossFadeDuration" and SkipMode != DynamicSoundConstants.SkipMode.CrossFade: + property.usage &= ~PROPERTY_USAGE_EDITOR; + func _ready() -> void: _core = DynamicSoundPlayerCore.new(self); _core.ready(); diff --git a/src/DynamicSoundPlayerCore.gd b/src/DynamicSoundPlayerCore.gd index 1ce3f0d..d1759d0 100644 --- a/src/DynamicSoundPlayerCore.gd +++ b/src/DynamicSoundPlayerCore.gd @@ -17,13 +17,22 @@ extends RefCounted const _FADE_SILENT_DB : float = -80.0; ## Per-song playback record. One exists per song currently audible — usually one, -## briefly two during a reverb-tail crossover. +## briefly two during a reverb-tail crossover or a crossfade. class _ActiveSong extends RefCounted: var ids : Array[int]; var start_time : float; var song_length : float; var reverb_tail : float; var started_next : bool = false; + # Per-song fade state — independent of the player-wide fade. Used by + # CrossFade-mode skips so two songs can fade in opposite directions at once. + 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_after_fade : bool = false; var _player; @@ -68,6 +77,18 @@ func process(delta: float) -> void: return; # iterate over a snapshot since we may modify _active_songs in the loop for active in _active_songs.duplicate(): + # advance per-song fade + if active.fading: + active.fade_elapsed += delta; + var t : float = clampf(active.fade_elapsed / active.fade_duration, 0.0, 1.0); + active.fade_db = lerp(active.fade_from_db, active.fade_to_db, t); + _apply_volumes_to_song(active, _player.volume_db); + if t >= 1.0: + active.fading = false; + if active.stop_after_fade: + _stop_active_song(active); + _active_songs.erase(active); + continue; var remaining : float = (active.start_time + active.song_length) - _cur_time; if remaining < active.reverb_tail and not active.started_next: active.started_next = true; @@ -118,7 +139,7 @@ func advance_to_next_song() -> void: advance_to_track((_current_index + 1) % playlist.QueuedSongs.size()); ## Jump to the song at [param index]. Ignored if the index is out of range. -## Triggers a smooth transition through the current song's reverb tail. +## Transition style is controlled by [code]_player.SkipMode[/code]. func advance_to_track(index: int) -> void: var playlist : DynamicSoundPlaylist = _player.Playlist; if playlist == null or index < 0 or index >= playlist.QueuedSongs.size(): @@ -126,14 +147,28 @@ func advance_to_track(index: int) -> void: if _playback == null: _player.play(); _playback = _player.get_stream_playback(); - # accelerate the most-recent active song so its remaining time hits the - # reverb tail right now — the outgoing song's tail covers the new one's start + var mode : int = _player.SkipMode; + var outgoing : _ActiveSong = null; if not _active_songs.is_empty(): - var current_active : _ActiveSong = _active_songs[_active_songs.size() - 1]; - current_active.start_time = _cur_time - current_active.song_length + current_active.reverb_tail; - current_active.started_next = true; + outgoing = _active_songs[_active_songs.size() - 1]; + if outgoing != null: + outgoing.started_next = true; + match mode: + DynamicSoundConstants.SkipMode.UseReverbTail: + # accelerate so remaining hits reverb_tail now; the existing + # overlap logic in process() will stop the song after that + outgoing.start_time = _cur_time - outgoing.song_length + outgoing.reverb_tail; + DynamicSoundConstants.SkipMode.CrossFade: + _start_song_fade(outgoing, outgoing.fade_db, _FADE_SILENT_DB, _player.CrossFadeDuration, true); + DynamicSoundConstants.SkipMode.Jump: + _stop_active_song(outgoing); + _active_songs.erase(outgoing); _current_index = index; _start_song_at_index(index); + if mode == DynamicSoundConstants.SkipMode.CrossFade and not _active_songs.is_empty(): + var incoming : _ActiveSong = _active_songs[_active_songs.size() - 1]; + _start_song_fade(incoming, _FADE_SILENT_DB, 0.0, _player.CrossFadeDuration, false); + _apply_volumes_to_song(incoming, _player.volume_db); func fade_in(duration: float) -> void: _start_playback(); @@ -151,9 +186,8 @@ func fade_out(duration: float) -> void: func apply_stream_volumes(value: float) -> void: if _playback == null: return; - var effective_db : float = value + _fade_db; for active in _active_songs: - _apply_volumes_to_song(active, effective_db); + _apply_volumes_to_song(active, value); func _start_playback() -> void: var playlist : DynamicSoundPlaylist = _player.Playlist; @@ -193,13 +227,14 @@ func _start_song_at_index(index: int) -> void: if song.Intensity3 != null: active.ids.append(_playback.play_stream(song.Intensity3)); _active_songs.append(active); - _apply_volumes_to_song(active, _player.volume_db + _fade_db); + _apply_volumes_to_song(active, _player.volume_db); func _stop_active_song(active: _ActiveSong) -> void: for id in active.ids: _playback.stop_stream(id); -func _apply_volumes_to_song(active: _ActiveSong, effective_db: float) -> void: +func _apply_volumes_to_song(active: _ActiveSong, volume_db_value: float) -> void: + var effective_db : float = volume_db_value + _fade_db + active.fade_db; var realIntensity : float; if active.ids.size() == 3: realIntensity = _player.Intensity; @@ -219,6 +254,15 @@ func _start_fade(from_db: float, to_db: float, duration: float, stop_after: bool _stop_when_faded = stop_after; apply_stream_volumes(_player.volume_db); +func _start_song_fade(song: _ActiveSong, from_db: float, to_db: float, duration: float, stop_after: bool) -> void: + song.fade_from_db = from_db; + song.fade_to_db = to_db; + song.fade_db = from_db; + song.fade_duration = max(duration, 0.0001); + song.fade_elapsed = 0.0; + song.fading = true; + song.stop_after_fade = stop_after; + func _stop_after_fade() -> void: for active in _active_songs: _stop_active_song(active);