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;