403 lines
13 KiB
GDScript
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
|