implement reverb tail, LoopMode, and playlist navigation

DynamicSound.ReverbTail now drives a seamless transition: when the current
song is within ReverbTail seconds of its full length, the next song is started
so its opening covers the outgoing tail. Both songs are audible during the
overlap; the outgoing one stops when its full length has elapsed.

API changes (continuous players only):
- Replaces `Loop : bool` with `LoopMode : enum { Single, All }`. Single keeps
  repeating the current song; All advances through the playlist and wraps
  back to index 0. There is no longer a "play once and stop" mode.
- The playlist resource is no longer mutated. The player tracks an internal
  index instead of popping songs off the front.
- Adds `get_current_song_index() -> int`, `advance_to_next_song()`, and
  `advance_to_track(index: int)` on each continuous wrapper. Both advance
  methods accelerate the outgoing song so its remaining time hits ReverbTail
  immediately, giving a smooth transition rather than a hard cut.

Internals: DynamicSoundPlayerCore now keeps an `_active_songs` array of an
inner _ActiveSong record (ids, start_time, song_length, reverb_tail,
started_next). _process advances time globally and runs the start-next /
stop-old conditions per active song, mirroring the OvaniPlayer reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Samson 2026-04-29 16:07:06 +01:00
parent e8224e3913
commit c989fc4eea
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
6 changed files with 204 additions and 90 deletions

View File

@ -33,7 +33,7 @@ A single piece of audio with up to three intensity layers that play simultaneous
| `Intensity1` | `AudioStream` | Least-intense layer (e.g. ambient). Required for length tracking. |
| `Intensity2` | `AudioStream` | Mid-intensity layer. Optional. |
| `Intensity3` | `AudioStream` | Most-intense layer. Optional. |
| `ReverbTail` | `float` | Length (seconds) of the song's reverb tail, used for seamless looping. |
| `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). |
### `DynamicSoundPlaylist` (Resource)
@ -41,7 +41,7 @@ An ordered list of `DynamicSound`s.
| Property | Type | Description |
| --- | --- | --- |
| `QueuedSongs` | `Array[DynamicSound]` | Songs to play in order. The continuous player pops songs off the front as each finishes. |
| `QueuedSongs` | `Array[DynamicSound]` | Songs to play in order. The continuous player tracks an internal index; the resource is not mutated as songs play. |
## Nodes
@ -62,9 +62,9 @@ Plays through a playlist one song at a time, blending three intensity layers per
| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `Playlist` | `DynamicSoundPlaylist` | `null` | Songs to play in order. Mutated as songs finish. |
| `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. |
| `Loop` | `bool` | `false` | When true, finished songs are re-queued at the back so the playlist cycles forever. |
| `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. |
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.
@ -78,12 +78,15 @@ When the node enters the tree in the editor, `stream` is auto-assigned an `Audio
| `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)` | 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_track(index: int)` | Same as `advance_to_next_song`, but jumps to a specific index. Out-of-range indices are ignored. |
#### Behaviour
- **Autoplay.** On `_ready`, plays the front song of the playlist *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.
- **Advancement.** When a song's `Intensity1` length elapses, the player pops it from the queue and plays the next. With `Loop = true`, the popped song is re-appended to the back.
- **Volume.** `volume_db` is honoured; the fade adjustment is layered on top internally, so manual volume changes during a fade still work correctly.
- **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.** `volume_db` is honoured; the fade adjustment is layered on top internally, so manual volume changes during a fade still work correctly. 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

View File

@ -1,10 +1,13 @@
class_name DynamicSoundConstants
## Used intenally by the sound players
enum NextState {
None = 0,
StartedLoop = 1,
StartedDifferent = 2
## How the [DynamicSoundPlayer] family loops the playlist.
##
## [b]Single[/b] — keep playing the current song; never advances.[br]
## [b]All[/b] — advance through the playlist, wrapping back to the first song
## after the last finishes.
enum LoopMode {
Single = 0,
All = 1
}
enum PitchMode {

View File

@ -19,8 +19,10 @@ extends AudioStreamPlayer
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 the playlist loops when the current song ends.
## [code]Single[/code] keeps playing the same song; [code]All[/code] advances
## through the playlist and wraps from the last song back to the first.
@export var LoopMode : DynamicSoundConstants.LoopMode = DynamicSoundConstants.LoopMode.All;
var _core : DynamicSoundPlayerCore;
@ -49,6 +51,18 @@ func _process(delta: float) -> void:
func play_playlist() -> void:
if _core: _core.play_playlist();
## Index of the song most recently started in the playlist.
func get_current_song_index() -> int:
return _core.get_current_song_index() if _core else 0;
## Skip to the next song (wraps to index 0). Smooth via the current song's reverb tail.
func advance_to_next_song() -> void:
if _core: _core.advance_to_next_song();
## Jump to the song at [param index]. Smooth via the current song's reverb tail.
func advance_to_track(index: int) -> void:
if _core: _core.advance_to_track(index);
## Pause playback. Call [method resume] to continue.
func pause() -> void:
if _core: _core.pause();

View File

@ -18,8 +18,10 @@ extends AudioStreamPlayer2D
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 the playlist loops when the current song ends.
## [code]Single[/code] keeps playing the same song; [code]All[/code] advances
## through the playlist and wraps from the last song back to the first.
@export var LoopMode : DynamicSoundConstants.LoopMode = DynamicSoundConstants.LoopMode.All;
var _core : DynamicSoundPlayerCore;
@ -48,6 +50,18 @@ func _process(delta: float) -> void:
func play_playlist() -> void:
if _core: _core.play_playlist();
## Index of the song most recently started in the playlist.
func get_current_song_index() -> int:
return _core.get_current_song_index() if _core else 0;
## Skip to the next song (wraps to index 0). Smooth via the current song's reverb tail.
func advance_to_next_song() -> void:
if _core: _core.advance_to_next_song();
## Jump to the song at [param index]. Smooth via the current song's reverb tail.
func advance_to_track(index: int) -> void:
if _core: _core.advance_to_track(index);
## Pause playback. Call [method resume] to continue.
func pause() -> void:
if _core: _core.pause();

View File

@ -18,8 +18,10 @@ extends AudioStreamPlayer3D
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 the playlist loops when the current song ends.
## [code]Single[/code] keeps playing the same song; [code]All[/code] advances
## through the playlist and wraps from the last song back to the first.
@export var LoopMode : DynamicSoundConstants.LoopMode = DynamicSoundConstants.LoopMode.All;
var _core : DynamicSoundPlayerCore;
@ -48,6 +50,18 @@ func _process(delta: float) -> void:
func play_playlist() -> void:
if _core: _core.play_playlist();
## Index of the song most recently started in the playlist.
func get_current_song_index() -> int:
return _core.get_current_song_index() if _core else 0;
## Skip to the next song (wraps to index 0). Smooth via the current song's reverb tail.
func advance_to_next_song() -> void:
if _core: _core.advance_to_next_song();
## Jump to the song at [param index]. Smooth via the current song's reverb tail.
func advance_to_track(index: int) -> void:
if _core: _core.advance_to_track(index);
## Pause playback. Call [method resume] to continue.
func pause() -> void:
if _core: _core.pause();

View File

@ -3,19 +3,34 @@ class_name DynamicSoundPlayerCore
##
## 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].
## [code]Playlist[/code], [code]Intensity[/code], [code]LoopMode[/code], [code]autoplay[/code],
## [code]volume_db[/code], [code]stream_paused[/code], [code]play()[/code], [code]stop()[/code],
## and [code]get_stream_playback()[/code].
##
## Supports seamless reverb-tail transitions: when the current song is within
## its [code]ReverbTail[/code] window of ending, the next song is started so its
## opening covers the outgoing song's tail. During the overlap, both songs play
## together; the outgoing one stops once it has played for [code]song_length[/code]
## seconds total.
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.
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;
var _player;
var Ids : Array[int];
var _active_songs : Array[_ActiveSong] = [];
var _playback : AudioStreamPlaybackPolyphonic;
var _song_length : float = 0.0;
var _song_elapsed : float = 0.0;
var _cur_time : float = 0.0;
var _current_index : int = 0;
var _paused : bool = false;
var _fade_db : float = 0.0;
var _fade_from_db : float = 0.0;
@ -35,28 +50,12 @@ func ready() -> void:
return;
_start_playback();
## Starts (or resumes) playlist playback. Useful when [code]autoplay[/code] is false.
func play_playlist() -> void:
if _paused:
resume();
return;
_start_playback();
func _start_playback() -> void:
var playlist : DynamicSoundPlaylist = _player.Playlist;
if playlist == null or playlist.QueuedSongs.is_empty():
return;
if _playback == null:
_player.play();
_playback = _player.get_stream_playback();
if Ids.is_empty():
_play_song(playlist.QueuedSongs[0]);
func process(delta: float) -> void:
if Engine.is_editor_hint():
return;
if _playback == null or _paused:
return;
_cur_time += delta;
if _fading:
_fade_elapsed += delta;
var t : float = clampf(_fade_elapsed / _fade_duration, 0.0, 1.0);
@ -67,39 +66,74 @@ func process(delta: float) -> void:
if _stop_when_faded:
_stop_after_fade();
return;
if _song_length <= 0.0:
return;
_song_elapsed += delta;
if _song_elapsed >= _song_length:
_advance_song();
# iterate over a snapshot since we may modify _active_songs in the loop
for active in _active_songs.duplicate():
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;
var next_index : int = _resolve_next_index();
_current_index = next_index;
_start_song_at_index(next_index);
if remaining < 0:
_stop_active_song(active);
_active_songs.erase(active);
func apply_stream_volumes(value: float) -> void:
if _playback == null:
## Starts (or resumes) playlist playback. Useful when [code]autoplay[/code] is false.
func play_playlist() -> void:
if _paused:
resume();
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)));
_start_playback();
## Pauses every active song. Call [method resume] to continue.
func pause() -> void:
if _playback == null or _paused:
return;
_player.stream_paused = true;
_paused = true;
## Resume after [method pause].
func resume() -> void:
if not _paused:
return;
_player.stream_paused = false;
_paused = false;
## True if any song is currently audible (and the player isn't paused).
func is_song_playing() -> bool:
return not Ids.is_empty() and not _paused;
return not _active_songs.is_empty() and not _paused;
## Index in the playlist of the song most recently started. During a reverb-tail
## crossover this becomes the incoming song the moment it kicks off, even though
## the outgoing one is still audible.
func get_current_song_index() -> int:
return _current_index;
## Skip to the next song in the playlist (wraps to index 0 from the end).
## Triggers a smooth transition through the current song's reverb tail.
func advance_to_next_song() -> void:
var playlist : DynamicSoundPlaylist = _player.Playlist;
if playlist == null or playlist.QueuedSongs.is_empty():
return;
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.
func advance_to_track(index: int) -> void:
var playlist : DynamicSoundPlaylist = _player.Playlist;
if playlist == null or index < 0 or index >= playlist.QueuedSongs.size():
return;
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
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;
_current_index = index;
_start_song_at_index(index);
func fade_in(duration: float) -> void:
_start_playback();
@ -114,6 +148,67 @@ func fade_out(duration: float) -> void:
resume();
_start_fade(_fade_db, _FADE_SILENT_DB, duration, true);
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);
func _start_playback() -> void:
var playlist : DynamicSoundPlaylist = _player.Playlist;
if playlist == null or playlist.QueuedSongs.is_empty():
return;
if _playback == null:
_player.play();
_playback = _player.get_stream_playback();
if _active_songs.is_empty():
if _current_index < 0 or _current_index >= playlist.QueuedSongs.size():
_current_index = 0;
_start_song_at_index(_current_index);
func _resolve_next_index() -> int:
var playlist : DynamicSoundPlaylist = _player.Playlist;
if playlist == null or playlist.QueuedSongs.is_empty():
return 0;
if _player.LoopMode == DynamicSoundConstants.LoopMode.Single:
return _current_index;
return (_current_index + 1) % playlist.QueuedSongs.size();
func _start_song_at_index(index: int) -> void:
var playlist : DynamicSoundPlaylist = _player.Playlist;
if playlist == null or index < 0 or index >= playlist.QueuedSongs.size():
return;
var song : DynamicSound = playlist.QueuedSongs[index];
if song == null:
return;
var active : _ActiveSong = _ActiveSong.new();
active.start_time = _cur_time;
active.song_length = song.Intensity1.get_length() if song.Intensity1 != null else 0.0;
active.reverb_tail = song.ReverbTail;
if song.Intensity1 != null:
active.ids.append(_playback.play_stream(song.Intensity1));
if song.Intensity2 != null:
active.ids.append(_playback.play_stream(song.Intensity2));
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);
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:
var realIntensity : float;
if active.ids.size() == 3:
realIntensity = _player.Intensity;
else:
realIntensity = 0;
for i in active.ids.size():
# set volume based on intensity; with a single layer it's just full volume
_playback.set_stream_volume(active.ids[i], linear_to_db(db_to_linear(effective_db) * max((.5 - abs(float(i)/2 - realIntensity))/.5, 0)));
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;
@ -125,39 +220,10 @@ func _start_fade(from_db: float, to_db: float, duration: float, stop_after: bool
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;
for active in _active_songs:
_stop_active_song(active);
_active_songs.clear();
_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);