Compare commits

..
15 Commits
Author SHA1 Message Date
Daniel SamsonandClaude Opus 4.7 7bcc12b45b make fade_in/fade_out idempotent — repeat calls toward the same target are no-ops
Without this, scripts that re-fire fade_in on every Area2D body_entered (or
similar repeating signal) restart the fade clock each call, which can stutter
audibly when a per-song crossfade is also running. Now a fade_in while already
fading to the user's set level — or fade_out while already fading to silent —
is a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:32:06 +01:00
Daniel SamsonandClaude Opus 4.7 5204295864 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>
2026-04-29 20:24:30 +01:00
Daniel SamsonandClaude Opus 4.7 1e53fb83d1 advance_to_track: ignore same-index calls so requesting the current track no longer restarts it
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:24:18 +01:00
Daniel SamsonandClaude Opus 4.7 bd0629f554 center logo and title in README, shrink logo
Wraps the logo and the H1 in a <div align="center"> block (HTML embedded in
markdown — both Gitea and GitHub render it). Sets the logo to 200px wide
instead of full source size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:09:34 +01:00
Daniel SamsonandClaude Opus 4.7 184b257135 add logo and proper icon, update @icon refs
logo.jpg displays at the top of README.md. gd-dynamic-sound-icon.jpg replaces
the icon.svg placeholder for the @icon decorator on all six player wrappers
plus the DynamicSound and DynamicSoundPlaylist resources.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:07:36 +01:00
Daniel SamsonandClaude Opus 4.7 5758106b1e gate plugin on Godot 4.6+; drop stale gd-options autoload registrations
plugin.gd now checks Engine.get_version_info() in _enter_tree and pushes a
loud error if the engine is older than 4.6. Doesn't actually disable the
addon's class_names (Godot parses those independently of EditorPlugin), but
gives users a clear "this is the cause" signal before any cryptic downstream
errors land.

Also removes four add_autoload_singleton calls for gd-options-owned scripts
(GameSettings, InputSettings, GraphicsSettings, AudioSettings). They were
copy-paste leftovers from when this addon was forked; gd-options/plugin.gd
already registers them, so this addon was double-registering — gd-dynamic-
sound has no business owning those autoloads.

README documents the 4.6 requirement near the top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:45:43 +01:00
Daniel SamsonandClaude Opus 4.7 59f1d93c6f validate that intensity layers have matching lengths
The three IntensityN streams of a DynamicSound play in parallel, and the
player keys end-of-song timing off Intensity1's length — mismatched lengths
cause silent gaps in the higher intensity layers and broken advancement.

DynamicSound:
- Setters on Intensity1/2/3 push_warning when assigning a stream whose length
  doesn't match the others (editor-time feedback).
- Public has_matching_layer_lengths() -> bool for callers wanting to validate
  songs themselves.

DynamicSoundPlayerCore:
- _start_song_at_index pushes a runtime error if the song fails the check
  before queuing its layers on the polyphonic playback.

The FX core deliberately doesn't add a runtime check — play_fx fires per
trigger and would spam, while the editor warning already covers authoring
mistakes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:36:50 +01:00
Daniel SamsonandClaude Opus 4.7 60047069fd add MIT license
Same wording as gd-options and gd-controls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:33:33 +01:00
Daniel SamsonandClaude Opus 4.7 df3e73b32e add SkipMode enum (UseReverbTail / CrossFade / Jump)
advance_to_next_song / advance_to_track now branch on the new SkipMode export:

- UseReverbTail (default): existing behavior. Accelerate the outgoing song so
  remaining time hits ReverbTail, both songs play during the overlap, outgoing
  stops naturally.
- CrossFade: programmatic volume crossfade. Outgoing fades from current volume
  to silent over CrossFadeDuration seconds (then stops), incoming starts silent
  and fades up to full over the same duration. Independent of any global fade.
- Jump: hard cut. Outgoing song's streams are stopped immediately and removed
  from the active list before the incoming starts.

For CrossFade we needed per-song fade state (the global _fade_db can't drive
two songs in opposite directions at once), so _ActiveSong gained fade_db /
fade_from_db / fade_to_db / fade_duration / fade_elapsed / fading /
stop_after_fade fields. _apply_volumes_to_song now adds both the global and
per-song fade_db to volume_db; process() advances each song's fade per-frame
and stops songs whose fades reach the silent target with stop_after_fade set.

CrossFadeDuration is hidden in the inspector via _validate_property unless
SkipMode == CrossFade. The natural end-of-song advance is unchanged — it still
uses the song's reverb tail. SkipMode only affects programmatic skips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:31:37 +01:00
Daniel SamsonandClaude Opus 4.7 e792572635 fix stale gd-dynamic-audio icon paths on resource classes
DynamicSound and DynamicSoundPlaylist were still pointing @icon at the
non-existent res://addons/gd-dynamic-audio/src/*.png paths, causing
"Failed loading resource" errors at editor load. Pointed both at the
placeholder icon.svg used by the player wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:13:25 +01:00
Daniel SamsonandClaude Opus 4.7 c989fc4eea 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>
2026-04-29 16:07:06 +01:00
Daniel SamsonandClaude Opus 4.7 e8224e3913 respect autoplay; add play_playlist() for manual start
DynamicSoundPlayerCore.ready() always called play() if a playlist had songs,
ignoring the inherited AudioStreamPlayer.autoplay property. Now early-returns
when autoplay is false. Adds play_playlist() (exposed on all three continuous
wrappers) so users with autoplay=false have a non-fade way to start playback.
fade_in() now uses the same _start_playback() helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:29:08 +01:00
Daniel SamsonandClaude Opus 4.7 36350ab8d5 ship a sample bus layout with Music and FX
Adds default_bus_layout.tres at the addon root containing a Music bus and an
FX bus, each routing into Master. Users can point Project Settings → Audio →
Default Bus Layout at this file (or copy it to their project root) to get the
buses the players auto-route to in the editor. README documents the wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:09:10 +01:00
Daniel SamsonandClaude Opus 4.7 dd805a4cd9 move PitchMode to FX players and implement random pitch
The continuous music players had PitchMode/RandomMin/MaxPitchScale exports but
no logic referenced them. Pitch variation only makes sense per-trigger, which
the FX family supports natively. So:

- Remove PitchMode, RandomMinPitchScale, RandomMaxPitchScale, and the
  _validate_property hook from DynamicSoundPlayer / 2D / 3D.
- Add the same exports + _validate_property to DynamicSoundFXPlayer / 2D / 3D.
- Implement pitch resolution in DynamicSoundFXPlayerCore.play_fx: each trigger
  samples a fresh pitch from PitchMode and passes it as the pitch_scale
  argument to AudioStreamPlaybackPolyphonic.play_stream.
- Random pitch uses 1.0 + randf_range(min, max) so the existing -0.5/0.5
  defaults give a useful [0.5, 1.5] pitch range.
- README updated to reflect the move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:03:03 +01:00
Daniel SamsonandClaude Opus 4.7 7f227acb6a auto-detect Music/FX bus in editor
When a player enters the tree in the editor, if its bus is still the default
"Master" and a "Music" (or "FX") bus exists in the AudioServer, switch to that.
User-customised buses are not overridden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:00:56 +01:00
19 changed files with 650 additions and 181 deletions
+9
View File
@@ -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.
+54 -17
View File
@@ -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,30 +74,33 @@ 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. |
| `PitchMode` | enum | `Constant` | `Constant` or `Random` (placeholder — pitch logic not yet implemented). |
| `RandomMinPitchScale` | `float` | `-0.5` | Minimum pitch when `PitchMode = Random`. Hidden in the inspector when `PitchMode = Constant`. |
| `RandomMaxPitchScale` | `float` | `0.5` | Maximum pitch when `PitchMode = Random`. Hidden in the inspector when `PitchMode = Constant`. |
| `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. |
The `stream` property is auto-assigned an `AudioStreamPolyphonic` when the node enters the tree in the editor, so you don't need to configure it manually.
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.
#### Methods
| 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
@@ -83,12 +113,19 @@ Each `play_fx()` call fires a fresh trigger of the assigned `DynamicSound`. Trig
| --- | --- | --- | --- |
| `Sound` | `DynamicSound` | `null` | The sound to trigger. Layers with intensity blending if all three are set. |
| `Intensity` | `float` (0–1) | `0` | Volume mix between layers. Only blended when all three layers are present; otherwise the first non-null layer plays at full volume. |
| `PitchMode` | enum | `Constant` | `Constant` plays each trigger at pitch `1.0`. `Random` picks a fresh pitch per trigger via `1.0 + randf_range(RandomMinPitchScale, RandomMaxPitchScale)`. |
| `RandomMinPitchScale` | `float` | `-0.5` | Minimum pitch offset (added to `1.0`) when `PitchMode = Random`. Hidden in the inspector when `PitchMode = Constant`. |
| `RandomMaxPitchScale` | `float` | `0.5` | Maximum pitch offset (added to `1.0`) when `PitchMode = Random`. Hidden in the inspector when `PitchMode = Constant`. |
When the node enters the tree in the editor, `stream` is auto-assigned an `AudioStreamPolyphonic` and `bus` is set to `"FX"` if a bus by that name exists in the project (otherwise it's left at `"Master"`). User-customised buses are not overridden.
For a *constant non-1.0* pitch, set the player's built-in `pitch_scale` property — that multiplies with whatever `PitchMode` produces, so e.g. `pitch_scale = 0.8` + `PitchMode = Random` with default range gives an effective pitch in `[0.4, 1.2]`.
#### Methods
| Method | Description |
| --- | --- |
| `play_fx()` | Triggers a fresh play of `Sound`. Volumes are computed at trigger time and locked in — later changes to `Intensity` or `volume_db` don't affect already-playing triggers. |
| `play_fx()` | Triggers a fresh play of `Sound`. Pitch is sampled at trigger time per `PitchMode`. Volumes are computed at trigger time and locked in — later changes to `Intensity` or `volume_db` don't affect already-playing triggers. |
#### Example: collision SFX
+15
View File
@@ -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

+40
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+40
View File
@@ -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
+5 -4
View File
@@ -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
View File
@@ -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]);
+24 -5
View File
@@ -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
}
+22 -2
View File
@@ -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.
##
@@ -15,11 +15,31 @@ extends AudioStreamPlayer
## Volume mix between intensity layers. Only blended when all three layers are set.
@export_range(0, 1) var Intensity : float = 0;
## How playback pitch is selected for each [method play_fx] trigger.
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
set(value):
PitchMode = value;
notify_property_list_changed();
## Minimum pitch offset (added to 1.0) when [member PitchMode] is Random.
@export var RandomMinPitchScale : float = -0.5;
## Maximum pitch offset (added to 1.0) when [member PitchMode] is Random.
@export var RandomMaxPitchScale : float = 0.5;
var _core : DynamicSoundFXPlayerCore;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
if not Engine.is_editor_hint():
return;
if stream == null:
stream = AudioStreamPolyphonic.new();
if bus == &"Master" and AudioServer.get_bus_index("FX") != -1:
bus = "FX";
func _validate_property(property: Dictionary) -> void:
if property.name in ["RandomMinPitchScale", "RandomMaxPitchScale"]:
if PitchMode != DynamicSoundConstants.PitchMode.Random:
property.usage &= ~PROPERTY_USAGE_EDITOR;
func _ready() -> void:
_core = DynamicSoundFXPlayerCore.new(self);
+22 -2
View File
@@ -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].
##
@@ -14,11 +14,31 @@ extends AudioStreamPlayer2D
## Volume mix between intensity layers. Only blended when all three layers are set.
@export_range(0, 1) var Intensity : float = 0;
## How playback pitch is selected for each [method play_fx] trigger.
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
set(value):
PitchMode = value;
notify_property_list_changed();
## Minimum pitch offset (added to 1.0) when [member PitchMode] is Random.
@export var RandomMinPitchScale : float = -0.5;
## Maximum pitch offset (added to 1.0) when [member PitchMode] is Random.
@export var RandomMaxPitchScale : float = 0.5;
var _core : DynamicSoundFXPlayerCore;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
if not Engine.is_editor_hint():
return;
if stream == null:
stream = AudioStreamPolyphonic.new();
if bus == &"Master" and AudioServer.get_bus_index("FX") != -1:
bus = "FX";
func _validate_property(property: Dictionary) -> void:
if property.name in ["RandomMinPitchScale", "RandomMaxPitchScale"]:
if PitchMode != DynamicSoundConstants.PitchMode.Random:
property.usage &= ~PROPERTY_USAGE_EDITOR;
func _ready() -> void:
_core = DynamicSoundFXPlayerCore.new(self);
+22 -2
View File
@@ -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].
##
@@ -14,11 +14,31 @@ extends AudioStreamPlayer3D
## Volume mix between intensity layers. Only blended when all three layers are set.
@export_range(0, 1) var Intensity : float = 0;
## How playback pitch is selected for each [method play_fx] trigger.
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
set(value):
PitchMode = value;
notify_property_list_changed();
## Minimum pitch offset (added to 1.0) when [member PitchMode] is Random.
@export var RandomMinPitchScale : float = -0.5;
## Maximum pitch offset (added to 1.0) when [member PitchMode] is Random.
@export var RandomMaxPitchScale : float = 0.5;
var _core : DynamicSoundFXPlayerCore;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
if not Engine.is_editor_hint():
return;
if stream == null:
stream = AudioStreamPolyphonic.new();
if bus == &"Master" and AudioServer.get_bus_index("FX") != -1:
bus = "FX";
func _validate_property(property: Dictionary) -> void:
if property.name in ["RandomMinPitchScale", "RandomMaxPitchScale"]:
if PitchMode != DynamicSoundConstants.PitchMode.Random:
property.usage &= ~PROPERTY_USAGE_EDITOR;
func _ready() -> void:
_core = DynamicSoundFXPlayerCore.new(self);
+10 -3
View File
@@ -3,8 +3,9 @@ class_name DynamicSoundFXPlayerCore
##
## Owned by [DynamicSoundFXPlayer], [DynamicSoundFXPlayer2D], and [DynamicSoundFXPlayer3D].
## Operates on its owner via duck-typed property access — the owner must expose
## [code]Sound[/code], [code]Intensity[/code], [code]volume_db[/code], [code]play()[/code],
## and [code]get_stream_playback()[/code].
## [code]Sound[/code], [code]Intensity[/code], [code]PitchMode[/code],
## [code]RandomMinPitchScale[/code], [code]RandomMaxPitchScale[/code],
## [code]volume_db[/code], [code]play()[/code], and [code]get_stream_playback()[/code].
extends RefCounted
var _player;
@@ -23,14 +24,20 @@ func play_fx() -> void:
var sound : DynamicSound = _player.Sound;
if sound == null or _playback == null:
return;
var pitch : float = _resolve_pitch();
var layers : Array[AudioStream] = [];
if sound.Intensity1 != null: layers.append(sound.Intensity1);
if sound.Intensity2 != null: layers.append(sound.Intensity2);
if sound.Intensity3 != null: layers.append(sound.Intensity3);
var real_intensity : float = _player.Intensity if layers.size() == 3 else 0.0;
for i in layers.size():
var id : int = _playback.play_stream(layers[i]);
var id : int = _playback.play_stream(layers[i], 0.0, 0.0, pitch);
if id == AudioStreamPlaybackPolyphonic.INVALID_ID:
continue;
var vol_lin : float = max((.5 - abs(float(i) / 2 - real_intensity)) / .5, 0.0);
_playback.set_stream_volume(id, linear_to_db(db_to_linear(_player.volume_db) * vol_lin));
func _resolve_pitch() -> float:
if _player.PitchMode == DynamicSoundConstants.PitchMode.Random:
return 1.0 + randf_range(_player.RandomMinPitchScale, _player.RandomMaxPitchScale);
return 1.0;
+34 -14
View File
@@ -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,35 +19,39 @@ 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 playback pitch is selected.
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
## 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):
PitchMode = value;
SkipMode = value;
notify_property_list_changed();
## Minimum pitch scale when [member PitchMode] is Random.
@export var RandomMinPitchScale : float = -0.5;
## Maximum pitch scale when [member PitchMode] is Random.
@export var RandomMaxPitchScale : float = 0.5;
## 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;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
if not Engine.is_editor_hint():
return;
if stream == null:
stream = AudioStreamPolyphonic.new();
if bus == &"Master" and AudioServer.get_bus_index("Music") != -1:
bus = "Music";
func _validate_property(property: Dictionary) -> void:
if property.name in ["RandomMinPitchScale", "RandomMaxPitchScale"]:
if PitchMode != DynamicSoundConstants.PitchMode.Random:
if property.name == "CrossFadeDuration" and SkipMode != DynamicSoundConstants.SkipMode.CrossFade:
property.usage &= ~PROPERTY_USAGE_EDITOR;
func _ready() -> void:
@@ -57,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();
+34 -14
View File
@@ -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,35 +18,39 @@ 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 playback pitch is selected.
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
## 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):
PitchMode = value;
SkipMode = value;
notify_property_list_changed();
## Minimum pitch scale when [member PitchMode] is Random.
@export var RandomMinPitchScale : float = -0.5;
## Maximum pitch scale when [member PitchMode] is Random.
@export var RandomMaxPitchScale : float = 0.5;
## 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;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
if not Engine.is_editor_hint():
return;
if stream == null:
stream = AudioStreamPolyphonic.new();
if bus == &"Master" and AudioServer.get_bus_index("Music") != -1:
bus = "Music";
func _validate_property(property: Dictionary) -> void:
if property.name in ["RandomMinPitchScale", "RandomMaxPitchScale"]:
if PitchMode != DynamicSoundConstants.PitchMode.Random:
if property.name == "CrossFadeDuration" and SkipMode != DynamicSoundConstants.SkipMode.CrossFade:
property.usage &= ~PROPERTY_USAGE_EDITOR;
func _ready() -> void:
@@ -56,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();
+34 -14
View File
@@ -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,35 +18,39 @@ 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 playback pitch is selected.
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
## 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):
PitchMode = value;
SkipMode = value;
notify_property_list_changed();
## Minimum pitch scale when [member PitchMode] is Random.
@export var RandomMinPitchScale : float = -0.5;
## Maximum pitch scale when [member PitchMode] is Random.
@export var RandomMaxPitchScale : float = 0.5;
## 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;
func _enter_tree() -> void:
if Engine.is_editor_hint() and stream == null:
if not Engine.is_editor_hint():
return;
if stream == null:
stream = AudioStreamPolyphonic.new();
if bus == &"Master" and AudioServer.get_bus_index("Music") != -1:
bus = "Music";
func _validate_property(property: Dictionary) -> void:
if property.name in ["RandomMinPitchScale", "RandomMaxPitchScale"]:
if PitchMode != DynamicSoundConstants.PitchMode.Random:
if property.name == "CrossFadeDuration" and SkipMode != DynamicSoundConstants.SkipMode.CrossFade:
property.usage &= ~PROPERTY_USAGE_EDITOR;
func _ready() -> void:
@@ -56,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
View File
@@ -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 -1
View File
@@ -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.