Skip to content

Instantly share code, notes, and snippets.

@twobob
Last active February 3, 2026 23:20
Show Gist options
  • Select an option

  • Save twobob/427492906872aec426fdb1d772962f0d to your computer and use it in GitHub Desktop.

Select an option

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.
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
@twobob
Copy link
Author

twobob commented Feb 3, 2026

added some low pass filtering options for previews

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment