Files
cthangover/Core/Audio/AudioService.cs
T
2026-09-10 11:44:46 +03:00

422 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Audio
{
/// <summary>
/// Singleton audio hub that owns three independent buses (Music, SFX, Ambient),
/// creating them at runtime if the audio setup lacks them. For sound effects,
/// AudioStreamPlayer nodes are pooled by SoundType so overlapping sounds
/// within the same category are cut off, while different categories stack.
/// Settings are polled each frame rather than event-driven, detecting
/// volume/enabled changes cheaply.
/// Exposes LinearToDb as a public static helper used elsewhere (e.g. UI sliders).
/// GetExpLevel / GetLowLevel provide logarithmic vs linear volume scaling
/// for contexts that need one or the other.
/// </summary>
public partial class AudioService : Node, IAudioService
{
private AudioStreamPlayer musicPlayer;
private AudioStreamPlayer ambientPlayer;
private readonly Dictionary<SoundType, AudioStreamPlayer> soundPlayers = new();
private int musicBusIndex;
private int soundBusIndex;
private int ambientBusIndex;
private const string MusicBusName = "Music";
private const string SoundBusName = "SFX";
private const string AmbientBusName = "Ambient";
private bool? lastSoundsEnabled;
private bool? lastMusicsEnabled;
private bool? lastAmbientEnabled;
private Random rnd = new Random();
public override void _Ready()
{
AddToGroup("AudioService");
SetupBuses();
SetupMusicPlayer();
SetupAmbientPlayer();
ApplySettings();
}
public override void _Process(double delta)
{
var settings = GameData.Instance?.Settings;
if (settings == null)
return;
if (settings.SoundsEnabled != lastSoundsEnabled ||
settings.SoundsVolume != lastSoundsVolume ||
settings.MusicsEnabled != lastMusicsEnabled ||
settings.MusicsVolume != lastMusicsVolume ||
settings.AmbientEnabled != lastAmbientEnabled ||
settings.AmbientVolume != lastAmbientVolume)
{
ApplySettings();
}
}
private int lastSoundsVolume = -1;
private int lastMusicsVolume = -1;
private int lastAmbientVolume = -1;
private void ApplySettings()
{
var settings = GameData.Instance?.Settings;
if (settings == null)
return;
GameLogger.Log("AUDIO", $"ApplySettings sounds={settings.SoundsEnabled}/{settings.SoundsVolume} musics={settings.MusicsEnabled}/{settings.MusicsVolume} ambient={settings.AmbientEnabled}/{settings.AmbientVolume}");
lastSoundsEnabled = settings.SoundsEnabled;
lastMusicsEnabled = settings.MusicsEnabled;
lastAmbientEnabled = settings.AmbientEnabled;
lastSoundsVolume = settings.SoundsVolume;
lastMusicsVolume = settings.MusicsVolume;
lastAmbientVolume = settings.AmbientVolume;
float soundVol = settings.SoundsEnabled ? settings.SoundsVolume : 0;
float musicVol = settings.MusicsEnabled ? settings.MusicsVolume : 0;
float ambientVol = settings.AmbientEnabled ? settings.AmbientVolume : 0;
AudioServer.SetBusVolumeDb(soundBusIndex, LinearToDb(soundVol / 100f));
AudioServer.SetBusVolumeDb(musicBusIndex, LinearToDb(musicVol / 100f));
AudioServer.SetBusVolumeDb(ambientBusIndex, LinearToDb(ambientVol / 100f));
}
private void SetupBuses()
{
musicBusIndex = AudioServer.GetBusIndex(MusicBusName);
if (musicBusIndex < 0)
{
musicBusIndex = AudioServer.GetBusCount();
AudioServer.AddBus(musicBusIndex);
AudioServer.SetBusName(musicBusIndex, MusicBusName);
}
soundBusIndex = AudioServer.GetBusIndex(SoundBusName);
if (soundBusIndex < 0)
{
soundBusIndex = AudioServer.GetBusCount();
AudioServer.AddBus(soundBusIndex);
AudioServer.SetBusName(soundBusIndex, SoundBusName);
}
ambientBusIndex = AudioServer.GetBusIndex(AmbientBusName);
if (ambientBusIndex < 0)
{
ambientBusIndex = AudioServer.GetBusCount();
AudioServer.AddBus(ambientBusIndex);
AudioServer.SetBusName(ambientBusIndex, AmbientBusName);
}
}
private void SetupMusicPlayer()
{
musicPlayer = new AudioStreamPlayer();
musicPlayer.Name = "MusicPlayer";
musicPlayer.Bus = MusicBusName;
AddChild(musicPlayer);
}
private void SetupAmbientPlayer()
{
ambientPlayer = new AudioStreamPlayer();
ambientPlayer.Name = "AmbientPlayer";
ambientPlayer.Bus = AmbientBusName;
ambientPlayer.Finished += OnAmbientFinished;
AddChild(ambientPlayer);
}
private void OnAmbientFinished()
{
if (ambientPlayer?.Stream != null)
ambientPlayer.Play();
}
private AudioStreamPlayer GetOrCreateSoundPlayer(SoundType type)
{
if (soundPlayers.TryGetValue(type, out var player) && IsInstanceValid(player))
return player;
player = new AudioStreamPlayer();
player.Name = $"SFX_{type}";
player.Bus = SoundBusName;
AddChild(player);
soundPlayers[type] = player;
return player;
}
/// <summary>
/// Loads a music stream via <c>MusicFactory</c> and hands it to
/// the dedicated Music player. If either the player or the
/// factory result is null the call is silently skipped — no
/// exception is thrown so that callers can fire-and-forget
/// without guarding against missing assets.
/// </summary>
public void PlayMusic(string id, MusicType musicType)
{
if (musicPlayer == null)
return;
var stream = MusicFactory.Instance?.Get(id);
if (stream == null)
return;
GameLogger.Log("AUDIO", $"AudioService.PlayMusic '{id}' type={musicType}");
musicPlayer.Stream = stream;
musicPlayer.Play();
}
/// <summary>
/// Stops playback on the Music player. The stream reference is
/// kept intact so a subsequent <c>Play()</c> would restart the
/// same track — external logic (e.g. auto-advance) is expected
/// to replace the stream before the next play.
/// </summary>
public void StopMusic()
{
musicPlayer?.Stop();
}
/// <summary>
/// Toggles the <c>StreamPaused</c> flag on the Music player.
/// When <paramref name="pause"/> is <c>true</c> playback is
/// suspended in-place; when <c>false</c> it resumes from the
/// exact sample. No stream replacement occurs.
/// </summary>
public void PauseMusic(bool pause)
{
if (musicPlayer == null)
return;
musicPlayer.StreamPaused = pause;
}
/// <summary>
/// Returns <c>true</c> when the Music player exists and is
/// currently producing audio. Used externally (e.g. UI) to
/// display playback state without coupling to the player
/// node directly.
/// </summary>
public bool IsMusicPlaying()
{
return musicPlayer?.Playing ?? false;
}
/// <summary>
/// Resolves an ambient loop from <c>SoundFactory</c> and plays it
/// on the Ambient player. If the same stream is already playing
/// the call is a no-op, preventing a disruptive restart glitch.
/// When the stream ends the player automatically loops via the
/// <c>Finished</c> signal.
/// </summary>
public void PlayAmbient(string id)
{
if (ambientPlayer == null)
return;
var stream = SoundFactory.Instance?.Get(id);
if (stream == null)
{
GameLogger.Log("AUDIO", $"AudioService.PlayAmbient: stream not found for '{id}'", LogLevel.Error);
return;
}
if (ambientPlayer.Playing && ambientPlayer.Stream == stream)
return;
GameLogger.Log("AUDIO", $"AudioService.PlayAmbient '{id}'");
ambientPlayer.Stream = stream;
ambientPlayer.Play();
}
/// <summary>
/// Stops the Ambient loop. Only acts when the player exists and
/// is actively playing; otherwise the call is a no-op. There is
/// no auto-resume — the next <c>PlayAmbient</c> loads the stream
/// from scratch.
/// </summary>
public void StopAmbient()
{
if (ambientPlayer == null || !ambientPlayer.Playing)
return;
GameLogger.Log("AUDIO", "AudioService.StopAmbient");
ambientPlayer.Stop();
}
/// <summary>
/// Plays a sound effect from <c>SoundFactory</c> with optional
/// random variation. If <paramref name="variations"/> is greater
/// than 1, a suffix <c>"_N"</c> is appended to <paramref name="id"/>
/// where N is uniformly random in [1, variations]. The result is
/// played on the per-<paramref name="soundType"/> pool player,
/// cutting off any previous sound of the same type.
/// </summary>
public void PlaySound(string id, int variations, SoundType soundType)
{
if (variations < 1)
{
GameLogger.Log("AUDIO", $"PlaySound '{id}' type={soundType} with invalid variations={variations}");
return;
}
if (variations > 1)
id += "_" + (rnd.Next(variations) + 1).ToString();
var stream = SoundFactory.Instance?.Get(id);
if (stream == null)
return;
GameLogger.Log("AUDIO", $"PlaySound '{id}' type={soundType}");
var player = GetOrCreateSoundPlayer(soundType);
player.Stream = stream;
player.Play();
}
/// <summary>
/// Convenience overload that delegates to
/// <c>PlaySound(id, 1, soundType)</c> — no variation suffix is
/// appended, so the exact asset name is used.
/// </summary>
public void PlaySound(string id, SoundType soundType)
{
PlaySound(id, 1, soundType);
}
/// <summary>
/// Stops the pooled player for a specific <see cref="SoundType"/>.
/// Other sound-type players are not affected, so e.g. stopping
/// <c>CardEffect</c> won't interrupt an ongoing <c>UI</c> sound.
/// </summary>
public void StopSound(SoundType type)
{
if (soundPlayers.TryGetValue(type, out var player) && IsInstanceValid(player))
player.Stop();
}
/// <summary>
/// Pauses the player for the given <paramref name="type"/> by
/// setting <c>StreamPaused = true</c>. A subsequent
/// <c>PlaySound</c> call will restart it from the paused
/// position because the stream is already loaded.
/// </summary>
public void PauseSound(SoundType type)
{
if (soundPlayers.TryGetValue(type, out var player) && IsInstanceValid(player))
player.StreamPaused = true;
}
/// <summary>
/// Applies a linear volume (01) directly to the Godot audio bus
/// identified by <paramref name="type"/>. The value is converted
/// to dB via <c>LinearToDb</c>. Unlike the per-frame settings
/// poll, this is an immediate one-shot override — useful for
/// fade tweens and manual volume control outside the settings
/// system.
/// </summary>
public void SetVolume(MixerType type, float volume)
{
GameLogger.Log("AUDIO", $"SetVolume {type} = {volume:F2}");
float db = LinearToDb(volume);
if (type == MixerType.Musics)
AudioServer.SetBusVolumeDb(musicBusIndex, db);
else if (type == MixerType.Ambient)
AudioServer.SetBusVolumeDb(ambientBusIndex, db);
else
AudioServer.SetBusVolumeDb(soundBusIndex, db);
}
/// <summary>
/// Silences the bus by forcing -80 dB when
/// <paramref name="enabled"/> is <c>false</c>. When <c>true</c>
/// the method returns immediately without restoring volume —
/// the next settings poll or <c>SetVolume</c> call is expected
/// to bring the bus back to its intended level. This asymmetry
/// exists because the settings system is the primary volume
/// driver and <c>SetEnabled(false)</c> is a hard mute overlay.
/// </summary>
public void SetEnabled(MixerType type, bool enabled)
{
if (enabled)
return;
float db = -80f;
if (type == MixerType.Musics)
AudioServer.SetBusVolumeDb(musicBusIndex, db);
else if (type == MixerType.Ambient)
AudioServer.SetBusVolumeDb(ambientBusIndex, db);
else
AudioServer.SetBusVolumeDb(soundBusIndex, db);
}
/// <summary>
/// Computes a logarithmic (dB) level for scenarios that need
/// exponential volume scaling (e.g. UI sliders). First clamps
/// the current bus level scaled by <paramref name="percent"/>
/// to (0.0001, 100], then converts to dB via
/// <c>log10(level/100) * 20</c>. The clamping avoids
/// <c>-infinity</c> dB at zero.
/// </summary>
public float GetExpLevel(MixerType type, float percent = 1f)
{
var level = GetLevel(type) * percent;
if (level > 100) level = 100;
if (level <= 0) level = 0.0001f;
return Mathf.Log(level / 100f) / Mathf.Log(10) * 20f;
}
/// <summary>
/// Returns the current bus level as a 01 linear factor by
/// dividing the raw 0100 settings value by 100. Used where
/// linear interpolation is more appropriate than dB scaling.
/// </summary>
public float GetLowLevel(MixerType type)
{
return GetLevel(type) / 100f;
}
private float GetLevel(MixerType type)
{
var settings = GameData.Instance?.Settings;
if (settings == null)
return 100;
return type switch
{
MixerType.Sounds => settings.SoundsEnabled ? settings.SoundsVolume : 0,
MixerType.Musics => settings.MusicsEnabled ? settings.MusicsVolume : 0,
MixerType.Ambient => settings.AmbientEnabled ? settings.AmbientVolume : 0,
_ => 100,
};
}
/// <summary>
/// Converts a linear amplitude (01) to decibels using the
/// <c>20 * log10(linear)</c> formula. Values at or below
/// 0.0001 are clamped to -80 dB to avoid <c>-infinity</c>.
/// Exposed as <c>public static</c> so external code (UI
/// sliders, settings panels) can perform the conversion
/// without holding a reference to the service.
/// </summary>
public static float LinearToDb(float linear)
{
if (linear <= 0.0001f)
return -80f;
return Mathf.Log(linear) / Mathf.Log(10) * 20f;
}
}
}