maxgame/addons/popochiu/engine/audio_manager/audio_manager.gd
2025-07-17 01:49:18 -04:00

403 lines
13 KiB
GDScript

class_name PopochiuAudioManager
extends Node
## Handles playing audio using [PopochiuAudioCue]s.
##
## It plays sound effects and music using [AudioStreamPlayer] or [AudioStreamPlayer2D], creating
## these nodes at runtime if needed. By default, it has 6 nodes for positional streams and 5 for
## playing non-positional streams.[br][br]
## The [b]PopochiuAudioManager[/b] is loaded as a child of [Popochiu] when the game starts.
## Used to mark stream players created at runtime that should be [method Node.free] when they are
## no longer needed.
const TEMP_PLAYER := "temporal"
## Specifies the path where the volume configuration for the audio buses used in the game is stored.
const SETTINGS_PATH = "user://audio_settings.save"
## Used to convert the value of the pitch set on [member PopochiuAudioCue.pitch] to the
## corresponding value needed for the [code]pitch_scale[/code] property of the audio stream players.
var twelfth_root_of_two := pow(2, (1.0 / 12))
## Stores the volume values for each of the audio buses used by the game.
var volume_settings := {}
var _mx_cues := {}
var _sfx_cues := {}
var _vo_cues := {}
var _ui_cues := {}
var _active := {}
var _all_in_one := {}
# Serves as a map that stores an AudioStreamPlayer/AudioStreamPlayer2D and the tween used to fade
# its volume
var _fading_sounds := {}
var _dflt_volumes := {}
#region Godot ######################################################################################
func _ready() -> void:
if Engine.is_editor_hint(): return
for bus_idx in range(AudioServer.get_bus_count()):
var bus_name = AudioServer.get_bus_name(bus_idx)
volume_settings[bus_name] = AudioServer.get_bus_volume_db(bus_idx)
for arr in ["mx_cues", "sfx_cues", "vo_cues", "ui_cues"]:
for rp in PopochiuResources.get_data_value("audio", arr, []):
var ac: PopochiuAudioCue = load(rp)
self["_%s" % arr][ac.resource_name] = ac
_all_in_one[ac.resource_name] = ac
_dflt_volumes[ac.resource_name] = ac.volume
#endregion
#region Public #####################################################################################
## Searches for the [PopochiuAudioCue] identified by [param cue_name] among the cues that are NOT
## part of the music group and plays it. You can set a [param position_2d] to play it positionally.
## If [param wait_to_end] is set to [code]true[/code], the function will pause until the audio
## finishes.
func play_sound_cue(cue_name := "", position_2d := Vector2.ZERO, wait_to_end = null) -> Node:
var stream_player: Node = null
if _all_in_one.has(cue_name.to_lower()):
var cue: PopochiuAudioCue = _all_in_one[cue_name.to_lower()]
stream_player = _play(cue, position_2d)
else:
PopochiuUtils.print_error("Sound not found: " + cue_name)
if wait_to_end != null:
await get_tree().process_frame
return null
if wait_to_end == true and stream_player:
await stream_player.finished
elif wait_to_end == false:
await get_tree().process_frame
return stream_player
## Searches for the [PopochiuAudioCue] identified by [param cue_name] among the cues that are part
## of the music group and plays it. It can fade for [param fade_duration] seconds, and you can start
## playing it from a given [param music_position] in seconds.
func play_music_cue(cue_name: String, fade_duration := 0.0, music_position := 0.0) -> Node:
var stream_player: Node = null
if _active.has(cue_name):
return _active[cue_name].players[0]
if _mx_cues.has(cue_name.to_lower()):
var cue: PopochiuAudioCue = _mx_cues[cue_name.to_lower()]
# NOTE: fixes #27 AudioCues were losing the volume set in editor when
# played with a fade
cue.volume = _dflt_volumes[cue_name.to_lower()]
if fade_duration > 0.0:
stream_player = _fade_in(
cue,
Vector2.ZERO,
fade_duration,
-80.0,
cue.volume,
music_position
)
else:
stream_player = _play(cue, Vector2.ZERO, music_position)
else:
PopochiuUtils.print_error("Music not found: " + cue_name)
return stream_player
## Plays the [PopochiuAudioCue] identified by [param cue_name] using a fade that will last
## [param duration] seconds. Specify the starting volume with [param from] and the target volume
## with [param to]. You can play the audio positionally with [param position_2d] and wait for it
## to finish if [param wait_to_end] is set to [code]true[/code].
func play_fade_cue(
cue_name := "",
duration := 1.0,
from := -80.0,
to := INF,
position_2d := Vector2.ZERO,
wait_to_end = null
) -> Node:
var stream_player: Node = null
if _all_in_one.has(cue_name.to_lower()):
var cue: PopochiuAudioCue = _all_in_one[cue_name.to_lower()]
stream_player = _fade_in(cue, position_2d, duration, from, to if to != INF else cue.volume)
else:
PopochiuUtils.print_error("Sound to fade not found " + cue_name)
if wait_to_end != null:
await get_tree().process_frame
return null
if wait_to_end == true and stream_player:
await stream_player.finished
elif wait_to_end == false:
await get_tree().process_frame
return stream_player
## Stops the [PopochiuAudioCue] identified by [param cue_name]. It can use a fade effect that will
## last [param fade_duration] seconds.
func stop(cue_name: String, fade_duration := 0.0) -> void:
if _active.has(cue_name):
var stream_player: Node = (_active[cue_name].players as Array).front()
if is_instance_valid(stream_player):
if fade_duration > 0.0:
_fade_sound(
cue_name, fade_duration, stream_player.volume_db, -80.0
)
else:
stream_player.stop()
# Always emit the signal since it won't be emitted if the audio
# file haven't reach the end yet
stream_player.finished.emit()
else:
_active.erase(cue_name)
## Returns the playback position of the [PopochiuAudioCue] identified by [param cue_name].
func get_cue_playback_position(cue_name: String) -> float:
if not _active.has(cue_name): return -1.0
var stream_player: Node = (_active[cue_name].players as Array).front()
if is_instance_valid(stream_player):
return stream_player.get_playback_position()
return -1.0
## Changes the [code]pitch_scale[/code] of the [PopochiuAudioCue] identified by [param cue_name] to
## the value set (in semitones) in [param pitch].
func change_cue_pitch(cue_name: String, pitch := 0.0) -> void:
if not _active.has(cue_name): return
var stream_player: Node = (_active[cue_name].players as Array).front()
stream_player.set_pitch_scale(_semitone_to_pitch(pitch))
## Changes the [code]volume_db[/code] of the [PopochiuAudioCue] identified by [param cue_name] to
## the value set in [param volume].
func change_cue_volume(cue_name: String, volume := 0.0) -> void:
if not _active.has(cue_name): return
var stream_player: Node = (_active[cue_name].players as Array).front()
stream_player.volume_db = volume
## Sets [param value] as the volume of the audio bus identified with [param bus_name].
func set_bus_volume_db(bus_name: String, value: float) -> void:
if volume_settings.has(bus_name):
volume_settings[bus_name] = value
AudioServer.set_bus_volume_db(
AudioServer.get_bus_index(bus_name), volume_settings[bus_name]
)
save_sound_settings()
## Saves in the file at [constant SETTINGS_PATH] the volume values of all the audio buses.
func save_sound_settings():
var file = FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file == null:
PopochiuUtils.print_error("Error opening file: " + SETTINGS_PATH)
else:
file.store_var(volume_settings)
file.close()
## Loads the volume values stored at [constant SETTINGS_PATH] for all the audio buses.
func load_sound_settings():
var file = FileAccess.open(SETTINGS_PATH, FileAccess.READ)
if file:
volume_settings = file.get_var(true)
file.close()
for bus_idx in range(AudioServer.get_bus_count()):
var bus_name = AudioServer.get_bus_name(bus_idx)
if volume_settings.has(bus_name):
AudioServer.set_bus_volume_db(
AudioServer.get_bus_index(bus_name),
volume_settings[bus_name]
)
else:
volume_settings[bus_name] = AudioServer.get_bus_volume_db(bus_idx)
## Returns [code]true[/code] if the [PopochiuAudioCue] identified by [param cue_name] is playing.
func is_playing_cue(cue_name: String) -> bool:
return get_cue_playback_position(cue_name) > -1
#endregion
#region Private ####################################################################################
# Calculates the [code]pitch_scale[/code] value of [param pitch], which is in semitones.
func _semitone_to_pitch(pitch: float) -> float:
return pow(twelfth_root_of_two, pitch)
# Plays the sound and assigns it to a free AudioStreamPlayer, or creates one if there are no more.
func _play(
cue: PopochiuAudioCue, position := Vector2.ZERO, from_position := 0.0
) -> Node:
var player: Node = null
if cue.is_2d:
player = _get_free_stream($Positional)
if not is_instance_valid(player):
player = AudioStreamPlayer2D.new()
player.set_meta(TEMP_PLAYER, true)
$Active.add_child(player)
(player as AudioStreamPlayer2D).stream = cue.audio
(player as AudioStreamPlayer2D).pitch_scale = cue.get_pitch_scale()
(player as AudioStreamPlayer2D).volume_db = cue.volume
(player as AudioStreamPlayer2D).max_distance = cue.max_distance
(player as AudioStreamPlayer2D).position = position
else:
player = _get_free_stream($Generic)
if not is_instance_valid(player):
player = AudioStreamPlayer.new()
player.set_meta(TEMP_PLAYER, true)
$Active.add_child(player)
(player as AudioStreamPlayer).stream = cue.audio
(player as AudioStreamPlayer).pitch_scale = cue.get_pitch_scale()
(player as AudioStreamPlayer).volume_db = cue.volume
var cue_name: String = cue.resource_name
player.bus = cue.bus
player.play(from_position)
if not player.finished.is_connected(_on_audio_stream_player_finished):
player.finished.connect(_on_audio_stream_player_finished.bind(player, cue_name, 0))
if _active.has(cue_name):
_active[cue_name].players.append(player)
# NOTE: Stop the previous stream player created to play the audio cue
# that is in loop to avoid having more than one sound playing.
if not _active[cue_name].can_play_simultaneous:
stop(cue_name)
else:
_active[cue_name] = {
players = [player],
loop = cue.loop,
can_play_simultaneous = cue.can_play_simultaneous
}
return player
func _get_free_stream(group: Node):
return _reparent(group, $Active, 0)
# Reassigns the [AudioStreamPlayer] to its original group when it finishes so it can be available
# for being used again.
func _on_audio_stream_player_finished(
stream_player: Node, cue_name: String, _debug_idx: int
) -> void:
if stream_player.has_meta(TEMP_PLAYER):
stream_player.queue_free()
elif stream_player is AudioStreamPlayer:
_reparent($Active, $Generic, stream_player.get_index())
else:
_reparent($Active, $Positional, stream_player.get_index())
if _active.has(cue_name):
var players: Array = _active[cue_name].players
for idx in players.size():
if players[idx].get_instance_id() == stream_player.get_instance_id():
players.remove_at(idx)
break
if players.is_empty():
_active.erase(cue_name)
if not stream_player.finished.is_connected(_on_audio_stream_player_finished):
stream_player.finished.connect(_on_audio_stream_player_finished)
func _reparent(source: Node, target: Node, child_idx: int) -> Node:
if not is_instance_valid(source) or source.get_children().is_empty():
return null
var node_to_reparent: Node = source.get_child(child_idx)
if not is_instance_valid(node_to_reparent):
return null
node_to_reparent.reparent(target)
return node_to_reparent
func _fade_in(
cue: PopochiuAudioCue,
position: Vector2,
duration := 1.0,
from := -80.0,
to := 0.0,
from_position := 0.0
) -> Node:
if cue.audio.get_instance_id() in _fading_sounds:
from = _fading_sounds[cue.audio.get_instance_id()].stream.volume_db
var tween: Tween = _fading_sounds[cue.audio.get_instance_id()].tween
# Stop the tween only of the sound that is fading
if is_instance_valid(tween) and tween.is_running():
tween.kill()
_fading_sounds[cue.audio.get_instance_id()].finished.emit()
_fading_sounds.erase(cue.audio.get_instance_id())
cue.volume = from
var stream_player: Node = _play(cue, position, from_position)
if stream_player:
_fade_sound(cue.resource_name, duration, from, to)
else:
cue.volume = to
return stream_player
func _fade_sound(cue_name: String, duration = 1, from = 0, to = 0) -> void:
var stream_player: Node = (_active[cue_name].players as Array).front()
if _fading_sounds.has(stream_player.stream.get_instance_id()):
_fading_sounds[stream_player.stream.get_instance_id()].tween.kill()
var t := create_tween().set_ease(Tween.EASE_IN_OUT)
t.finished.connect(_fadeout_finished.bind(stream_player, t))
t.tween_property(stream_player, "volume_db", to, duration).from(from)
if from > to :
_fading_sounds[stream_player.stream.get_instance_id()] = {
stream = stream_player,
tween = t
}
func _fadeout_finished(stream_player: Node, tween: Tween) -> void:
if stream_player.stream.get_instance_id() in _fading_sounds :
_fading_sounds.erase(stream_player.stream.get_instance_id())
stream_player.stop()
tween.finished.disconnect(_fadeout_finished)
#endregion