add SkipMode enum (UseReverbTail / CrossFade / Jump)

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) <noreply@anthropic.com>
This commit is contained in:
Daniel Samson
2026-04-29 16:31:37 +01:00
co-authored by Claude Opus 4.7
parent e792572635
commit df3e73b32e
6 changed files with 116 additions and 12 deletions
+3 -1
View File
@@ -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
+16
View File
@@ -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
}
+14
View File
@@ -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();
+14
View File
@@ -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();
+14
View File
@@ -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();
+55 -11
View File
@@ -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);