class_name AudioLayerController extends Node ## Presentation-layer dual music loops (AnchorV1.0 chapter 20). ## Past and Future layers start together and stay in sync forever; a phase ## switch only crossfades volumes. The clock authority stays with ## RhythmManager — drift correction seeks THESE players, never the clock. ## ## Placeholder assets: both layers use the same song; the Future layer is ## routed through a low-pass bus so the switch is audible until a real ## Future loop asset lands (BPM/beat-aligned per the design doc). const FUTURE_BUS_NAME := "TimePhaseFuture" const MUSIC_BUS_NAME := "Music" @export var past_stream: AudioStream @export var future_stream: AudioStream @export var default_stream_path := "res://assets/audio/ev_past1.mp3" @export var crossfade_seconds := 0.35 @export var active_volume_db := 0.0 @export var inactive_volume_db := -60.0 @export var drift_check_interval := 2.0 @export var drift_threshold_seconds := 0.015 @export var mute_clock := true @export var future_lowpass_cutoff_hz := 2200.0 var past_player: AudioStreamPlayer var future_player: AudioStreamPlayer var _fade_tween: Tween var _drift_accum := 0.0 func _ready() -> void: if DisplayServer.get_name() == "headless": return if past_stream == null and ResourceLoader.exists(default_stream_path): past_stream = load(default_stream_path) if future_stream == null: future_stream = past_stream _ensure_future_bus() # 双音轨走 Music 总线(Future 层经 TimePhaseFuture 低通后 send 到 Music), # 与标题音乐一致,音乐音量设置才能同时约束标题与局内音乐。 past_player = _make_layer_player("PastLoop", past_stream, MUSIC_BUS_NAME if AudioServer.get_bus_index(MUSIC_BUS_NAME) != -1 else "Master") future_player = _make_layer_player("FutureLoop", future_stream, FUTURE_BUS_NAME) var bus := _event_bus_or_null() if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): bus.connect("time_phase_changed", _on_time_phase_changed) _start_layers() _apply_phase_volumes(_current_time_phase(), true) if mute_clock: var rhythm := _rhythm_manager_or_null() if rhythm != null: rhythm.set("volume_db", -80.0) func _process(delta: float) -> void: if past_player == null: return _drift_accum += delta if _drift_accum < drift_check_interval: return _drift_accum = 0.0 _correct_layer_drift() ## Snaps both layers back onto the clock immediately (a fresh run just ## re-zeroed RhythmManager) instead of waiting for the next drift check. func restart_layers() -> void: _start_layers() _apply_phase_volumes(_current_time_phase(), true) _drift_accum = 0.0 func _start_layers() -> void: var rhythm := _rhythm_manager_or_null() var start_position := 0.0 if rhythm != null and rhythm.has_method("song_position"): start_position = maxf(0.0, float(rhythm.call("song_position"))) for player: AudioStreamPlayer in [past_player, future_player]: if player != null and player.stream != null: player.play(start_position) func _correct_layer_drift() -> void: var rhythm := _rhythm_manager_or_null() if rhythm == null or not rhythm.has_method("song_position"): return if not bool(rhythm.get("running")): return var target := float(rhythm.call("song_position")) for player: AudioStreamPlayer in [past_player, future_player]: if player == null or not player.playing or player.stream == null: continue var length := float(player.stream.get_length()) var wrapped_target := fmod(target, length) if length > 0.0 else target var raw_drift := absf(player.get_playback_position() - wrapped_target) var drift := minf(raw_drift, length - raw_drift) if length > 0.0 else raw_drift if drift > drift_threshold_seconds: player.seek(wrapped_target) func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void: _apply_phase_volumes(current, false) func _apply_phase_volumes(time_phase: StringName, instant: bool) -> void: if past_player == null or future_player == null: return var past_target := active_volume_db if time_phase != &"future" else inactive_volume_db var future_target := active_volume_db if time_phase == &"future" else inactive_volume_db if _fade_tween != null and _fade_tween.is_valid(): _fade_tween.kill() if instant: past_player.volume_db = past_target future_player.volume_db = future_target return _fade_tween = create_tween() _fade_tween.set_parallel(true) _fade_tween.tween_property(past_player, "volume_db", past_target, crossfade_seconds) _fade_tween.tween_property(future_player, "volume_db", future_target, crossfade_seconds) func _make_layer_player(player_name: String, stream: AudioStream, bus_name: String) -> AudioStreamPlayer: var player := AudioStreamPlayer.new() player.name = player_name player.stream = stream player.volume_db = inactive_volume_db if AudioServer.get_bus_index(bus_name) != -1: player.bus = bus_name add_child(player) return player func _ensure_future_bus() -> void: var existing := AudioServer.get_bus_index(FUTURE_BUS_NAME) if existing != -1: _route_bus_to_music(existing) return AudioServer.add_bus() var bus_index := AudioServer.bus_count - 1 AudioServer.set_bus_name(bus_index, FUTURE_BUS_NAME) var lowpass := AudioEffectLowPassFilter.new() lowpass.cutoff_hz = future_lowpass_cutoff_hz AudioServer.add_bus_effect(bus_index, lowpass) _route_bus_to_music(bus_index) func _route_bus_to_music(bus_index: int) -> void: if AudioServer.get_bus_index(MUSIC_BUS_NAME) != -1: AudioServer.set_bus_send(bus_index, MUSIC_BUS_NAME) func _current_time_phase() -> StringName: var manager := get_tree().root.get_node_or_null("TimePhaseManager") if is_inside_tree() else null if manager != null: return StringName(str(manager.get("current_time_phase"))) return &"past" func _rhythm_manager_or_null() -> Node: if not is_inside_tree(): return null return get_tree().root.get_node_or_null("RhythmManager") func _event_bus_or_null() -> Node: if not is_inside_tree(): return null return get_tree().root.get_node_or_null("EventBus")