From 59f1d93c6f19e74f865fd313bb0dbc4bb03402d3 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:36:50 +0100 Subject: [PATCH] validate that intensity layers have matching lengths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three IntensityN streams of a DynamicSound play in parallel, and the player keys end-of-song timing off Intensity1's length — mismatched lengths cause silent gaps in the higher intensity layers and broken advancement. DynamicSound: - Setters on Intensity1/2/3 push_warning when assigning a stream whose length doesn't match the others (editor-time feedback). - Public has_matching_layer_lengths() -> bool for callers wanting to validate songs themselves. DynamicSoundPlayerCore: - _start_song_at_index pushes a runtime error if the song fails the check before queuing its layers on the polyphonic playback. The FX core deliberately doesn't add a runtime check — play_fx fires per trigger and would spam, while the editor warning already covers authoring mistakes. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 ++++-- src/DynamicSound.gd | 58 ++++++++++++++++++++++++++++++----- src/DynamicSoundPlayerCore.gd | 3 ++ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fc56adb..a41c5ef 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,17 @@ A ready-made layout containing both buses (each routing into `Master`) ships wit A single piece of audio with up to three intensity layers that play simultaneously and are blended at runtime. +> **All assigned layers must have identical lengths.** The three streams play in parallel; if `Intensity2` ends a beat earlier than `Intensity1`, the blend becomes silence on that layer for the remainder of the song and the player's end-of-song timing (which keys off `Intensity1.get_length()`) misfires. The resource pushes a warning in the editor when you assign mismatched-length streams, and `DynamicSoundPlayer` pushes a runtime error when it tries to play one. Trim or pad your stems to match before exporting. + | Property | Type | Description | | --- | --- | --- | -| `Intensity1` | `AudioStream` | Least-intense layer (e.g. ambient). Required for length tracking. | -| `Intensity2` | `AudioStream` | Mid-intensity layer. Optional. | -| `Intensity3` | `AudioStream` | Most-intense layer. Optional. | +| `Intensity1` | `AudioStream` | Least-intense layer (e.g. ambient). Required — the player uses its length to time song advancement. | +| `Intensity2` | `AudioStream` | Mid-intensity layer. Optional. Must match `Intensity1`'s length if assigned. | +| `Intensity3` | `AudioStream` | Most-intense layer. Optional. Must match `Intensity1`'s length if assigned. | | `ReverbTail` | `float` | Length (seconds) of the song's audio that is pure reverb tail. The next song is started this far ahead of the current song's full length, so the new opening covers the outgoing tail without a perceptible gap. Set to `0` to disable overlap (hard cut). | +The resource exposes `has_matching_layer_lengths() -> bool` if you want to validate songs yourself (e.g. on load, in tests). + ### `DynamicSoundPlaylist` (Resource) An ordered list of `DynamicSound`s. diff --git a/src/DynamicSound.gd b/src/DynamicSound.gd index 02b0e85..ecd70d7 100644 --- a/src/DynamicSound.gd +++ b/src/DynamicSound.gd @@ -1,17 +1,61 @@ @icon("res://addons/gd-dynamic-sound/icon.svg") @tool class_name DynamicSound -## The DynamicSound Holds onto all your music variants. +## A single piece of audio with up to three intensity layers that play simultaneously. +## +## [b]All assigned layers must have identical lengths.[/b] The layers play in +## parallel and are mixed by an [code]Intensity[/code] value at runtime; if their +## lengths differ they desynchronise as the shorter ones end first, breaking the +## blend and the player's end-of-song timing. Both the editor (via property +## setters) and the [DynamicSoundPlayer] family flag mismatched lengths. extends Resource @export_category("Sound Files") -## This should be set to your least intense music file. -@export var Intensity1 : AudioStream; +## This should be set to your least intense music file. Required — the player +## uses its length to time song advancement. +@export var Intensity1 : AudioStream: + set(value): + Intensity1 = value; + _validate_layer_lengths(); ## This should be set to your semi-intense music file. -@export var Intensity2 : AudioStream; +## Must match [member Intensity1]'s length if assigned. +@export var Intensity2 : AudioStream: + set(value): + Intensity2 = value; + _validate_layer_lengths(); ## This should be set to your most intense music file. -@export var Intensity3 : AudioStream; +## Must match [member Intensity1]'s length if assigned. +@export var Intensity3 : AudioStream: + set(value): + Intensity3 = value; + _validate_layer_lengths(); -## Set this to how long the sound should loop over itself. [br] -## This will make any songs with a Reverb Tail loop perfectly. +## Length (seconds) of the song's audio that is pure reverb tail. The next song +## is started this far ahead of the current song's full length so the new +## opening covers the outgoing tail without a perceptible gap. Set to 0 to +## disable overlap (hard cut). @export var ReverbTail : float; + +## Returns true iff every assigned [code]IntensityN[/code] layer has the same length. +## Single-layer or empty sounds always return true. +func has_matching_layer_lengths() -> bool: + var lengths : Array[float] = []; + if Intensity1 != null: lengths.append(Intensity1.get_length()); + if Intensity2 != null: lengths.append(Intensity2.get_length()); + if Intensity3 != null: lengths.append(Intensity3.get_length()); + if lengths.size() < 2: + return true; + for l in lengths: + if not is_equal_approx(l, lengths[0]): + return false; + return true; + +func _validate_layer_lengths() -> void: + if has_matching_layer_lengths(): + return; + var lengths : Array[float] = []; + if Intensity1 != null: lengths.append(Intensity1.get_length()); + if Intensity2 != null: lengths.append(Intensity2.get_length()); + if Intensity3 != null: lengths.append(Intensity3.get_length()); + var path : String = resource_path if not resource_path.is_empty() else ""; + push_warning("DynamicSound %s: intensity layers have different lengths %s — they must match to stay in sync." % [path, lengths]); diff --git a/src/DynamicSoundPlayerCore.gd b/src/DynamicSoundPlayerCore.gd index d1759d0..6ef3440 100644 --- a/src/DynamicSoundPlayerCore.gd +++ b/src/DynamicSoundPlayerCore.gd @@ -216,6 +216,9 @@ func _start_song_at_index(index: int) -> void: var song : DynamicSound = playlist.QueuedSongs[index]; if song == null: return; + if not song.has_matching_layer_lengths(): + var path : String = song.resource_path if not song.resource_path.is_empty() else ""; + push_error("DynamicSound %s: intensity layers must have matching lengths — playback will desync." % path); var active : _ActiveSong = _ActiveSong.new(); active.start_time = _cur_time; active.song_length = song.Intensity1.get_length() if song.Intensity1 != null else 0.0;