Last active
February 3, 2026 23:20
-
-
Save twobob/427492906872aec426fdb1d772962f0d to your computer and use it in GitHub Desktop.
loading and playing from a list of sounds with timed Xfade. provides a very basic test UI for the task. When the fancy meta options are too much for your needs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| extends Node3D | |
| # Enhanced 3D Audio Player with crossfade, debug UI, and auto-setup | |
| # Audio players for crossfading | |
| var audio_player1: AudioStreamPlayer3D | |
| var audio_player2: AudioStreamPlayer3D | |
| var current_player: AudioStreamPlayer3D | |
| var next_player: AudioStreamPlayer3D | |
| # Playlist configuration | |
| var playlist: Array[String] = [ | |
| "res://audio/start.ogg", | |
| "res://audio/run.ogg", | |
| "res://audio/end.ogg" | |
| ] | |
| var current_track: int = 0 | |
| var fade_duration: float = 0.5 | |
| var is_fading: bool = false | |
| var _active_tween: Tween | |
| # UI References | |
| var debug_ui_layer: CanvasLayer | |
| var status_label: Label | |
| var track_label: Label | |
| var progress_bar: ProgressBar | |
| var volume_slider: HSlider | |
| # Audio settings | |
| @export var master_volume_db: float = 0.0: | |
| set(value): | |
| master_volume_db = value | |
| _update_master_volume() | |
| @export var unit_size: float = 10.0 | |
| @export var max_distance: float = 50.0 | |
| @export var autoplay: bool = true | |
| @export var loop_playlist: bool = true | |
| # Low-pass filter settings | |
| var lowpass_filter: AudioEffectLowPassFilter | |
| var lowpass_enabled: bool = false | |
| var lowpass_cutoff_hz: float = 250.0 | |
| var lowpass_db: AudioEffectFilter.FilterDB = AudioEffectFilter.FILTER_24DB | |
| var master_bus_idx: int = -1 | |
| func _ready() -> void: | |
| _ensure_scene_setup() | |
| _create_audio_players() | |
| _setup_lowpass_filter() | |
| _setup_debug_ui() | |
| if autoplay and playlist.size() > 0: | |
| play_track(0) | |
| func _process(_delta: float) -> void: | |
| _update_progress_bar() | |
| func _ensure_scene_setup() -> void: | |
| """Ensures a 3D camera and listener exist for spatial audio""" | |
| var viewport := get_viewport() | |
| var camera := viewport.get_camera_3d() | |
| if not camera: | |
| _update_status("No Camera/Listener found. Creating default...") | |
| var cam := Camera3D.new() | |
| cam.name = "AutoCamera" | |
| cam.position = Vector3(0, 1, 2) | |
| cam.look_at(Vector3.ZERO) | |
| add_child(cam) | |
| var listener := AudioListener3D.new() | |
| listener.name = "AutoListener" | |
| cam.add_child(listener) | |
| listener.make_current() | |
| func _create_audio_players() -> void: | |
| """Creates and configures the dual audio players for crossfading""" | |
| audio_player1 = AudioStreamPlayer3D.new() | |
| audio_player1.name = "AudioPlayer1" | |
| audio_player2 = AudioStreamPlayer3D.new() | |
| audio_player2.name = "AudioPlayer2" | |
| add_child(audio_player1) | |
| add_child(audio_player2) | |
| for player in [audio_player1, audio_player2]: | |
| player.unit_size = unit_size | |
| player.max_distance = max_distance | |
| player.attenuation_model = AudioStreamPlayer3D.ATTENUATION_INVERSE_DISTANCE | |
| player.volume_db = master_volume_db | |
| player.pitch_scale = 1.0 | |
| player.position = Vector3.ZERO | |
| player.bus = "Master" # Explicitly route to Master bus | |
| player.finished.connect(_on_audio_finished.bind(player)) | |
| current_player = audio_player1 | |
| next_player = audio_player2 | |
| next_player.volume_db = -80.0 | |
| func play_track(index: int, fade: bool = false) -> void: | |
| """Play a track from the playlist by index""" | |
| if index < 0 or index >= playlist.size(): | |
| _update_status("Error: Track index out of bounds") | |
| return | |
| current_track = index | |
| var resource_path := playlist[current_track] | |
| if not ResourceLoader.exists(resource_path): | |
| _update_status("Error: File not found - " + resource_path.get_file()) | |
| return | |
| if fade and current_player.playing: | |
| crossfade_to_track(resource_path) | |
| else: | |
| _kill_tween() | |
| is_fading = false | |
| current_player.stop() | |
| current_player.stream = load(resource_path) | |
| current_player.volume_db = master_volume_db | |
| current_player.play() | |
| _update_status("Playing: " + resource_path.get_file()) | |
| _update_track_info() | |
| func crossfade_to_track(audio_path: String) -> void: | |
| """Smoothly crossfade from current track to new track""" | |
| if is_fading: | |
| return | |
| is_fading = true | |
| _update_status("Crossfading to: " + audio_path.get_file()) | |
| next_player.stream = load(audio_path) | |
| next_player.volume_db = -80.0 | |
| next_player.play() | |
| _kill_tween() | |
| _active_tween = create_tween() | |
| _active_tween.set_parallel(true) | |
| if current_player.playing: | |
| _active_tween.tween_property(current_player, "volume_db", -80.0, fade_duration) | |
| _active_tween.tween_property(next_player, "volume_db", master_volume_db, fade_duration) | |
| _active_tween.set_parallel(false) | |
| _active_tween.tween_callback(_on_crossfade_complete) | |
| func _on_crossfade_complete() -> void: | |
| """Swap players after crossfade completes""" | |
| current_player.stop() | |
| var temp := current_player | |
| current_player = next_player | |
| next_player = temp | |
| is_fading = false | |
| _update_status("Playing: " + playlist[current_track].get_file()) | |
| _update_track_info() | |
| func next_track(fade: bool = true) -> void: | |
| """Play next track in playlist""" | |
| if playlist.size() == 0: | |
| return | |
| var next_index := (current_track + 1) % playlist.size() | |
| if next_index == 0 and not loop_playlist: | |
| _update_status("Playlist ended") | |
| stop_audio(fade) | |
| return | |
| play_track(next_index, fade) | |
| func previous_track(fade: bool = true) -> void: | |
| """Play previous track in playlist""" | |
| if playlist.size() == 0: | |
| return | |
| var prev_index := current_track - 1 | |
| if prev_index < 0: | |
| prev_index = playlist.size() - 1 | |
| play_track(prev_index, fade) | |
| func _on_audio_finished(who_finished: AudioStreamPlayer3D) -> void: | |
| """Auto-advance to next track when current finishes""" | |
| if who_finished == current_player and not is_fading: | |
| next_track(true) | |
| func stop_audio(fade_out: bool = false) -> void: | |
| """Stop playback with optional fade out""" | |
| _kill_tween() | |
| if fade_out and current_player.playing: | |
| is_fading = true | |
| _active_tween = create_tween() | |
| _active_tween.tween_property(current_player, "volume_db", -80.0, fade_duration) | |
| _active_tween.tween_callback(func(): | |
| current_player.stop() | |
| is_fading = false | |
| _update_status("Stopped") | |
| ) | |
| else: | |
| current_player.stop() | |
| is_fading = false | |
| _update_status("Stopped") | |
| func pause_audio() -> void: | |
| """Toggle pause state""" | |
| current_player.stream_paused = not current_player.stream_paused | |
| if next_player.playing: | |
| next_player.stream_paused = current_player.stream_paused | |
| _update_status("Paused" if current_player.stream_paused else "Resumed") | |
| func _kill_tween() -> void: | |
| """Safely kill active tween""" | |
| if _active_tween and _active_tween.is_valid(): | |
| _active_tween.kill() | |
| func _update_master_volume() -> void: | |
| """Update volume on active player""" | |
| if current_player and current_player.playing: | |
| current_player.volume_db = master_volume_db | |
| # --- Low-Pass Filter Functions --- | |
| func _setup_lowpass_filter() -> void: | |
| """Initialize the low-pass filter on the Master bus""" | |
| master_bus_idx = AudioServer.get_bus_index("Master") | |
| # Create the low-pass filter | |
| lowpass_filter = AudioEffectLowPassFilter.new() | |
| lowpass_filter.cutoff_hz = lowpass_cutoff_hz | |
| lowpass_filter.db = lowpass_db | |
| # Add it to the Master bus (disabled by default) | |
| AudioServer.add_bus_effect(master_bus_idx, lowpass_filter) | |
| var effect_idx = AudioServer.get_bus_effect_count(master_bus_idx) - 1 | |
| AudioServer.set_bus_effect_enabled(master_bus_idx, effect_idx, lowpass_enabled) | |
| func toggle_lowpass_filter(enabled: bool) -> void: | |
| """Enable or disable the low-pass filter""" | |
| lowpass_enabled = enabled | |
| var effect_count = AudioServer.get_bus_effect_count(master_bus_idx) | |
| # Find our low-pass filter effect | |
| for i in range(effect_count): | |
| var effect = AudioServer.get_bus_effect(master_bus_idx, i) | |
| if effect is AudioEffectLowPassFilter and effect == lowpass_filter: | |
| AudioServer.set_bus_effect_enabled(master_bus_idx, i, enabled) | |
| _update_status("Low-Pass Filter: %s" % ("ON" if enabled else "OFF")) | |
| return | |
| func set_lowpass_cutoff(hz: float) -> void: | |
| """Set the cutoff frequency of the low-pass filter""" | |
| lowpass_cutoff_hz = hz | |
| if lowpass_filter: | |
| lowpass_filter.cutoff_hz = hz | |
| # --- UI Functions --- | |
| func _setup_debug_ui() -> void: | |
| """Create debug UI panel""" | |
| debug_ui_layer = CanvasLayer.new() | |
| add_child(debug_ui_layer) | |
| var scroll := ScrollContainer.new() | |
| scroll.set_anchors_preset(Control.PRESET_TOP_LEFT) | |
| scroll.position = Vector2(20, 20) | |
| scroll.custom_minimum_size = Vector2(350, 600) | |
| scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED | |
| debug_ui_layer.add_child(scroll) | |
| var panel := PanelContainer.new() | |
| scroll.add_child(panel) | |
| var vbox := VBoxContainer.new() | |
| vbox.add_theme_constant_override("separation", 8) | |
| panel.add_child(vbox) | |
| # Title | |
| var title := Label.new() | |
| title.text = "3D Audio Player" | |
| title.add_theme_font_size_override("font_size", 18) | |
| vbox.add_child(title) | |
| vbox.add_child(HSeparator.new()) | |
| # Status | |
| status_label = Label.new() | |
| status_label.text = "Ready" | |
| vbox.add_child(status_label) | |
| # Track info | |
| track_label = Label.new() | |
| track_label.text = "Track: --" | |
| vbox.add_child(track_label) | |
| # Progress bar | |
| progress_bar = ProgressBar.new() | |
| progress_bar.custom_minimum_size = Vector2(300, 20) | |
| progress_bar.show_percentage = false | |
| vbox.add_child(progress_bar) | |
| vbox.add_child(HSeparator.new()) | |
| # Playback controls | |
| var hbox := HBoxContainer.new() | |
| hbox.add_theme_constant_override("separation", 4) | |
| vbox.add_child(hbox) | |
| _create_button(hbox, "⏮", func(): previous_track(true)) | |
| _create_button(hbox, "⏸", pause_audio) | |
| _create_button(hbox, "⏹", func(): stop_audio(true)) | |
| _create_button(hbox, "⏭", func(): next_track(true)) | |
| vbox.add_child(HSeparator.new()) | |
| # Volume control | |
| var vol_label := Label.new() | |
| vol_label.text = "Master Volume" | |
| vbox.add_child(vol_label) | |
| volume_slider = HSlider.new() | |
| volume_slider.min_value = -40 | |
| volume_slider.max_value = 6 | |
| volume_slider.step = 1 | |
| volume_slider.value = master_volume_db | |
| volume_slider.custom_minimum_size = Vector2(300, 0) | |
| volume_slider.value_changed.connect(func(val): | |
| master_volume_db = val | |
| ) | |
| vbox.add_child(volume_slider) | |
| # Fade duration | |
| var fade_label := Label.new() | |
| fade_label.text = "Fade Duration (s)" | |
| vbox.add_child(fade_label) | |
| var spin_fade := SpinBox.new() | |
| spin_fade.min_value = 0.1 | |
| spin_fade.max_value = 5.0 | |
| spin_fade.step = 0.1 | |
| spin_fade.value = fade_duration | |
| spin_fade.value_changed.connect(func(val): fade_duration = val) | |
| vbox.add_child(spin_fade) | |
| vbox.add_child(HSeparator.new()) | |
| # Low-Pass Filter Section | |
| var filter_title := Label.new() | |
| filter_title.text = "Low-Pass Filter" | |
| filter_title.add_theme_font_size_override("font_size", 14) | |
| vbox.add_child(filter_title) | |
| # Filter toggle | |
| var filter_hbox := HBoxContainer.new() | |
| vbox.add_child(filter_hbox) | |
| var filter_check := CheckButton.new() | |
| filter_check.text = "Enable Filter" | |
| filter_check.button_pressed = lowpass_enabled | |
| filter_check.toggled.connect(toggle_lowpass_filter) | |
| filter_hbox.add_child(filter_check) | |
| # Filter presets | |
| var preset_label := Label.new() | |
| preset_label.text = "Presets:" | |
| vbox.add_child(preset_label) | |
| # Cutoff frequency control (create slider FIRST so presets can reference it) | |
| var cutoff_label := Label.new() | |
| cutoff_label.text = "Cutoff Frequency" | |
| vbox.add_child(cutoff_label) | |
| var cutoff_hbox := HBoxContainer.new() | |
| vbox.add_child(cutoff_hbox) | |
| var cutoff_slider := HSlider.new() | |
| cutoff_slider.min_value = 20 | |
| cutoff_slider.max_value = 20000 | |
| cutoff_slider.step = 10 | |
| cutoff_slider.value = lowpass_cutoff_hz | |
| cutoff_slider.custom_minimum_size = Vector2(200, 0) | |
| cutoff_slider.value_changed.connect(set_lowpass_cutoff) | |
| cutoff_hbox.add_child(cutoff_slider) | |
| var cutoff_value_label := Label.new() | |
| cutoff_value_label.text = "%d Hz" % lowpass_cutoff_hz | |
| cutoff_value_label.custom_minimum_size = Vector2(80, 0) | |
| cutoff_slider.value_changed.connect(func(val): | |
| cutoff_value_label.text = "%d Hz" % val | |
| ) | |
| cutoff_hbox.add_child(cutoff_value_label) | |
| # Now create preset buttons that reference the slider | |
| var preset_hbox := HBoxContainer.new() | |
| preset_hbox.add_theme_constant_override("separation", 4) | |
| vbox.add_child(preset_hbox) | |
| _create_button(preset_hbox, "Lux Noise Calc", func(): | |
| cutoff_slider.value = 350 | |
| ) | |
| _create_button(preset_hbox, "Cheap Noise Calc", func(): | |
| cutoff_slider.value = 3170 | |
| ) | |
| var preset_hbox2 := HBoxContainer.new() | |
| preset_hbox2.add_theme_constant_override("separation", 4) | |
| vbox.add_child(preset_hbox2) | |
| _create_button(preset_hbox2, "Earbuds", func(): | |
| cutoff_slider.value = 5000 | |
| ) | |
| _create_button(preset_hbox2, "NONE", func(): | |
| cutoff_slider.value = 20000 | |
| ) | |
| func _create_button(parent: Control, text: String, callable: Callable) -> void: | |
| """Helper to create a button""" | |
| var btn := Button.new() | |
| btn.text = text | |
| btn.pressed.connect(callable) | |
| parent.add_child(btn) | |
| func _update_status(msg: String) -> void: | |
| """Update status label""" | |
| if status_label: | |
| status_label.text = msg | |
| func _update_track_info() -> void: | |
| """Update track information display""" | |
| if track_label and playlist.size() > 0: | |
| var track_name := playlist[current_track].get_file().get_basename() | |
| track_label.text = "Track %d/%d: %s" % [current_track + 1, playlist.size(), track_name] | |
| func _update_progress_bar() -> void: | |
| """Update playback progress bar""" | |
| if not progress_bar or not current_player.playing: | |
| return | |
| if current_player.stream: | |
| var length := current_player.stream.get_length() | |
| var position := current_player.get_playback_position() | |
| if length > 0: | |
| progress_bar.max_value = length | |
| progress_bar.value = position |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
added some low pass filtering options for previews