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>
This commit is contained in:
Daniel Samson
2026-04-29 15:03:03 +01:00
co-authored by Claude Opus 4.7
parent 7f227acb6a
commit dd805a4cd9
8 changed files with 67 additions and 56 deletions
+9 -5
View File
@@ -50,11 +50,8 @@ Plays through a playlist one song at a time, blending three intensity layers per
| `Playlist` | `DynamicSoundPlaylist` | `null` | Songs to play in order. Mutated as songs finish. | | `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. | | `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. | | `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. 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 #### Methods
@@ -83,12 +80,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. | | `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. | | `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 #### Methods
| Method | Description | | 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 #### Example: collision SFX
+16
View File
@@ -15,6 +15,17 @@ extends AudioStreamPlayer
## Volume mix between intensity layers. Only blended when all three layers are set. ## Volume mix between intensity layers. Only blended when all three layers are set.
@export_range(0, 1) var Intensity : float = 0; @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; var _core : DynamicSoundFXPlayerCore;
func _enter_tree() -> void: func _enter_tree() -> void:
@@ -25,6 +36,11 @@ func _enter_tree() -> void:
if bus == &"Master" and AudioServer.get_bus_index("FX") != -1: if bus == &"Master" and AudioServer.get_bus_index("FX") != -1:
bus = "FX"; 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: func _ready() -> void:
_core = DynamicSoundFXPlayerCore.new(self); _core = DynamicSoundFXPlayerCore.new(self);
_core.ready(); _core.ready();
+16
View File
@@ -14,6 +14,17 @@ extends AudioStreamPlayer2D
## Volume mix between intensity layers. Only blended when all three layers are set. ## Volume mix between intensity layers. Only blended when all three layers are set.
@export_range(0, 1) var Intensity : float = 0; @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; var _core : DynamicSoundFXPlayerCore;
func _enter_tree() -> void: func _enter_tree() -> void:
@@ -24,6 +35,11 @@ func _enter_tree() -> void:
if bus == &"Master" and AudioServer.get_bus_index("FX") != -1: if bus == &"Master" and AudioServer.get_bus_index("FX") != -1:
bus = "FX"; 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: func _ready() -> void:
_core = DynamicSoundFXPlayerCore.new(self); _core = DynamicSoundFXPlayerCore.new(self);
_core.ready(); _core.ready();
+16
View File
@@ -14,6 +14,17 @@ extends AudioStreamPlayer3D
## Volume mix between intensity layers. Only blended when all three layers are set. ## Volume mix between intensity layers. Only blended when all three layers are set.
@export_range(0, 1) var Intensity : float = 0; @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; var _core : DynamicSoundFXPlayerCore;
func _enter_tree() -> void: func _enter_tree() -> void:
@@ -24,6 +35,11 @@ func _enter_tree() -> void:
if bus == &"Master" and AudioServer.get_bus_index("FX") != -1: if bus == &"Master" and AudioServer.get_bus_index("FX") != -1:
bus = "FX"; 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: func _ready() -> void:
_core = DynamicSoundFXPlayerCore.new(self); _core = DynamicSoundFXPlayerCore.new(self);
_core.ready(); _core.ready();
+10 -3
View File
@@ -3,8 +3,9 @@ class_name DynamicSoundFXPlayerCore
## ##
## Owned by [DynamicSoundFXPlayer], [DynamicSoundFXPlayer2D], and [DynamicSoundFXPlayer3D]. ## Owned by [DynamicSoundFXPlayer], [DynamicSoundFXPlayer2D], and [DynamicSoundFXPlayer3D].
## Operates on its owner via duck-typed property access — the owner must expose ## 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], ## [code]Sound[/code], [code]Intensity[/code], [code]PitchMode[/code],
## and [code]get_stream_playback()[/code]. ## [code]RandomMinPitchScale[/code], [code]RandomMaxPitchScale[/code],
## [code]volume_db[/code], [code]play()[/code], and [code]get_stream_playback()[/code].
extends RefCounted extends RefCounted
var _player; var _player;
@@ -23,14 +24,20 @@ func play_fx() -> void:
var sound : DynamicSound = _player.Sound; var sound : DynamicSound = _player.Sound;
if sound == null or _playback == null: if sound == null or _playback == null:
return; return;
var pitch : float = _resolve_pitch();
var layers : Array[AudioStream] = []; var layers : Array[AudioStream] = [];
if sound.Intensity1 != null: layers.append(sound.Intensity1); if sound.Intensity1 != null: layers.append(sound.Intensity1);
if sound.Intensity2 != null: layers.append(sound.Intensity2); if sound.Intensity2 != null: layers.append(sound.Intensity2);
if sound.Intensity3 != null: layers.append(sound.Intensity3); if sound.Intensity3 != null: layers.append(sound.Intensity3);
var real_intensity : float = _player.Intensity if layers.size() == 3 else 0.0; var real_intensity : float = _player.Intensity if layers.size() == 3 else 0.0;
for i in layers.size(): 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: if id == AudioStreamPlaybackPolyphonic.INVALID_ID:
continue; continue;
var vol_lin : float = max((.5 - abs(float(i) / 2 - real_intensity)) / .5, 0.0); 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)); _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;
-16
View File
@@ -22,17 +22,6 @@ extends AudioStreamPlayer
## When true, finished songs are re-appended to the back of the queue so the playlist cycles forever. ## When true, finished songs are re-appended to the back of the queue so the playlist cycles forever.
@export var Loop : bool; @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; var _core : DynamicSoundPlayerCore;
func _set(property: StringName, value: Variant) -> bool: func _set(property: StringName, value: Variant) -> bool:
@@ -49,11 +38,6 @@ func _enter_tree() -> void:
if bus == &"Master" and AudioServer.get_bus_index("Music") != -1: if bus == &"Master" and AudioServer.get_bus_index("Music") != -1:
bus = "Music"; bus = "Music";
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: func _ready() -> void:
_core = DynamicSoundPlayerCore.new(self); _core = DynamicSoundPlayerCore.new(self);
_core.ready(); _core.ready();
-16
View File
@@ -21,17 +21,6 @@ extends AudioStreamPlayer2D
## When true, finished songs are re-appended to the back of the queue so the playlist cycles forever. ## When true, finished songs are re-appended to the back of the queue so the playlist cycles forever.
@export var Loop : bool; @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; var _core : DynamicSoundPlayerCore;
func _set(property: StringName, value: Variant) -> bool: func _set(property: StringName, value: Variant) -> bool:
@@ -48,11 +37,6 @@ func _enter_tree() -> void:
if bus == &"Master" and AudioServer.get_bus_index("Music") != -1: if bus == &"Master" and AudioServer.get_bus_index("Music") != -1:
bus = "Music"; bus = "Music";
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: func _ready() -> void:
_core = DynamicSoundPlayerCore.new(self); _core = DynamicSoundPlayerCore.new(self);
_core.ready(); _core.ready();
-16
View File
@@ -21,17 +21,6 @@ extends AudioStreamPlayer3D
## When true, finished songs are re-appended to the back of the queue so the playlist cycles forever. ## When true, finished songs are re-appended to the back of the queue so the playlist cycles forever.
@export var Loop : bool; @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; var _core : DynamicSoundPlayerCore;
func _set(property: StringName, value: Variant) -> bool: func _set(property: StringName, value: Variant) -> bool:
@@ -48,11 +37,6 @@ func _enter_tree() -> void:
if bus == &"Master" and AudioServer.get_bus_index("Music") != -1: if bus == &"Master" and AudioServer.get_bus_index("Music") != -1:
bus = "Music"; bus = "Music";
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: func _ready() -> void:
_core = DynamicSoundPlayerCore.new(self); _core = DynamicSoundPlayerCore.new(self);
_core.ready(); _core.ready();