Initial commit: dynamic music & FX players
Continuous music players (DynamicSoundPlayer, 2D, 3D) with intensity-blended layers, playlist queue, Loop, fade in/out, pause/resume. One-shot SFX players (DynamicSoundFXPlayer, 2D, 3D) for overlapping triggers. All variants share RefCounted core classes via duck-typed property access. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
cb47d89377
|
|
@ -0,0 +1,113 @@
|
||||||
|
# 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**.
|
||||||
|
|
||||||
|
## 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 reverb tail, used for seamless looping. |
|
||||||
|
|
||||||
|
### `DynamicSoundPlaylist` (Resource)
|
||||||
|
|
||||||
|
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. |
|
||||||
|
|
||||||
|
## 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. Mutated as songs finish. |
|
||||||
|
| `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`. |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `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. |
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
- **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. |
|
||||||
|
|
||||||
|
#### 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. |
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
|
|
@ -0,0 +1,43 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://b7i126kalykof"
|
||||||
|
path="res://.godot/imported/icon.svg-9a86c2ed9bf8d44461ea973f38915c93.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://addons/gd-dynamic-sound/icon.svg"
|
||||||
|
dest_files=["res://.godot/imported/icon.svg-9a86c2ed9bf8d44461ea973f38915c93.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
|
||||||
|
svg/scale=1.0
|
||||||
|
editor/scale_with_editor_scale=false
|
||||||
|
editor/convert_colors_with_editor_theme=false
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
[plugin]
|
||||||
|
|
||||||
|
name="gd-dynamic-sound"
|
||||||
|
description="An audio/sound framework that will let you easily manage dynamic music and play dynamic fx in your game."
|
||||||
|
author="Daniel Samson"
|
||||||
|
version="0.1.0"
|
||||||
|
script="plugin.gd"
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
@tool
|
||||||
|
extends EditorPlugin
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://clnfrvqcs1sj4
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
@icon("res://addons/gd-dynamic-audio/src/dynamic_sound_icon.png")
|
||||||
|
@tool
|
||||||
|
class_name DynamicSound
|
||||||
|
## The DynamicSound Holds onto all your music variants.
|
||||||
|
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 semi-intense music file.
|
||||||
|
@export var Intensity2 : AudioStream;
|
||||||
|
## This should be set to your most intense music file.
|
||||||
|
@export var Intensity3 : AudioStream;
|
||||||
|
|
||||||
|
## Set this to how long the sound should loop over itself. [br]
|
||||||
|
## This will make any songs with a Reverb Tail loop perfectly.
|
||||||
|
@export var ReverbTail : float;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dfma4so3silpl
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
class_name DynamicSoundConstants
|
||||||
|
|
||||||
|
## Used intenally by the sound players
|
||||||
|
enum NextState {
|
||||||
|
None = 0,
|
||||||
|
StartedLoop = 1,
|
||||||
|
StartedDifferent = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PitchMode {
|
||||||
|
Constant = 0,
|
||||||
|
Random = 1
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b6uidt4yqrvqa
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
@tool
|
||||||
|
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||||
|
class_name DynamicSoundFXPlayer
|
||||||
|
## A one-shot SFX player that supports overlapping plays.
|
||||||
|
##
|
||||||
|
## Each call to [method play_fx] triggers a fresh play of the assigned
|
||||||
|
## [DynamicSound], layering on top of any plays still in flight. Use this for
|
||||||
|
## events like collision impacts, footsteps, or weapon fires — situations where
|
||||||
|
## the same effect can legitimately overlap itself.
|
||||||
|
extends AudioStreamPlayer
|
||||||
|
|
||||||
|
## The [DynamicSound] to trigger. Each [method play_fx] call plays a fresh instance.
|
||||||
|
@export var Sound : DynamicSound;
|
||||||
|
|
||||||
|
## Volume mix between intensity layers. Only blended when all three layers are set.
|
||||||
|
@export_range(0, 1) var Intensity : float = 0;
|
||||||
|
|
||||||
|
var _core : DynamicSoundFXPlayerCore;
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
if Engine.is_editor_hint() and stream == null:
|
||||||
|
stream = AudioStreamPolyphonic.new();
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_core = DynamicSoundFXPlayerCore.new(self);
|
||||||
|
_core.ready();
|
||||||
|
|
||||||
|
## Trigger a one-shot of [member Sound]. Layers with any in-flight triggers.
|
||||||
|
func play_fx() -> void:
|
||||||
|
if _core: _core.play_fx();
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://gt0jwyt1aexo
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
@tool
|
||||||
|
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||||
|
class_name DynamicSoundFXPlayer2D
|
||||||
|
## 2D positional variant of [DynamicSoundFXPlayer].
|
||||||
|
##
|
||||||
|
## Each [method play_fx] call triggers a fresh play of the assigned
|
||||||
|
## [DynamicSound], layered with any plays still in flight, distance-attenuated
|
||||||
|
## from a 2D position relative to the listener.
|
||||||
|
extends AudioStreamPlayer2D
|
||||||
|
|
||||||
|
## The [DynamicSound] to trigger. Each [method play_fx] call plays a fresh instance.
|
||||||
|
@export var Sound : DynamicSound;
|
||||||
|
|
||||||
|
## Volume mix between intensity layers. Only blended when all three layers are set.
|
||||||
|
@export_range(0, 1) var Intensity : float = 0;
|
||||||
|
|
||||||
|
var _core : DynamicSoundFXPlayerCore;
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
if Engine.is_editor_hint() and stream == null:
|
||||||
|
stream = AudioStreamPolyphonic.new();
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_core = DynamicSoundFXPlayerCore.new(self);
|
||||||
|
_core.ready();
|
||||||
|
|
||||||
|
## Trigger a one-shot of [member Sound]. Layers with any in-flight triggers.
|
||||||
|
func play_fx() -> void:
|
||||||
|
if _core: _core.play_fx();
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cwd887g4jf2nu
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
@tool
|
||||||
|
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||||
|
class_name DynamicSoundFXPlayer3D
|
||||||
|
## 3D positional variant of [DynamicSoundFXPlayer].
|
||||||
|
##
|
||||||
|
## Each [method play_fx] call triggers a fresh play of the assigned
|
||||||
|
## [DynamicSound], layered with any plays still in flight, fully spatialised
|
||||||
|
## with distance attenuation, directionality, and Doppler.
|
||||||
|
extends AudioStreamPlayer3D
|
||||||
|
|
||||||
|
## The [DynamicSound] to trigger. Each [method play_fx] call plays a fresh instance.
|
||||||
|
@export var Sound : DynamicSound;
|
||||||
|
|
||||||
|
## Volume mix between intensity layers. Only blended when all three layers are set.
|
||||||
|
@export_range(0, 1) var Intensity : float = 0;
|
||||||
|
|
||||||
|
var _core : DynamicSoundFXPlayerCore;
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
if Engine.is_editor_hint() and stream == null:
|
||||||
|
stream = AudioStreamPolyphonic.new();
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_core = DynamicSoundFXPlayerCore.new(self);
|
||||||
|
_core.ready();
|
||||||
|
|
||||||
|
## Trigger a one-shot of [member Sound]. Layers with any in-flight triggers.
|
||||||
|
func play_fx() -> void:
|
||||||
|
if _core: _core.play_fx();
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://h131u8q1cumh
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
class_name DynamicSoundFXPlayerCore
|
||||||
|
## Shared logic for the one-shot [DynamicSoundFXPlayer] family.
|
||||||
|
##
|
||||||
|
## 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].
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
var _player;
|
||||||
|
var _playback : AudioStreamPlaybackPolyphonic;
|
||||||
|
|
||||||
|
func _init(player) -> void:
|
||||||
|
_player = player;
|
||||||
|
|
||||||
|
func ready() -> void:
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
return;
|
||||||
|
_player.play();
|
||||||
|
_playback = _player.get_stream_playback();
|
||||||
|
|
||||||
|
func play_fx() -> void:
|
||||||
|
var sound : DynamicSound = _player.Sound;
|
||||||
|
if sound == null or _playback == null:
|
||||||
|
return;
|
||||||
|
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]);
|
||||||
|
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));
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dcli0jyeagogq
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
@tool
|
||||||
|
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||||
|
class_name DynamicSoundPlayer
|
||||||
|
## Continuous music player with intensity-blended layers and seamless playlist transitions.
|
||||||
|
##
|
||||||
|
## Plays through a [DynamicSoundPlaylist] one [DynamicSound] at a time. Each
|
||||||
|
## [DynamicSound] has up to three intensity layers that play simultaneously and
|
||||||
|
## are mixed by the [member Intensity] property at runtime.
|
||||||
|
extends AudioStreamPlayer
|
||||||
|
|
||||||
|
|
||||||
|
## Songs to play in order. Mutated as songs finish: each finished song is
|
||||||
|
## popped, and re-appended at the back when [member Loop] is on.
|
||||||
|
@export var Playlist : DynamicSoundPlaylist;
|
||||||
|
|
||||||
|
## Runtime mix between the three intensity layers. [code]0[/code] = least intense, [code]1[/code] = most intense.
|
||||||
|
@export_range(0, 1) var Intensity : float = 0:
|
||||||
|
set(value):
|
||||||
|
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 playback pitch is selected.
|
||||||
|
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
|
||||||
|
set(value):
|
||||||
|
PitchMode = 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;
|
||||||
|
|
||||||
|
var _core : DynamicSoundPlayerCore;
|
||||||
|
|
||||||
|
func _set(property: StringName, value: Variant) -> bool:
|
||||||
|
if property == &"volume_db":
|
||||||
|
if _core: _core.apply_stream_volumes(value);
|
||||||
|
return false;
|
||||||
|
return false;
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
if Engine.is_editor_hint() and stream == null:
|
||||||
|
stream = AudioStreamPolyphonic.new();
|
||||||
|
|
||||||
|
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 = DynamicSoundPlayerCore.new(self);
|
||||||
|
_core.ready();
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if _core: _core.process(delta);
|
||||||
|
|
||||||
|
## Pause playback. Call [method resume] to continue.
|
||||||
|
func pause() -> void:
|
||||||
|
if _core: _core.pause();
|
||||||
|
|
||||||
|
## Resume playback after a [method pause].
|
||||||
|
func resume() -> void:
|
||||||
|
if _core: _core.resume();
|
||||||
|
|
||||||
|
## True if a song is currently playing (and not paused). Distinct from [method is_playing],
|
||||||
|
## which stays true between songs because the polyphonic playback session is still active.
|
||||||
|
func is_song_playing() -> bool:
|
||||||
|
return _core != null and _core.is_song_playing();
|
||||||
|
|
||||||
|
## Start playback (if needed) and fade volume up from silent over [param duration] seconds.
|
||||||
|
func fade_in(duration: float) -> void:
|
||||||
|
if _core: _core.fade_in(duration);
|
||||||
|
|
||||||
|
## Fade volume to silent over [param duration] seconds, then stop playback.
|
||||||
|
func fade_out(duration: float) -> void:
|
||||||
|
if _core: _core.fade_out(duration);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dsy7nja77teiq
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
@tool
|
||||||
|
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||||
|
class_name DynamicSoundPlayer2D
|
||||||
|
## 2D positional variant of [DynamicSoundPlayer].
|
||||||
|
##
|
||||||
|
## Same playlist, intensity, fade, and pause behaviour — distance-attenuated
|
||||||
|
## from a 2D position relative to the listener.
|
||||||
|
extends AudioStreamPlayer2D
|
||||||
|
|
||||||
|
|
||||||
|
## Songs to play in order. Mutated as songs finish: each finished song is
|
||||||
|
## popped, and re-appended at the back when [member Loop] is on.
|
||||||
|
@export var Playlist : DynamicSoundPlaylist;
|
||||||
|
|
||||||
|
## Runtime mix between the three intensity layers. [code]0[/code] = least intense, [code]1[/code] = most intense.
|
||||||
|
@export_range(0, 1) var Intensity : float = 0:
|
||||||
|
set(value):
|
||||||
|
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 playback pitch is selected.
|
||||||
|
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
|
||||||
|
set(value):
|
||||||
|
PitchMode = 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;
|
||||||
|
|
||||||
|
var _core : DynamicSoundPlayerCore;
|
||||||
|
|
||||||
|
func _set(property: StringName, value: Variant) -> bool:
|
||||||
|
if property == &"volume_db":
|
||||||
|
if _core: _core.apply_stream_volumes(value);
|
||||||
|
return false;
|
||||||
|
return false;
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
if Engine.is_editor_hint() and stream == null:
|
||||||
|
stream = AudioStreamPolyphonic.new();
|
||||||
|
|
||||||
|
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 = DynamicSoundPlayerCore.new(self);
|
||||||
|
_core.ready();
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if _core: _core.process(delta);
|
||||||
|
|
||||||
|
## Pause playback. Call [method resume] to continue.
|
||||||
|
func pause() -> void:
|
||||||
|
if _core: _core.pause();
|
||||||
|
|
||||||
|
## Resume playback after a [method pause].
|
||||||
|
func resume() -> void:
|
||||||
|
if _core: _core.resume();
|
||||||
|
|
||||||
|
## True if a song is currently playing (and not paused).
|
||||||
|
func is_song_playing() -> bool:
|
||||||
|
return _core != null and _core.is_song_playing();
|
||||||
|
|
||||||
|
## Start playback (if needed) and fade volume up from silent over [param duration] seconds.
|
||||||
|
func fade_in(duration: float) -> void:
|
||||||
|
if _core: _core.fade_in(duration);
|
||||||
|
|
||||||
|
## Fade volume to silent over [param duration] seconds, then stop playback.
|
||||||
|
func fade_out(duration: float) -> void:
|
||||||
|
if _core: _core.fade_out(duration);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://blrdw81nc8yjx
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
@tool
|
||||||
|
@icon("res://addons/gd-dynamic-sound/icon.svg")
|
||||||
|
class_name DynamicSoundPlayer3D
|
||||||
|
## 3D positional variant of [DynamicSoundPlayer].
|
||||||
|
##
|
||||||
|
## Same playlist, intensity, fade, and pause behaviour — fully spatialised with
|
||||||
|
## distance attenuation, directionality, and Doppler.
|
||||||
|
extends AudioStreamPlayer3D
|
||||||
|
|
||||||
|
|
||||||
|
## Songs to play in order. Mutated as songs finish: each finished song is
|
||||||
|
## popped, and re-appended at the back when [member Loop] is on.
|
||||||
|
@export var Playlist : DynamicSoundPlaylist;
|
||||||
|
|
||||||
|
## Runtime mix between the three intensity layers. [code]0[/code] = least intense, [code]1[/code] = most intense.
|
||||||
|
@export_range(0, 1) var Intensity : float = 0:
|
||||||
|
set(value):
|
||||||
|
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 playback pitch is selected.
|
||||||
|
@export var PitchMode : DynamicSoundConstants.PitchMode = DynamicSoundConstants.PitchMode.Constant:
|
||||||
|
set(value):
|
||||||
|
PitchMode = 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;
|
||||||
|
|
||||||
|
var _core : DynamicSoundPlayerCore;
|
||||||
|
|
||||||
|
func _set(property: StringName, value: Variant) -> bool:
|
||||||
|
if property == &"volume_db":
|
||||||
|
if _core: _core.apply_stream_volumes(value);
|
||||||
|
return false;
|
||||||
|
return false;
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
if Engine.is_editor_hint() and stream == null:
|
||||||
|
stream = AudioStreamPolyphonic.new();
|
||||||
|
|
||||||
|
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 = DynamicSoundPlayerCore.new(self);
|
||||||
|
_core.ready();
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if _core: _core.process(delta);
|
||||||
|
|
||||||
|
## Pause playback. Call [method resume] to continue.
|
||||||
|
func pause() -> void:
|
||||||
|
if _core: _core.pause();
|
||||||
|
|
||||||
|
## Resume playback after a [method pause].
|
||||||
|
func resume() -> void:
|
||||||
|
if _core: _core.resume();
|
||||||
|
|
||||||
|
## True if a song is currently playing (and not paused).
|
||||||
|
func is_song_playing() -> bool:
|
||||||
|
return _core != null and _core.is_song_playing();
|
||||||
|
|
||||||
|
## Start playback (if needed) and fade volume up from silent over [param duration] seconds.
|
||||||
|
func fade_in(duration: float) -> void:
|
||||||
|
if _core: _core.fade_in(duration);
|
||||||
|
|
||||||
|
## Fade volume to silent over [param duration] seconds, then stop playback.
|
||||||
|
func fade_out(duration: float) -> void:
|
||||||
|
if _core: _core.fade_out(duration);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://doxkfmr2k6nxa
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
class_name DynamicSoundPlayerCore
|
||||||
|
## Shared logic for the continuous [DynamicSoundPlayer] family.
|
||||||
|
##
|
||||||
|
## 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].
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const _FADE_SILENT_DB : float = -80.0;
|
||||||
|
|
||||||
|
var _player;
|
||||||
|
|
||||||
|
var Ids : Array[int];
|
||||||
|
var _playback : AudioStreamPlaybackPolyphonic;
|
||||||
|
var _song_length : float = 0.0;
|
||||||
|
var _song_elapsed : float = 0.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;
|
||||||
|
|
||||||
|
func _init(player) -> void:
|
||||||
|
_player = player;
|
||||||
|
|
||||||
|
func ready() -> void:
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
return;
|
||||||
|
var playlist : DynamicSoundPlaylist = _player.Playlist;
|
||||||
|
if playlist == null or playlist.QueuedSongs.is_empty():
|
||||||
|
return;
|
||||||
|
_player.play();
|
||||||
|
_playback = _player.get_stream_playback();
|
||||||
|
_play_song(playlist.QueuedSongs[0]);
|
||||||
|
|
||||||
|
func process(delta: float) -> void:
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
return;
|
||||||
|
if _playback == null or _paused:
|
||||||
|
return;
|
||||||
|
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);
|
||||||
|
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();
|
||||||
|
|
||||||
|
func apply_stream_volumes(value: float) -> void:
|
||||||
|
if _playback == null:
|
||||||
|
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)));
|
||||||
|
|
||||||
|
func pause() -> void:
|
||||||
|
if _playback == null or _paused:
|
||||||
|
return;
|
||||||
|
_player.stream_paused = true;
|
||||||
|
_paused = true;
|
||||||
|
|
||||||
|
func resume() -> void:
|
||||||
|
if not _paused:
|
||||||
|
return;
|
||||||
|
_player.stream_paused = false;
|
||||||
|
_paused = false;
|
||||||
|
|
||||||
|
func is_song_playing() -> bool:
|
||||||
|
return not Ids.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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
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]);
|
||||||
|
|
||||||
|
func _play_song(song : DynamicSound) -> void:
|
||||||
|
if song.Intensity1 != null:
|
||||||
|
Ids.append(_playback.play_stream(song.Intensity1));
|
||||||
|
if song.Intensity2 != null:
|
||||||
|
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);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dxxvkqg40xqt5
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
@icon("res://addons/gd-dynamic-audio/src/dynamic_sound_playlist_icon.png")
|
||||||
|
@tool
|
||||||
|
class_name DynamicSoundPlaylist
|
||||||
|
## The DynamicSound Holds onto all your music variants.
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
@export_category("Playlist")
|
||||||
|
@export var QueuedSongs : Array[DynamicSound];
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://0xy3r2caqdec
|
||||||
Loading…
Reference in New Issue