Compare commits
13
Commits
dd805a4cd9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bcc12b45b | ||
|
|
5204295864 | ||
|
|
1e53fb83d1 | ||
|
|
bd0629f554 | ||
|
|
184b257135 | ||
|
|
5758106b1e | ||
|
|
59f1d93c6f | ||
|
|
60047069fd | ||
|
|
df3e73b32e | ||
|
|
e792572635 | ||
|
|
c989fc4eea | ||
|
|
e8224e3913 | ||
|
|
36350ab8d5 |
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 funfirerabbit
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,11 +1,34 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="logo.jpg" width="200" alt="gd-dynamic-sound">
|
||||
|
||||
# gd-dynamic-sound
|
||||
|
||||
</div>
|
||||
|
||||
A Godot 4 audio addon for **dynamic music** (intensity-layered, seamless playlist transitions, fade in/out) and **overlapping one-shot SFX** — both built on `AudioStreamPolyphonic`.
|
||||
|
||||
**Requires Godot 4.6 or newer.** Older versions will load the addon but log an error from `plugin.gd` on enable; some `class_name`s may also fail to parse on engines lacking 4.6 GDScript features.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy the `addons/gd-dynamic-sound/` folder into your project's `addons/` directory.
|
||||
2. Enable the plugin in **Project Settings → Plugins**.
|
||||
3. *(Optional, recommended)* Wire up the `Music` and `FX` buses — see below.
|
||||
|
||||
## Audio buses
|
||||
|
||||
When a player enters the tree in the editor, it auto-routes to a named bus if one exists:
|
||||
|
||||
- `DynamicSoundPlayer` family → looks for a bus named **`Music`**
|
||||
- `DynamicSoundFXPlayer` family → looks for a bus named **`FX`**
|
||||
|
||||
If the named bus doesn't exist, the player stays on `Master`. User-customised buses are never overridden — the auto-route only fires when `bus` is still the default `Master`.
|
||||
|
||||
A ready-made layout containing both buses (each routing into `Master`) ships with the addon at `addons/gd-dynamic-sound/default_bus_layout.tres`. To use it:
|
||||
|
||||
- **Whole-project replacement:** copy it to your project root as `default_bus_layout.tres`, or point Godot at the addon's copy via **Project Settings → General → Audio → Buses → Default Bus Layout**.
|
||||
- **Merge into an existing layout:** open Godot's audio bus panel and add `Music` and `FX` buses manually, both sending to `Master`.
|
||||
|
||||
## Concepts
|
||||
|
||||
@@ -13,12 +36,16 @@ A Godot 4 audio addon for **dynamic music** (intensity-layered, seamless playlis
|
||||
|
||||
A single piece of audio with up to three intensity layers that play simultaneously and are blended at runtime.
|
||||
|
||||
> **All assigned layers must have identical lengths.** The three streams play in parallel; if `Intensity2` ends a beat earlier than `Intensity1`, the blend becomes silence on that layer for the remainder of the song and the player's end-of-song timing (which keys off `Intensity1.get_length()`) misfires. The resource pushes a warning in the editor when you assign mismatched-length streams, and `DynamicSoundPlayer` pushes a runtime error when it tries to play one. Trim or pad your stems to match before exporting.
|
||||
|
||||
| Property | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `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. |
|
||||
| `Intensity1` | `AudioStream` | Least-intense layer (e.g. ambient). Required — the player uses its length to time song advancement. |
|
||||
| `Intensity2` | `AudioStream` | Mid-intensity layer. Optional. Must match `Intensity1`'s length if assigned. |
|
||||
| `Intensity3` | `AudioStream` | Most-intense layer. Optional. Must match `Intensity1`'s length if assigned. |
|
||||
| `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). |
|
||||
|
||||
The resource exposes `has_matching_layer_lengths() -> bool` if you want to validate songs yourself (e.g. on load, in tests).
|
||||
|
||||
### `DynamicSoundPlaylist` (Resource)
|
||||
|
||||
@@ -26,7 +53,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
|
||||
|
||||
@@ -47,9 +74,11 @@ 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` (0–1) | `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. |
|
||||
| `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.
|
||||
|
||||
@@ -57,17 +86,21 @@ When the node enters the tree in the editor, `stream` is auto-assigned an `Audio
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `play_playlist()` | Starts (or resumes) the playlist. Use this when `autoplay` is `false` and you want to start playback programmatically. |
|
||||
| `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. |
|
||||
| `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. **Idempotent** — calling it while already fading to the same target is a no-op (won't restart the fade clock). 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. **Idempotent** — calling it while already fading to silent is a no-op. |
|
||||
| `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. **No-op when called with the index of the song already playing** — won't restart it. |
|
||||
|
||||
#### Behaviour
|
||||
|
||||
- **Autoplay.** On `_ready`, plays the front song of the playlist.
|
||||
- **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.** `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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[gd_resource type="AudioBusLayout" format=3]
|
||||
|
||||
[resource]
|
||||
bus/1/name = &"Music"
|
||||
bus/1/solo = false
|
||||
bus/1/mute = false
|
||||
bus/1/bypass_fx = false
|
||||
bus/1/volume_db = 0.0
|
||||
bus/1/send = &"Master"
|
||||
bus/2/name = &"FX"
|
||||
bus/2/solo = false
|
||||
bus/2/mute = false
|
||||
bus/2/bypass_fx = false
|
||||
bus/2/volume_db = 0.0
|
||||
bus/2/send = &"Master"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bny3rl7bvwn0i"
|
||||
path="res://.godot/imported/gd-dynamic-sound-icon.jpg-d7e11273584ff253e211b46c08833600.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/gd-dynamic-sound/gd-dynamic-sound-icon.jpg"
|
||||
dest_files=["res://.godot/imported/gd-dynamic-sound-icon.jpg-d7e11273584ff253e211b46c08833600.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cgqver78prtlf"
|
||||
path="res://.godot/imported/logo.jpg-eeda92563711509d55301ee15f9a0e8a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/gd-dynamic-sound/logo.jpg"
|
||||
dest_files=["res://.godot/imported/logo.jpg-eeda92563711509d55301ee15f9a0e8a.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -1,12 +1,13 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
const MIN_MAJOR : int = 4
|
||||
const MIN_MINOR : int = 6
|
||||
|
||||
func _enter_tree() -> void:
|
||||
add_autoload_singleton("GameSettings", "res://addons/gd-options/src/common/game_settings.gd")
|
||||
add_autoload_singleton("InputSettings", "res://addons/gd-options/src/common/input_settings.gd")
|
||||
add_autoload_singleton("GraphicsSettings", "res://addons/gd-options/src/common/graphics_settings.gd")
|
||||
add_autoload_singleton("AudioSettings", "res://addons/gd-options/src/common/audio_settings.gd")
|
||||
var v : Dictionary = Engine.get_version_info()
|
||||
if v.major < MIN_MAJOR or (v.major == MIN_MAJOR and v.minor < MIN_MINOR):
|
||||
push_error("gd-dynamic-sound requires Godot %d.%d+ (running %s). Some classes may fail to load or behave incorrectly." % [MIN_MAJOR, MIN_MINOR, v.string])
|
||||
|
||||
func _exit_tree() -> void:
|
||||
pass
|
||||
|
||||
+52
-8
@@ -1,17 +1,61 @@
|
||||
@icon("res://addons/gd-dynamic-audio/src/dynamic_sound_icon.png")
|
||||
@icon("res://addons/gd-dynamic-sound/gd-dynamic-sound-icon.jpg")
|
||||
@tool
|
||||
class_name DynamicSound
|
||||
## The DynamicSound Holds onto all your music variants.
|
||||
## A single piece of audio with up to three intensity layers that play simultaneously.
|
||||
##
|
||||
## [b]All assigned layers must have identical lengths.[/b] The layers play in
|
||||
## parallel and are mixed by an [code]Intensity[/code] value at runtime; if their
|
||||
## lengths differ they desynchronise as the shorter ones end first, breaking the
|
||||
## blend and the player's end-of-song timing. Both the editor (via property
|
||||
## setters) and the [DynamicSoundPlayer] family flag mismatched lengths.
|
||||
extends Resource
|
||||
|
||||
@export_category("Sound Files")
|
||||
## This should be set to your least intense music file.
|
||||
@export var Intensity1 : AudioStream;
|
||||
## This should be set to your least intense music file. Required — the player
|
||||
## uses its length to time song advancement.
|
||||
@export var Intensity1 : AudioStream:
|
||||
set(value):
|
||||
Intensity1 = value;
|
||||
_validate_layer_lengths();
|
||||
## This should be set to your semi-intense music file.
|
||||
@export var Intensity2 : AudioStream;
|
||||
## Must match [member Intensity1]'s length if assigned.
|
||||
@export var Intensity2 : AudioStream:
|
||||
set(value):
|
||||
Intensity2 = value;
|
||||
_validate_layer_lengths();
|
||||
## This should be set to your most intense music file.
|
||||
@export var Intensity3 : AudioStream;
|
||||
## Must match [member Intensity1]'s length if assigned.
|
||||
@export var Intensity3 : AudioStream:
|
||||
set(value):
|
||||
Intensity3 = value;
|
||||
_validate_layer_lengths();
|
||||
|
||||
## Set this to how long the sound should loop over itself. [br]
|
||||
## This will make any songs with a Reverb Tail loop perfectly.
|
||||
## 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).
|
||||
@export var ReverbTail : float;
|
||||
|
||||
## Returns true iff every assigned [code]IntensityN[/code] layer has the same length.
|
||||
## Single-layer or empty sounds always return true.
|
||||
func has_matching_layer_lengths() -> bool:
|
||||
var lengths : Array[float] = [];
|
||||
if Intensity1 != null: lengths.append(Intensity1.get_length());
|
||||
if Intensity2 != null: lengths.append(Intensity2.get_length());
|
||||
if Intensity3 != null: lengths.append(Intensity3.get_length());
|
||||
if lengths.size() < 2:
|
||||
return true;
|
||||
for l in lengths:
|
||||
if not is_equal_approx(l, lengths[0]):
|
||||
return false;
|
||||
return true;
|
||||
|
||||
func _validate_layer_lengths() -> void:
|
||||
if has_matching_layer_lengths():
|
||||
return;
|
||||
var lengths : Array[float] = [];
|
||||
if Intensity1 != null: lengths.append(Intensity1.get_length());
|
||||
if Intensity2 != null: lengths.append(Intensity2.get_length());
|
||||
if Intensity3 != null: lengths.append(Intensity3.get_length());
|
||||
var path : String = resource_path if not resource_path.is_empty() else "<unsaved>";
|
||||
push_warning("DynamicSound %s: intensity layers have different lengths %s — they must match to stay in sync." % [path, lengths]);
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@tool
|
||||
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||
@icon("res://addons/gd-dynamic-sound/gd-dynamic-sound-icon.jpg")
|
||||
class_name DynamicSoundFXPlayer
|
||||
## A one-shot SFX player that supports overlapping plays.
|
||||
##
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@tool
|
||||
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||
@icon("res://addons/gd-dynamic-sound/gd-dynamic-sound-icon.jpg")
|
||||
class_name DynamicSoundFXPlayer2D
|
||||
## 2D positional variant of [DynamicSoundFXPlayer].
|
||||
##
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@tool
|
||||
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||
@icon("res://addons/gd-dynamic-sound/gd-dynamic-sound-icon.jpg")
|
||||
class_name DynamicSoundFXPlayer3D
|
||||
## 3D positional variant of [DynamicSoundFXPlayer].
|
||||
##
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@tool
|
||||
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||
@icon("res://addons/gd-dynamic-sound/gd-dynamic-sound-icon.jpg")
|
||||
class_name DynamicSoundPlayer
|
||||
## Continuous music player with intensity-blended layers and seamless playlist transitions.
|
||||
##
|
||||
@@ -19,14 +19,26 @@ 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;
|
||||
|
||||
## 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:
|
||||
if property == &"volume_db":
|
||||
if _core: _core.apply_stream_volumes(value);
|
||||
if _core: _core.on_volume_db_set(value);
|
||||
return false;
|
||||
return false;
|
||||
|
||||
@@ -38,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();
|
||||
@@ -45,6 +61,22 @@ func _ready() -> void:
|
||||
func _process(delta: float) -> void:
|
||||
if _core: _core.process(delta);
|
||||
|
||||
## Start (or resume) playlist playback. Useful when [code]autoplay[/code] is false.
|
||||
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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@tool
|
||||
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||
@icon("res://addons/gd-dynamic-sound/gd-dynamic-sound-icon.jpg")
|
||||
class_name DynamicSoundPlayer2D
|
||||
## 2D positional variant of [DynamicSoundPlayer].
|
||||
##
|
||||
@@ -18,14 +18,26 @@ 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;
|
||||
|
||||
## 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:
|
||||
if property == &"volume_db":
|
||||
if _core: _core.apply_stream_volumes(value);
|
||||
if _core: _core.on_volume_db_set(value);
|
||||
return false;
|
||||
return false;
|
||||
|
||||
@@ -37,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();
|
||||
@@ -44,6 +60,22 @@ func _ready() -> void:
|
||||
func _process(delta: float) -> void:
|
||||
if _core: _core.process(delta);
|
||||
|
||||
## Start (or resume) playlist playback. Useful when [code]autoplay[/code] is false.
|
||||
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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@tool
|
||||
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||
@icon("res://addons/gd-dynamic-sound/gd-dynamic-sound-icon.jpg")
|
||||
class_name DynamicSoundPlayer3D
|
||||
## 3D positional variant of [DynamicSoundPlayer].
|
||||
##
|
||||
@@ -18,14 +18,26 @@ 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;
|
||||
|
||||
## 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:
|
||||
if property == &"volume_db":
|
||||
if _core: _core.apply_stream_volumes(value);
|
||||
if _core: _core.on_volume_db_set(value);
|
||||
return false;
|
||||
return false;
|
||||
|
||||
@@ -37,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();
|
||||
@@ -44,6 +60,22 @@ func _ready() -> void:
|
||||
func _process(delta: float) -> void:
|
||||
if _core: _core.process(delta);
|
||||
|
||||
## Start (or resume) playlist playback. Useful when [code]autoplay[/code] is false.
|
||||
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();
|
||||
|
||||
+229
-92
@@ -3,152 +3,289 @@ 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 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;
|
||||
|
||||
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;
|
||||
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():
|
||||
return;
|
||||
var playlist : DynamicSoundPlaylist = _player.Playlist;
|
||||
if playlist == null or playlist.QueuedSongs.is_empty():
|
||||
if not _player.autoplay:
|
||||
return;
|
||||
_player.play();
|
||||
_playback = _player.get_stream_playback();
|
||||
_play_song(playlist.QueuedSongs[0]);
|
||||
_start_playback();
|
||||
|
||||
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);
|
||||
_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;
|
||||
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():
|
||||
# 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;
|
||||
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;
|
||||
|
||||
func fade_in(duration: float) -> void:
|
||||
if _playback == null:
|
||||
_player.play();
|
||||
_playback = _player.get_stream_playback();
|
||||
if _paused:
|
||||
resume();
|
||||
var playlist : DynamicSoundPlaylist = _player.Playlist;
|
||||
if Ids.is_empty() and playlist != null and not playlist.QueuedSongs.is_empty():
|
||||
_play_song(playlist.QueuedSongs[0]);
|
||||
_start_fade(_FADE_SILENT_DB, 0.0, duration, false);
|
||||
## 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;
|
||||
|
||||
func fade_out(duration: float) -> void:
|
||||
if _playback == null:
|
||||
return;
|
||||
if _paused:
|
||||
resume();
|
||||
_start_fade(_fade_db, _FADE_SILENT_DB, duration, true);
|
||||
|
||||
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;
|
||||
_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);
|
||||
|
||||
func _stop_after_fade() -> void:
|
||||
for id in Ids:
|
||||
_playback.stop_stream(id);
|
||||
Ids.clear();
|
||||
_song_length = 0.0;
|
||||
_song_elapsed = 0.0;
|
||||
_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;
|
||||
## 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;
|
||||
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]);
|
||||
advance_to_track((_current_index + 1) % playlist.QueuedSongs.size());
|
||||
|
||||
func _play_song(song : DynamicSound) -> void:
|
||||
## Jump to the song at [param index]. Ignored if the index is out of range.
|
||||
## 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():
|
||||
return;
|
||||
if index == _current_index and not _active_songs.is_empty():
|
||||
return;
|
||||
if _playback == null:
|
||||
_player.play();
|
||||
_playback = _player.get_stream_playback();
|
||||
var mode : int = _player.SkipMode;
|
||||
var outgoing : _ActiveSong = null;
|
||||
if not _active_songs.is_empty():
|
||||
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);
|
||||
|
||||
## 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.
|
||||
## Idempotent: no-op if a fade is already heading to this same target.
|
||||
func fade_in(duration: float) -> void:
|
||||
var target_db : float = _target_volume_db;
|
||||
if target_db <= _FADE_SILENT_DB + 0.001:
|
||||
target_db = 0.0;
|
||||
if _fading and is_equal_approx(_fade_to_db, target_db):
|
||||
return;
|
||||
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.
|
||||
## Idempotent: no-op if a fade is already heading to silent.
|
||||
func fade_out(duration: float) -> void:
|
||||
if _fading and is_equal_approx(_fade_to_db, _FADE_SILENT_DB):
|
||||
return;
|
||||
_start_fade(_player.volume_db, _FADE_SILENT_DB, duration);
|
||||
|
||||
func apply_stream_volumes(value: float) -> void:
|
||||
if _playback == null:
|
||||
return;
|
||||
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():
|
||||
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;
|
||||
if not song.has_matching_layer_lengths():
|
||||
var path : String = song.resource_path if not song.resource_path.is_empty() else "<unsaved>";
|
||||
push_error("DynamicSound %s: intensity layers must have matching lengths — playback will desync." % path);
|
||||
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:
|
||||
Ids.append(_playback.play_stream(song.Intensity1));
|
||||
active.ids.append(_playback.play_stream(song.Intensity1));
|
||||
if song.Intensity2 != null:
|
||||
Ids.append(_playback.play_stream(song.Intensity2));
|
||||
active.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);
|
||||
active.ids.append(_playback.play_stream(song.Intensity3));
|
||||
_active_songs.append(active);
|
||||
_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, volume_db_value: float) -> void:
|
||||
var effective_db : float = volume_db_value + active.fade_db;
|
||||
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) -> void:
|
||||
_fade_from_db = from_db;
|
||||
_fade_to_db = to_db;
|
||||
_fade_duration = max(duration, 0.0001);
|
||||
_fade_elapsed = 0.0;
|
||||
_fading = true;
|
||||
_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;
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@icon("res://addons/gd-dynamic-audio/src/dynamic_sound_playlist_icon.png")
|
||||
@icon("res://addons/gd-dynamic-sound/gd-dynamic-sound-icon.jpg")
|
||||
@tool
|
||||
class_name DynamicSoundPlaylist
|
||||
## The DynamicSound Holds onto all your music variants.
|
||||
|
||||
Reference in New Issue
Block a user