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);