extends Node ## Two arrangements share one sample clock; changing layer never restarts music. const FRONT_PATH := "res://assets/audio/bgm/tree_theme_front_candidate.wav" const BACK_PATH := "res://assets/audio/bgm/tree_theme_back_candidate.wav" const CROSSFADE_SECONDS := 1.25 var mix := 0.0 var target_mix := 0.0 var playback_starts := 0 var startup_error := "" var _start_mix := 0.0 var _elapsed := CROSSFADE_SECONDS var _player: AudioStreamPlayer var _synchronized: AudioStreamSynchronized var _closed := false func configure(initial_layer: int) -> void: mix = float(clampi(initial_layer, 0, 1)) target_mix = mix _start_mix = mix func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS var front := ResourceLoader.load(FRONT_PATH, "AudioStreamWAV", ResourceLoader.CACHE_MODE_IGNORE) as AudioStreamWAV var back := ResourceLoader.load(BACK_PATH, "AudioStreamWAV", ResourceLoader.CACHE_MODE_IGNORE) as AudioStreamWAV if front == null or back == null: startup_error = "MISSING_PAIRED_BGM" return if front.format != AudioStreamWAV.FORMAT_16_BITS or back.format != front.format or not front.stereo or not back.stereo or front.mix_rate != back.mix_rate or front.data.size() != back.data.size() or front.data.is_empty(): startup_error = "INCOMPATIBLE_PAIRED_BGM" return for stream: AudioStreamWAV in [front, back]: stream.loop_mode = AudioStreamWAV.LOOP_FORWARD stream.loop_begin = 0 stream.loop_end = int(stream.data.size() / 4.0) _synchronized = AudioStreamSynchronized.new() _synchronized.stream_count = 2 _synchronized.set_sync_stream(0, front) _synchronized.set_sync_stream(1, back) _apply_mix() _player = AudioStreamPlayer.new() _player.name = "PairedBGMPlayer" _player.process_mode = Node.PROCESS_MODE_ALWAYS _player.bus = &"Master" _player.stream = _synchronized add_child(_player) _player.play() playback_starts += 1 func set_layer(layer: int) -> void: if _closed: return var destination := float(clampi(layer, 0, 1)) if destination == target_mix: return _start_mix = mix target_mix = destination _elapsed = 0.0 func _process(delta: float) -> void: if _closed or _elapsed >= CROSSFADE_SECONDS or not is_finite(delta) or delta <= 0.0: return _elapsed = minf(CROSSFADE_SECONDS, _elapsed + delta) mix = lerpf(_start_mix, target_mix, _elapsed / CROSSFADE_SECONDS) _apply_mix() func _apply_mix() -> void: if _synchronized == null: return var front_gain := cos(mix * PI / 2.0) var back_gain := sin(mix * PI / 2.0) _synchronized.set_sync_stream_volume(0, linear_to_db(front_gain) if front_gain > 0.0001 else -80.0) _synchronized.set_sync_stream_volume(1, linear_to_db(back_gain) if back_gain > 0.0001 else -80.0) func capture_owned_targets() -> Array[Dictionary]: var result: Array[Dictionary] = [] for entry: Array in [[&"bgm_owner", self], [&"bgm_player", _player], [&"bgm_synchronized", _synchronized]]: if is_instance_valid(entry[1]): result.append({&"role": &"bgm", &"kind": entry[0], &"weak": weakref(entry[1]), &"instance_id": entry[1].get_instance_id()}) if _synchronized != null: for index: int in range(2): var stream := _synchronized.get_sync_stream(index) if stream != null: result.append({&"role": &"front" if index == 0 else &"back", &"kind": &"bgm_stream", &"weak": weakref(stream), &"instance_id": stream.get_instance_id()}) if is_instance_valid(_player) and _player.has_stream_playback(): var playback := _player.get_stream_playback() result.append({&"role": &"bgm", &"kind": &"bgm_playback", &"weak": weakref(playback), &"instance_id": playback.get_instance_id()}) return result func prepare_owner_shutdown() -> void: _closed = true set_process(false) if is_instance_valid(_player): _player.stop() _player.stream = null _synchronized = null func _exit_tree() -> void: prepare_owner_shutdown()