commit cb47d893779be0c8d6cfaf567c7af9d20f1bda56 Author: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Wed Apr 29 14:46:48 2026 +0100 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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9cdf6eb --- /dev/null +++ b/README.md @@ -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. diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..b10d40c --- /dev/null +++ b/icon.svg.import @@ -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 diff --git a/plugin.cfg b/plugin.cfg new file mode 100644 index 0000000..caadf73 --- /dev/null +++ b/plugin.cfg @@ -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" diff --git a/plugin.gd b/plugin.gd new file mode 100644 index 0000000..4fa978e --- /dev/null +++ b/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 diff --git a/plugin.gd.uid b/plugin.gd.uid new file mode 100644 index 0000000..08af714 --- /dev/null +++ b/plugin.gd.uid @@ -0,0 +1 @@ +uid://clnfrvqcs1sj4 diff --git a/src/DynamicSound.gd b/src/DynamicSound.gd new file mode 100644 index 0000000..6bf516a --- /dev/null +++ b/src/DynamicSound.gd @@ -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; diff --git a/src/DynamicSound.gd.uid b/src/DynamicSound.gd.uid new file mode 100644 index 0000000..b90be05 --- /dev/null +++ b/src/DynamicSound.gd.uid @@ -0,0 +1 @@ +uid://dfma4so3silpl diff --git a/src/DynamicSoundConstants.gd b/src/DynamicSoundConstants.gd new file mode 100644 index 0000000..6a625b2 --- /dev/null +++ b/src/DynamicSoundConstants.gd @@ -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 +} diff --git a/src/DynamicSoundConstants.gd.uid b/src/DynamicSoundConstants.gd.uid new file mode 100644 index 0000000..9900ba2 --- /dev/null +++ b/src/DynamicSoundConstants.gd.uid @@ -0,0 +1 @@ +uid://b6uidt4yqrvqa diff --git a/src/DynamicSoundFXPlayer.gd b/src/DynamicSoundFXPlayer.gd new file mode 100644 index 0000000..af71ed1 --- /dev/null +++ b/src/DynamicSoundFXPlayer.gd @@ -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(); diff --git a/src/DynamicSoundFXPlayer.gd.uid b/src/DynamicSoundFXPlayer.gd.uid new file mode 100644 index 0000000..df7fb4a --- /dev/null +++ b/src/DynamicSoundFXPlayer.gd.uid @@ -0,0 +1 @@ +uid://gt0jwyt1aexo diff --git a/src/DynamicSoundFXPlayer2d.gd b/src/DynamicSoundFXPlayer2d.gd new file mode 100644 index 0000000..0ff84e8 --- /dev/null +++ b/src/DynamicSoundFXPlayer2d.gd @@ -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(); diff --git a/src/DynamicSoundFXPlayer2d.gd.uid b/src/DynamicSoundFXPlayer2d.gd.uid new file mode 100644 index 0000000..b64a6cc --- /dev/null +++ b/src/DynamicSoundFXPlayer2d.gd.uid @@ -0,0 +1 @@ +uid://cwd887g4jf2nu diff --git a/src/DynamicSoundFXPlayer3d.gd b/src/DynamicSoundFXPlayer3d.gd new file mode 100644 index 0000000..e21220d --- /dev/null +++ b/src/DynamicSoundFXPlayer3d.gd @@ -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(); diff --git a/src/DynamicSoundFXPlayer3d.gd.uid b/src/DynamicSoundFXPlayer3d.gd.uid new file mode 100644 index 0000000..5de66e3 --- /dev/null +++ b/src/DynamicSoundFXPlayer3d.gd.uid @@ -0,0 +1 @@ +uid://h131u8q1cumh diff --git a/src/DynamicSoundFXPlayerCore.gd b/src/DynamicSoundFXPlayerCore.gd new file mode 100644 index 0000000..e80f770 --- /dev/null +++ b/src/DynamicSoundFXPlayerCore.gd @@ -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)); diff --git a/src/DynamicSoundFXPlayerCore.gd.uid b/src/DynamicSoundFXPlayerCore.gd.uid new file mode 100644 index 0000000..33661ee --- /dev/null +++ b/src/DynamicSoundFXPlayerCore.gd.uid @@ -0,0 +1 @@ +uid://dcli0jyeagogq diff --git a/src/DynamicSoundPlayer.gd b/src/DynamicSoundPlayer.gd new file mode 100644 index 0000000..827754e --- /dev/null +++ b/src/DynamicSoundPlayer.gd @@ -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); diff --git a/src/DynamicSoundPlayer.gd.uid b/src/DynamicSoundPlayer.gd.uid new file mode 100644 index 0000000..b22945c --- /dev/null +++ b/src/DynamicSoundPlayer.gd.uid @@ -0,0 +1 @@ +uid://dsy7nja77teiq diff --git a/src/DynamicSoundPlayer2d.gd b/src/DynamicSoundPlayer2d.gd new file mode 100644 index 0000000..61ab21e --- /dev/null +++ b/src/DynamicSoundPlayer2d.gd @@ -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); diff --git a/src/DynamicSoundPlayer2d.gd.uid b/src/DynamicSoundPlayer2d.gd.uid new file mode 100644 index 0000000..c91a07b --- /dev/null +++ b/src/DynamicSoundPlayer2d.gd.uid @@ -0,0 +1 @@ +uid://blrdw81nc8yjx diff --git a/src/DynamicSoundPlayer3d.gd b/src/DynamicSoundPlayer3d.gd new file mode 100644 index 0000000..e294db2 --- /dev/null +++ b/src/DynamicSoundPlayer3d.gd @@ -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); diff --git a/src/DynamicSoundPlayer3d.gd.uid b/src/DynamicSoundPlayer3d.gd.uid new file mode 100644 index 0000000..62dcd56 --- /dev/null +++ b/src/DynamicSoundPlayer3d.gd.uid @@ -0,0 +1 @@ +uid://doxkfmr2k6nxa diff --git a/src/DynamicSoundPlayerCore.gd b/src/DynamicSoundPlayerCore.gd new file mode 100644 index 0000000..1a1d17d --- /dev/null +++ b/src/DynamicSoundPlayerCore.gd @@ -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); diff --git a/src/DynamicSoundPlayerCore.gd.uid b/src/DynamicSoundPlayerCore.gd.uid new file mode 100644 index 0000000..ad3ad59 --- /dev/null +++ b/src/DynamicSoundPlayerCore.gd.uid @@ -0,0 +1 @@ +uid://dxxvkqg40xqt5 diff --git a/src/DynamicSoundPlaylist.gd b/src/DynamicSoundPlaylist.gd new file mode 100644 index 0000000..efc67ec --- /dev/null +++ b/src/DynamicSoundPlaylist.gd @@ -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]; diff --git a/src/DynamicSoundPlaylist.gd.uid b/src/DynamicSoundPlaylist.gd.uid new file mode 100644 index 0000000..1749969 --- /dev/null +++ b/src/DynamicSoundPlaylist.gd.uid @@ -0,0 +1 @@ +uid://0xy3r2caqdec