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>
139 lines
9.5 KiB
Markdown
139 lines
9.5 KiB
Markdown
# gd-dynamic-sound
|
||
|
||
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`.
|
||
|
||
## 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
|
||
|
||
### `DynamicSound` (Resource)
|
||
|
||
A single piece of audio with up to three intensity layers that play simultaneously and are blended at runtime.
|
||
|
||
| 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 audio that is pure reverb tail. The next song is started this far ahead of the current song's full length, so the new opening covers the outgoing tail without a perceptible gap. Set to `0` to disable overlap (hard cut). |
|
||
|
||
### `DynamicSoundPlaylist` (Resource)
|
||
|
||
An ordered list of `DynamicSound`s.
|
||
|
||
| Property | Type | Description |
|
||
| --- | --- | --- |
|
||
| `QueuedSongs` | `Array[DynamicSound]` | Songs to play in order. The continuous player tracks an internal index; the resource is not mutated as songs play. |
|
||
|
||
## Nodes
|
||
|
||
Two node families, each with three positional variants:
|
||
|
||
| Family | Non-positional | 2D | 3D |
|
||
| --- | --- | --- | --- |
|
||
| Continuous music | `DynamicSoundPlayer` | `DynamicSoundPlayer2D` | `DynamicSoundPlayer3D` |
|
||
| One-shot SFX | `DynamicSoundFXPlayer` | `DynamicSoundFXPlayer2D` | `DynamicSoundFXPlayer3D` |
|
||
|
||
The 2D variants apply distance attenuation from a 2D position. The 3D variants are fully spatialised — distance, directionality, and Doppler.
|
||
|
||
### `DynamicSoundPlayer` family — continuous music
|
||
|
||
Plays through a playlist one song at a time, blending three intensity layers per song.
|
||
|
||
#### Properties
|
||
|
||
| Property | Type | Default | Description |
|
||
| --- | --- | --- | --- |
|
||
| `Playlist` | `DynamicSoundPlaylist` | `null` | Songs to play in order. Read-only as songs play — the player tracks an internal index. |
|
||
| `Intensity` | `float` (0–1) | `0` | Runtime mix between the three layers. `0` = layer 1 dominates, `1` = layer 3 dominates. |
|
||
| `LoopMode` | enum | `All` | `Single` keeps repeating the current song. `All` advances through the playlist and wraps back to index 0 after the last song. Both modes loop forever; there's no "play once and stop" mode. |
|
||
| `SkipMode` | enum | `UseReverbTail` | How `advance_to_next_song()` / `advance_to_track()` transition. `UseReverbTail` accelerates the outgoing song so its remaining time hits `ReverbTail` and the new song plays on top — relies on the song's audio having a natural tail. `CrossFade` programmatically fades the outgoing song down and the incoming song up over `CrossFadeDuration` seconds. `Jump` is a hard cut. Does **not** affect natural end-of-song advancement (that always uses reverb tail). |
|
||
| `CrossFadeDuration` | `float` | `1.0` | Duration of the volume crossfade in seconds when `SkipMode = CrossFade`. Hidden in the inspector for the other modes. |
|
||
|
||
When the node enters the tree in the editor, `stream` is auto-assigned an `AudioStreamPolyphonic` and `bus` is set to `"Music"` if a bus by that name exists in the project (otherwise it's left at `"Master"`). User-customised buses are not overridden.
|
||
|
||
#### 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. Works regardless of `autoplay`. |
|
||
| `fade_out(duration: float)` | Ramps volume from current level to silent over `duration` seconds, then stops the polyphonic session. A subsequent `fade_in` reinitialises. |
|
||
| `get_current_song_index() -> int` | Index of the song most recently kicked off in `Playlist.QueuedSongs`. During a reverb-tail crossover this becomes the incoming song the moment it starts, even though the outgoing song is still audible. |
|
||
| `advance_to_next_song()` | Skip to the next song in the playlist (wraps from the end back to index 0). Transition style is controlled by `SkipMode`. |
|
||
| `advance_to_track(index: int)` | Same as `advance_to_next_song`, but jumps to a specific index. Out-of-range indices are ignored. |
|
||
|
||
#### Behaviour
|
||
|
||
- **Autoplay.** On `_ready`, plays the song at the current index *only if `autoplay` is true* (the inherited `AudioStreamPlayer.autoplay` property; default `true`). Set `autoplay = false` to defer playback to a `play_playlist()` or `fade_in()` call.
|
||
- **Reverb-tail transitions.** When the current song is within `ReverbTail` seconds of its full length, the next song (chosen per `LoopMode`) is started so its leading audio covers the outgoing tail. Both songs are audible during the overlap window; the outgoing one stops when its full length has elapsed.
|
||
- **Volume.** `volume_db` is honoured; the fade adjustment is layered on top internally, so manual volume changes during a fade still work correctly. During a reverb-tail overlap, both songs share the same volume/intensity mix.
|
||
- **Editor.** All scripts are `@tool`, but `_ready` and `_process` early-return in the editor so audio doesn't play in the inspector.
|
||
|
||
### `DynamicSoundFXPlayer` family — overlapping one-shot SFX
|
||
|
||
Each `play_fx()` call fires a fresh trigger of the assigned `DynamicSound`. Triggers overlap freely — the underlying `AudioStreamPolyphonic` cleans up each trigger when its audio ends.
|
||
|
||
#### Properties
|
||
|
||
| Property | Type | Default | Description |
|
||
| --- | --- | --- | --- |
|
||
| `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`. 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
|
||
|
||
```gdscript
|
||
@onready var fire_fx : DynamicSoundFXPlayer2D = $FireFX
|
||
|
||
func _on_body_entered(body) -> void:
|
||
fire_fx.play_fx()
|
||
```
|
||
|
||
## Architecture
|
||
|
||
The six player classes are thin wrappers (~70 lines each) around two shared `RefCounted` cores:
|
||
|
||
- `DynamicSoundPlayerCore` — continuous-music logic (advancement, fades, pause, intensity blending).
|
||
- `DynamicSoundFXPlayerCore` — one-shot trigger logic.
|
||
|
||
Each wrapper declares its exports, sets up a core in `_ready`, and forwards public methods. The cores access their owner via duck-typed property lookups (`_player.Playlist`, `_player.volume_db`, etc.), which is what lets the same core drive non-positional, 2D, and 3D wrappers without inheritance.
|
||
|
||
## Honourable mentions
|
||
|
||
This addon was heavily inspired by the Ovani Plugin 1.4. Please consider supporting [OvaniSound](https://ovanisound.com) — these players are designed to drive their dynamic sounds.
|