refactor fade_in/fade_out to drive volume_db directly and decouple from play/stop

Fades are now pure volume ramps: they no longer call play(), stop(), or
unpause. The animation is visible on the volume_db property each frame.
Track _target_volume_db separately so fade_in/fade_out round-trips return
to the user's set level instead of a transient lerp value, and skip the
"snap to silent" when interrupting an in-flight player or per-song
crossfade so audible state is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Samson 2026-04-29 20:24:30 +01:00
parent 1e53fb83d1
commit 5204295864
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
5 changed files with 43 additions and 36 deletions

View File

@ -90,8 +90,8 @@ When the node enters the tree in the editor, `stream` is auto-assigned an `Audio
| `pause()` | Pauses playback. No-op if not playing or already paused. |
| `resume()` | Resumes playback after a pause. |
| `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. |
| `fade_in(duration: float)` | Animates `volume_db` up to the user's set level over `duration` seconds. **Pure volume operation** — does not start playback, unpause, or otherwise change playing state. The "set level" is the last value written to `volume_db` outside of an active fade, so `fade_in``fade_out``fade_in` round-trips back to the original volume. If any fade is in flight (player-wide `fade_out` or per-song crossfade from `SkipMode = CrossFade`), the ramp continues from the current `volume_db` so audible state is preserved. Otherwise it snaps to silent and ramps up. Pair with `play_playlist()` / `resume()` if the player isn't already playing. |
| `fade_out(duration: float)` | Animates `volume_db` from its current value down to silent over `duration` seconds. **Pure volume operation** — does not stop the polyphonic session or pause. Call `pause()` / `stop()` yourself afterwards if you want playback to halt. |
| `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). 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. |
@ -100,7 +100,7 @@ When the node enters the tree in the editor, `stream` is auto-assigned an `Audio
- **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.
- **Volume.** `fade_in` and `fade_out` animate `volume_db` directly — the property visibly changes each frame during a fade. Setting `volume_db` mid-fade will be overwritten on the next frame; let the fade complete (or stop it by triggering another fade) before nudging the property manually. 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

@ -38,7 +38,7 @@ var _core : DynamicSoundPlayerCore;
func _set(property: StringName, value: Variant) -> bool:
if property == &"volume_db":
if _core: _core.apply_stream_volumes(value);
if _core: _core.on_volume_db_set(value);
return false;
return false;

View File

@ -37,7 +37,7 @@ var _core : DynamicSoundPlayerCore;
func _set(property: StringName, value: Variant) -> bool:
if property == &"volume_db":
if _core: _core.apply_stream_volumes(value);
if _core: _core.on_volume_db_set(value);
return false;
return false;

View File

@ -37,7 +37,7 @@ var _core : DynamicSoundPlayerCore;
func _set(property: StringName, value: Variant) -> bool:
if property == &"volume_db":
if _core: _core.apply_stream_volumes(value);
if _core: _core.on_volume_db_set(value);
return false;
return false;

View File

@ -41,16 +41,18 @@ var _playback : AudioStreamPlaybackPolyphonic;
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;
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;
# Stable record of the user's set volume. Fades animate volume_db directly,
# so without this we lose the original target across fade interruptions.
var _target_volume_db : float = 0.0;
func _init(player) -> void:
_player = player;
_target_volume_db = player.volume_db;
func ready() -> void:
if Engine.is_editor_hint():
@ -68,13 +70,9 @@ func process(delta: float) -> void:
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);
_player.volume_db = lerp(_fade_from_db, _fade_to_db, t);
if t >= 1.0:
_fading = false;
if _stop_when_faded:
_stop_after_fade();
return;
# iterate over a snapshot since we may modify _active_songs in the loop
for active in _active_songs.duplicate():
# advance per-song fade
@ -172,18 +170,30 @@ func advance_to_track(index: int) -> void:
_start_song_fade(incoming, _FADE_SILENT_DB, 0.0, _player.CrossFadeDuration, false);
_apply_volumes_to_song(incoming, _player.volume_db);
## Ramps [code]volume_db[/code] up to the user's set level over [param duration]
## seconds. Pure volume operation — does not call [code]play()[/code], unpause,
## or change playing state. If any fade is in flight (player-wide or per-song
## crossfade), the ramp continues from the current [code]volume_db[/code] —
## no snap to silent, so audible crossfade state is preserved. Otherwise the
## ramp snaps to silent first and rises to the user's set level.
func fade_in(duration: float) -> void:
_start_playback();
if _paused:
resume();
_start_fade(_FADE_SILENT_DB, 0.0, duration, false);
var target_db : float = _target_volume_db;
if target_db <= _FADE_SILENT_DB + 0.001:
target_db = 0.0;
var any_song_fading : bool = false;
for active in _active_songs:
if active.fading:
any_song_fading = true;
break;
var from_db : float = _player.volume_db if (_fading or any_song_fading) else _FADE_SILENT_DB;
_start_fade(from_db, target_db, duration);
## Ramps [code]volume_db[/code] from its current value down to silent over
## [param duration] seconds. Pure volume operation — does not stop the
## polyphonic session or pause. Call [method pause] or [code]stop()[/code]
## yourself if you want playback to halt at the end.
func fade_out(duration: float) -> void:
if _playback == null:
return;
if _paused:
resume();
_start_fade(_fade_db, _FADE_SILENT_DB, duration, true);
_start_fade(_player.volume_db, _FADE_SILENT_DB, duration);
func apply_stream_volumes(value: float) -> void:
if _playback == null:
@ -191,6 +201,14 @@ func apply_stream_volumes(value: float) -> void:
for active in _active_songs:
_apply_volumes_to_song(active, value);
## Wrapper-side hook for [code]volume_db[/code] writes. Updates [code]_target_volume_db[/code]
## (the user's set level) only when no fade is animating it, so transient lerp
## values aren't captured as a new target.
func on_volume_db_set(value: float) -> void:
if not _fading:
_target_volume_db = value;
apply_stream_volumes(value);
func _start_playback() -> void:
var playlist : DynamicSoundPlaylist = _player.Playlist;
if playlist == null or playlist.QueuedSongs.is_empty():
@ -239,7 +257,7 @@ func _stop_active_song(active: _ActiveSong) -> void:
_playback.stop_stream(id);
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 effective_db : float = volume_db_value + active.fade_db;
var realIntensity : float;
if active.ids.size() == 3:
realIntensity = _player.Intensity;
@ -249,15 +267,13 @@ func _apply_volumes_to_song(active: _ActiveSong, volume_db_value: float) -> void
# 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:
func _start_fade(from_db: float, to_db: float, duration: float) -> 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);
_player.volume_db = from_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;
@ -267,12 +283,3 @@ func _start_song_fade(song: _ActiveSong, from_db: float, to_db: float, duration:
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);
_active_songs.clear();
_player.stop();
_playback = null;
_fade_db = 0.0;
_stop_when_faded = false;