init mod
This commit is contained in:
commit
62dcf9d0fd
17 files changed
+1789
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
mod_audio_base_name = Audio Engine
|
||||||
|
mod_audio_base_desc = Engine audio subsystem: IAudioService implementation (Music/SFX/Ambient buses), the auto-advancing music player and the audio factories (music, sounds, playlists).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_audio_base_name = Аудиодвижок
|
||||||
|
mod_audio_base_desc = Звуковая подсистема движка: реализация IAudioService (шины Music/SFX/Ambient), музыкальный плеер с автопродвижением и фабрики аудио (музыка, звуки, плейлисты).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "audio_base",
|
||||||
|
"name": "Audio Engine",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "ct",
|
||||||
|
"description": "Engine audio subsystem: IAudioService implementation (Music/SFX/Ambient buses), the auto-advancing music player and the audio factories (music, sounds, playlists). Replaces the former Core/Audio autoloads — the AudioModInitializer adds nodes named AudioService and MusicPlayer to the scene tree root on the boot Scenes phase, so /root/AudioService keeps resolving.",
|
||||||
|
"category": "engine, library",
|
||||||
|
"sources": [
|
||||||
|
"src/**/*.cs"
|
||||||
|
],
|
||||||
|
"depends": ["base2d>=1", "interface>=1"],
|
||||||
|
"ui": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Factories;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Base factory for loading audio streams from mod archives.
|
||||||
|
/// <c>.ogg</c> files are parsed through <c>OggPacketParser</c> to
|
||||||
|
/// produce <c>AudioStreamOggVorbis</c> (Godot needs the raw packet
|
||||||
|
/// sequence, not just file bytes). <c>.wav</c> files are loaded
|
||||||
|
/// directly into <c>AudioStreamWav</c>. Inherits the mod-aware
|
||||||
|
/// caching and fallback logic from <c>PrefabFactory</c>.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class AudioFactory : PrefabFactory<AudioStream>
|
||||||
|
{
|
||||||
|
protected AudioFactory(string factoryKey, int fallbackCacheSize)
|
||||||
|
: base(factoryKey, fallbackCacheSize) { }
|
||||||
|
|
||||||
|
/// <summary>Audio extensions from mod config (typically .ogg and .wav), tried in order.</summary>
|
||||||
|
protected override List<string> Extensions { get; } = ModConfig.Instance.AudioExtensions;
|
||||||
|
|
||||||
|
/// <summary>Builds an <c>AudioStreamOggVorbis</c> from parsed OGG packets, or an <c>AudioStreamWav</c> from raw PCM bytes.</summary>
|
||||||
|
protected override AudioStream ConvertFromBytes(string id, byte[] data, string extension)
|
||||||
|
{
|
||||||
|
if (extension?.Equals(".ogg", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
var packetSequence = OggPacketParser.CreateFromOggBytes(data);
|
||||||
|
if (packetSequence == null)
|
||||||
|
{
|
||||||
|
GameLogger.Log("FACTORY", $"Failed to parse OGG data for sound '{id}'", LogLevel.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new AudioStreamOggVorbis { PacketSequence = packetSequence };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension?.Equals(".wav", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
return new AudioStreamWav { Data = data };
|
||||||
|
|
||||||
|
GameLogger.Log("FACTORY", $"Unsupported audio format '{extension}' for sound '{id}'", LogLevel.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Cthangover.Core.Events;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Engine-mod initializer for the audio subsystem. Registers the
|
||||||
|
/// <c>/root/AudioService</c> and <c>/root/MusicPlayer</c> nodes when mod
|
||||||
|
/// resources become ready and subscribes the music player to scene events,
|
||||||
|
/// replacing the former core autoloads. Both operations are idempotent:
|
||||||
|
/// <c>ModManager.Reload()</c> re-runs <c>OnModResourcesReady</c>, so node
|
||||||
|
/// creation and event subscription must tolerate repeat invocations.
|
||||||
|
/// </summary>
|
||||||
|
public class AudioModInitializer : IModInitializer
|
||||||
|
{
|
||||||
|
private static bool subscribed;
|
||||||
|
|
||||||
|
/// <summary>No per-mod setup is required; node registration happens on resources-ready.</summary>
|
||||||
|
public void OnModLoaded(string modId) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures the audio nodes exist under the tree root and that the
|
||||||
|
/// music player listens to scene events. Safe to call again after
|
||||||
|
/// a mod reload.
|
||||||
|
/// </summary>
|
||||||
|
public void OnModResourcesReady()
|
||||||
|
{
|
||||||
|
EnsureAudioNodes();
|
||||||
|
EnsureEventSubscriptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool nodesQueued;
|
||||||
|
|
||||||
|
private static void EnsureAudioNodes()
|
||||||
|
{
|
||||||
|
var root = ModEventBus.Instance?.GetTree()?.Root;
|
||||||
|
if (root == null)
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", "AudioModInitializer: tree root not available, skipping node setup", LogLevel.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.GetNodeOrNull("AudioService") != null || nodesQueued)
|
||||||
|
return;
|
||||||
|
nodesQueued = true;
|
||||||
|
|
||||||
|
// Deferred: OnModResourcesReady fires while the root is still
|
||||||
|
// setting up autoload children, so a direct AddChild is rejected.
|
||||||
|
// Deferred calls run in FIFO order, which keeps AudioService ahead
|
||||||
|
// of MusicPlayerBehaviour — its OnReady resolves AudioService.
|
||||||
|
root.CallDeferred("add_child", new AudioService { Name = "AudioService" });
|
||||||
|
root.CallDeferred("add_child", new MusicPlayerBehaviour { Name = "MusicPlayer" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureEventSubscriptions()
|
||||||
|
{
|
||||||
|
if (subscribed)
|
||||||
|
return;
|
||||||
|
subscribed = true;
|
||||||
|
|
||||||
|
ModEventBus.Subscribe<SceneEnteredEvent>(OnSceneEntered, priority: 10);
|
||||||
|
ModEventBus.Subscribe<SceneRouteActivatingEvent>(OnRouteActivating);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnSceneEntered(SceneEnteredEvent evt)
|
||||||
|
{
|
||||||
|
NotifyMusicPlayers(evt.SceneName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnRouteActivating(SceneRouteActivatingEvent evt)
|
||||||
|
{
|
||||||
|
NotifyMusicPlayers(evt.MusicScene);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void NotifyMusicPlayers(string musicScene)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(musicScene))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var tree = ModEventBus.Instance?.GetTree();
|
||||||
|
if (tree == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var node in tree.GetNodesInGroup("music_player"))
|
||||||
|
{
|
||||||
|
if (node is MusicPlayerBehaviour player)
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", $"MusicPlayer notified about scene '{musicScene}'");
|
||||||
|
player.UpdateMusicType(musicScene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Audio;
|
||||||
|
using Cthangover.Core.Bootstrap;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Base;
|
||||||
|
namespace Cthangover.Mods.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.
|
||||||
|
/// <para/>
|
||||||
|
/// Lives in the audio mod: the node is created and named
|
||||||
|
/// <c>AudioService</c> at /root by <see cref="AudioModInitializer"/>, so
|
||||||
|
/// node-path consumers (<c>/root/AudioService</c>) and the
|
||||||
|
/// <c>AudioService</c> group keep working. Because mod assemblies get no
|
||||||
|
/// Godot source generation, the class derives from the core
|
||||||
|
/// <c>ModNode</c> forwarder and hooks the engine through
|
||||||
|
/// <c>OnReady</c>/<c>OnProcess</c> instead of <c>_Ready</c>/<c>_Process</c>.
|
||||||
|
/// </summary>
|
||||||
|
public class AudioService : ModNode, 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();
|
||||||
|
|
||||||
|
/// <summary>Creates the three audio buses (Music/SFX/Ambient) if missing, the music and ambient players, and applies saved settings.</summary>
|
||||||
|
protected override void OnReady()
|
||||||
|
{
|
||||||
|
AddToGroup("AudioService");
|
||||||
|
SetupBuses();
|
||||||
|
SetupMusicPlayer();
|
||||||
|
SetupAmbientPlayer();
|
||||||
|
ApplySettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-frame settings poll — reapplies volumes only when an enabled/volume setting actually changed.</summary>
|
||||||
|
protected override void OnProcess(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 (0–1) 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 0–1 linear factor by
|
||||||
|
/// dividing the raw 0–100 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 (0–1) 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.Factories;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Thin <c>AudioFactory</c> for background music tracks. The separate
|
||||||
|
/// <c>"music"</c> group name matters for Godot's audio bus routing —
|
||||||
|
/// music plays through a dedicated mixer channel with its own volume
|
||||||
|
/// and reverb profile, distinct from the <c>"sounds"</c> bus used by
|
||||||
|
/// <c>SoundFactory</c>.
|
||||||
|
/// <para/>
|
||||||
|
/// The single instance is owned by this assembly as a lazily created
|
||||||
|
/// singleton, exposed through the static <see cref="Instance"/> facade.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicFactory : AudioFactory
|
||||||
|
{
|
||||||
|
private static readonly Lazy<MusicFactory> instance = new(() => new MusicFactory());
|
||||||
|
|
||||||
|
internal MusicFactory() : base("music", 64) { }
|
||||||
|
|
||||||
|
/// <summary>The lazily created shared instance.</summary>
|
||||||
|
public static MusicFactory Instance => instance.Value;
|
||||||
|
|
||||||
|
/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>music</b>.</summary>
|
||||||
|
public override string GroupName => "music";
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
using Cthangover.Core.Audio;
|
||||||
|
using Cthangover.Core.Bootstrap;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Base;
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Autonomous music player that drives playlist auto-advance and
|
||||||
|
/// scene-aware track switching. It finds the AudioService's MusicPlayer
|
||||||
|
/// via the scene tree (not injected), initialises playlists lazily,
|
||||||
|
/// and advances to a random track when playback ends.
|
||||||
|
/// The core complexity is the Combat ↔ Ambient handoff:
|
||||||
|
/// when entering combat, the current ambient track and time are saved;
|
||||||
|
/// when leaving combat, they're restored — creating a seamless
|
||||||
|
/// interruption model. FadeMusic uses a Tween with a 1s delay
|
||||||
|
/// followed by a 6s volume ramp.
|
||||||
|
/// Disabling auto-play preserves the last track/time; re-enabling
|
||||||
|
/// resumes from the saved point.
|
||||||
|
/// <para/>
|
||||||
|
/// Lives in the audio mod: the node is created and named
|
||||||
|
/// <c>MusicPlayer</c> at /root by <see cref="AudioModInitializer"/>,
|
||||||
|
/// keeps the <c>music_player</c> group, and is notified about scene
|
||||||
|
/// music from the mod's own <c>SceneEnteredEvent</c> /
|
||||||
|
/// <c>SceneRouteActivatingEvent</c> subscriptions. Because mod
|
||||||
|
/// assemblies get no Godot source generation, the class derives from
|
||||||
|
/// the core <c>ModNode</c> forwarder and hooks the engine through
|
||||||
|
/// <c>OnReady</c>/<c>OnProcess</c> instead of <c>_Ready</c>/<c>_Process</c>.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicPlayerBehaviour : ModNode
|
||||||
|
{
|
||||||
|
private AudioStreamPlayer audioPlayer;
|
||||||
|
private readonly PlaylistContext playlistContext = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level toggle for the entire auto-advance state machine.
|
||||||
|
/// When <c>false</c>, <c>NextSound</c> is skipped, no new
|
||||||
|
/// tracks are selected, and <c>UpdateMusicType</c> does not
|
||||||
|
/// trigger a switch. Set by <c>EnabledAutoPlay</c> /
|
||||||
|
/// <c>DisabledAutoPlay</c> and also forced to <c>false</c>
|
||||||
|
/// when a playlist has no tracks.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsCanAutoPlay { get; set; } = true;
|
||||||
|
|
||||||
|
private Tween fadeTween;
|
||||||
|
private bool playlistsInited;
|
||||||
|
private double autoAdvanceCooldown;
|
||||||
|
|
||||||
|
/// <summary>Registers in the <c>music_player</c> group and locates the AudioService music player.</summary>
|
||||||
|
protected override void OnReady()
|
||||||
|
{
|
||||||
|
AddToGroup("music_player");
|
||||||
|
TryFindAudioPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives auto-advance: defers playlist init until the mod registry is
|
||||||
|
/// finalized, then starts a random track when playback has been idle
|
||||||
|
/// past the 0.5s cooldown and auto-play is enabled.
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnProcess(double delta)
|
||||||
|
{
|
||||||
|
if (audioPlayer == null || !IsInstanceValid(audioPlayer))
|
||||||
|
{
|
||||||
|
TryFindAudioPlayer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playlistsInited)
|
||||||
|
{
|
||||||
|
// During the launcher boot the mod registry is being populated on a
|
||||||
|
// background thread; reading Mods from the main thread would race
|
||||||
|
// with that write. Defer playlist init until the registry is finalized
|
||||||
|
// (ModRegistry.IsInitialized flips at the end of FinalizeGodotInit) —
|
||||||
|
// the playlist is re-initialised on scene transitions anyway.
|
||||||
|
if (!Cthangover.Core.Mods.ModManager.Instance.IsInitialized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sceneMgr = GetNodeOrNull<Cthangover.Core.Scenes.SceneManager>("/root/SceneManager");
|
||||||
|
var sceneName = sceneMgr?.CurrentSceneName ?? GameData.Instance?.Runtime?.CurrentScene.ToString() ?? "MainMenu";
|
||||||
|
InitPlaylists(sceneName);
|
||||||
|
playlistsInited = true;
|
||||||
|
autoAdvanceCooldown = Godot.Time.GetTicksUsec() / 1_000_000.0 + 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsCanAutoPlay)
|
||||||
|
return;
|
||||||
|
|
||||||
|
double now = Godot.Time.GetTicksUsec() / 1_000_000.0;
|
||||||
|
if (now > autoAdvanceCooldown && !audioPlayer.Playing && !audioPlayer.StreamPaused)
|
||||||
|
{
|
||||||
|
NextSound();
|
||||||
|
autoAdvanceCooldown = now + 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-enables auto-play after it was disabled. Stops current
|
||||||
|
/// playback and, if a <c>LastMusicName</c> is saved, restores
|
||||||
|
/// that track from <c>MusicFactory</c> — effectively resuming
|
||||||
|
/// the previously-interrupted song. If no track is saved the
|
||||||
|
/// auto-advance loop in <c>OnProcess</c> will pick the next
|
||||||
|
/// random track within 0.5s.
|
||||||
|
/// </summary>
|
||||||
|
public void EnabledAutoPlay()
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", "Autoplay enabled");
|
||||||
|
|
||||||
|
IsCanAutoPlay = true;
|
||||||
|
StopMusic();
|
||||||
|
|
||||||
|
if (playlistContext.LastMusicName != null && audioPlayer != null)
|
||||||
|
{
|
||||||
|
audioPlayer.Stream = MusicFactory.Instance.Get(playlistContext.LastMusicName);
|
||||||
|
audioPlayer.Play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disables auto-play and saves the current playback position
|
||||||
|
/// into <c>PlaylistContext.LastMusicTime</c> so it can be
|
||||||
|
/// restored later via <c>EnabledAutoPlay</c>. Stops playback
|
||||||
|
/// immediately after saving.
|
||||||
|
/// </summary>
|
||||||
|
public void DisabledAutoPlay()
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", "Autoplay disabled");
|
||||||
|
|
||||||
|
IsCanAutoPlay = false;
|
||||||
|
|
||||||
|
if (playlistContext.LastMusicName != null && audioPlayer != null && audioPlayer.Playing)
|
||||||
|
playlistContext.LastMusicTime = audioPlayer.GetPlaybackPosition();
|
||||||
|
|
||||||
|
StopMusic();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overload that delegates to
|
||||||
|
/// <c>UpdateMusicType(scene.ToString())</c>.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateMusicType(GodotSceneType scene)
|
||||||
|
{
|
||||||
|
UpdateMusicType(scene.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scene-transition entry point that orchestrates the
|
||||||
|
/// Combat ↔ Ambient music handoff. Re-initialises the
|
||||||
|
/// playlist for the new scene and determines the appropriate
|
||||||
|
/// <see cref="MusicType"/>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>If the type is unchanged and the current track exists
|
||||||
|
/// in the new playlist, playback continues uninterrupted.</item>
|
||||||
|
/// <item>If the type is unchanged but the track is absent from
|
||||||
|
/// the new playlist, a new random track is picked.</item>
|
||||||
|
/// <item>When transitioning <b>Ambient → Combat</b>: the
|
||||||
|
/// current ambient track name and playback position are saved
|
||||||
|
/// to <c>PlaylistContext</c> so they can be restored after
|
||||||
|
/// combat ends.</item>
|
||||||
|
/// <item>When transitioning <b>Combat → Ambient</b>: the
|
||||||
|
/// saved ambient state is restored. If the saved time is past
|
||||||
|
/// 0.5s, <c>OggPacketParser.CreateTrimmedStream</c> is used to
|
||||||
|
/// create a truncated OGG stream starting from the saved
|
||||||
|
/// position — this avoids the O(N) seek penalty of
|
||||||
|
/// <c>Play(fromPosition)</c> on large OGG files.</item>
|
||||||
|
/// </list>
|
||||||
|
/// When auto-play is disabled the entire method is a no-op
|
||||||
|
/// except for playlist initialisation.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateMusicType(string sceneName)
|
||||||
|
{
|
||||||
|
var previousType = playlistContext.LastMusicType;
|
||||||
|
InitPlaylists(sceneName);
|
||||||
|
|
||||||
|
if (!IsCanAutoPlay)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var newType = AppServices.Current.SceneRoutes.TryGetRoute(sceneName, out var route) ? route.Music : MusicType.Ambient;
|
||||||
|
GameLogger.Log("AUDIO", $"Scene '{sceneName}' -> music type {newType} (was {previousType})");
|
||||||
|
|
||||||
|
if (newType == previousType)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(playlistContext.LastMusicName))
|
||||||
|
{
|
||||||
|
var dict = playlistContext.Playlist?.Musics;
|
||||||
|
if (dict != null && dict.TryGetValue(newType, out var list) && list != null && list.Contains(playlistContext.LastMusicName))
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", $"Keeping current track '{playlistContext.LastMusicName}' (found in scene playlist)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GameLogger.Log("AUDIO", $"Current track '{playlistContext.LastMusicName}' not in scene playlist, switching");
|
||||||
|
NextSound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newType == MusicType.Combat && previousType == MusicType.Ambient)
|
||||||
|
{
|
||||||
|
if (audioPlayer != null && audioPlayer.Playing)
|
||||||
|
{
|
||||||
|
playlistContext.SavedAmbientMusicName = playlistContext.LastMusicName;
|
||||||
|
playlistContext.SavedAmbientMusicTime = audioPlayer.GetPlaybackPosition();
|
||||||
|
GameLogger.Log("AUDIO", $"Saved ambient state: '{playlistContext.SavedAmbientMusicName}' at {playlistContext.SavedAmbientMusicTime:F1}s");
|
||||||
|
}
|
||||||
|
playlistContext.LastMusicType = newType;
|
||||||
|
StopMusic();
|
||||||
|
NextSound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newType == MusicType.Ambient && previousType == MusicType.Combat)
|
||||||
|
{
|
||||||
|
StopMusic();
|
||||||
|
playlistContext.LastMusicType = newType;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(playlistContext.SavedAmbientMusicName) && audioPlayer != null)
|
||||||
|
{
|
||||||
|
var stream = MusicFactory.Instance.Get(playlistContext.SavedAmbientMusicName);
|
||||||
|
if (stream != null)
|
||||||
|
{
|
||||||
|
var savedTime = (double)playlistContext.SavedAmbientMusicTime;
|
||||||
|
GameLogger.Log("AUDIO", $"Restoring ambient: '{playlistContext.SavedAmbientMusicName}' was at {savedTime:F1}s");
|
||||||
|
|
||||||
|
if (savedTime > 0.5f)
|
||||||
|
{
|
||||||
|
var trimmed = OggPacketParser.CreateTrimmedStream(stream, savedTime);
|
||||||
|
if (trimmed != null)
|
||||||
|
{
|
||||||
|
stream = trimmed;
|
||||||
|
GameLogger.Log("AUDIO", $"Trimmed stream created — playing from 0 (effective start {savedTime:F1}s)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioPlayer.Stream = stream;
|
||||||
|
playlistContext.LastMusicName = playlistContext.SavedAmbientMusicName;
|
||||||
|
audioPlayer.Play();
|
||||||
|
playlistContext.SavedAmbientMusicName = null;
|
||||||
|
playlistContext.SavedAmbientMusicTime = 0;
|
||||||
|
autoAdvanceCooldown = Godot.Time.GetTicksUsec() / 1_000_000.0 + 0.5;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GameLogger.Log("AUDIO", $"Restore failed: stream not found for '{playlistContext.SavedAmbientMusicName}'", LogLevel.Error);
|
||||||
|
playlistContext.SavedAmbientMusicName = null;
|
||||||
|
playlistContext.SavedAmbientMusicTime = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", "No saved ambient state to restore");
|
||||||
|
}
|
||||||
|
NextSound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistContext.LastMusicType = newType;
|
||||||
|
NextSound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks a random track from the current playlist's
|
||||||
|
/// <c>LastMusicType</c> bucket, avoiding the immediately
|
||||||
|
/// previous track when the bucket has more than one entry.
|
||||||
|
/// Stops the current player and starts the new track via
|
||||||
|
/// <c>PlayMusic</c>. Skips silently when auto-play is
|
||||||
|
/// disabled or the playlist is empty.
|
||||||
|
/// </summary>
|
||||||
|
public void NextSound()
|
||||||
|
{
|
||||||
|
if (!IsCanAutoPlay)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var dict = playlistContext.Playlist?.Musics;
|
||||||
|
if (dict == null || !dict.TryGetValue(playlistContext.LastMusicType, out var list) || Lists.IsEmpty(list))
|
||||||
|
return;
|
||||||
|
|
||||||
|
int iteration = 0;
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
int index = (int)(GD.Randi() % (uint)list.Count);
|
||||||
|
var nextMusic = list[index];
|
||||||
|
|
||||||
|
if (list.Count == 1 || playlistContext.LastMusicName != nextMusic)
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", $"NextSound -> '{nextMusic}' (type={playlistContext.LastMusicType}, scene={playlistContext.Playlist?.Scene})");
|
||||||
|
|
||||||
|
StopMusic();
|
||||||
|
PlayMusic(nextMusic);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iteration++ > 10)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overload that delegates to
|
||||||
|
/// <c>InitPlaylists(scene.ToString())</c>.
|
||||||
|
/// </summary>
|
||||||
|
public void InitPlaylists(GodotSceneType scene)
|
||||||
|
{
|
||||||
|
InitPlaylists(scene.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the playlist for a scene via
|
||||||
|
/// <c>PlaylistFactory.CreatePlaylist</c>. If the playlist for
|
||||||
|
/// the same scene is already loaded the call is a no-op. Forces
|
||||||
|
/// <c>LastMusicType</c> from <c>Force</c> to <c>Ambient</c> so
|
||||||
|
/// transient forced tracks don't persist across scenes. If the
|
||||||
|
/// resulting playlist has no tracks, disables auto-play
|
||||||
|
/// entirely.
|
||||||
|
/// </summary>
|
||||||
|
public void InitPlaylists(string sceneName)
|
||||||
|
{
|
||||||
|
if (playlistContext.Playlist != null && playlistContext.Playlist.Scene == sceneName)
|
||||||
|
return;
|
||||||
|
|
||||||
|
playlistContext.Playlist = PlaylistFactory.Instance.CreatePlaylist(sceneName);
|
||||||
|
|
||||||
|
if (playlistContext.LastMusicType == MusicType.Force)
|
||||||
|
playlistContext.LastMusicType = MusicType.Ambient;
|
||||||
|
|
||||||
|
var trackCount = playlistContext.Playlist.Musics?.Count ?? 0;
|
||||||
|
GameLogger.Log("AUDIO", $"InitPlaylists scene='{sceneName}' tracks={trackCount} autoplay={IsCanAutoPlay}");
|
||||||
|
|
||||||
|
if (playlistContext.Playlist.Musics == null || playlistContext.Playlist.Musics.Count == 0)
|
||||||
|
IsCanAutoPlay = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manually plays a specific track with an explicit
|
||||||
|
/// <c>AudioStream</c>. Updates <c>LastMusicName</c> and resets
|
||||||
|
/// the auto-advance cooldown to prevent an immediate skip.
|
||||||
|
/// The <paramref name="isLooped"/> parameter is accepted but
|
||||||
|
/// currently unused — OGG streams are expected to be
|
||||||
|
/// non-looping and auto-advance handles the next-track
|
||||||
|
/// selection.
|
||||||
|
/// </summary>
|
||||||
|
public void PlayMusic(string name, AudioStream music, bool isLooped = false)
|
||||||
|
{
|
||||||
|
if (audioPlayer == null || !IsInstanceValid(audioPlayer))
|
||||||
|
TryFindAudioPlayer();
|
||||||
|
if (audioPlayer == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
GameLogger.Log("AUDIO", $"PlayMusic '{name}'");
|
||||||
|
|
||||||
|
playlistContext.LastMusicName = name;
|
||||||
|
audioPlayer.Stream = music;
|
||||||
|
audioPlayer.Play();
|
||||||
|
|
||||||
|
autoAdvanceCooldown = Godot.Time.GetTicksUsec() / 1_000_000.0 + 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays a track by name, resolving the stream from
|
||||||
|
/// <c>MusicFactory</c>. Delegates to the
|
||||||
|
/// <c>PlayMusic(name, stream, isLooped)</c> overload.
|
||||||
|
/// </summary>
|
||||||
|
public void PlayMusic(string name, bool isLooped = false)
|
||||||
|
{
|
||||||
|
PlayMusic(name, MusicFactory.Instance.Get(name), isLooped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resumes the currently-loaded stream on the audio player.
|
||||||
|
/// Unlike the named overloads, does not change the stream
|
||||||
|
/// or <c>LastMusicName</c>. Resets the auto-advance cooldown
|
||||||
|
/// so the just-resumed track isn't replaced immediately.
|
||||||
|
/// </summary>
|
||||||
|
public void PlayMusic()
|
||||||
|
{
|
||||||
|
audioPlayer?.Play();
|
||||||
|
autoAdvanceCooldown = Godot.Time.GetTicksUsec() / 1_000_000.0 + 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pauses the music player in-place via <c>StreamPaused</c>.
|
||||||
|
/// Unlike <c>StopMusic</c>, the stream stays loaded and
|
||||||
|
/// <c>PlayMusic()</c> will resume from the paused sample.
|
||||||
|
/// </summary>
|
||||||
|
public void PauseMusic()
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", "PauseMusic");
|
||||||
|
|
||||||
|
if (audioPlayer != null)
|
||||||
|
audioPlayer.StreamPaused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immediately stops playback on the audio player. The stream
|
||||||
|
/// reference is preserved; a subsequent <c>PlayMusic()</c>
|
||||||
|
/// would restart the same track from the beginning.
|
||||||
|
/// <c>LastMusicName</c> is not cleared, so <c>EnabledAutoPlay</c>
|
||||||
|
/// can restore it from the factory.
|
||||||
|
/// </summary>
|
||||||
|
public void StopMusic()
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", "StopMusic");
|
||||||
|
|
||||||
|
audioPlayer?.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fades the music bus volume from 1 to 0 over 6 seconds after
|
||||||
|
/// a 1-second delay, then stops playback and resets the bus to
|
||||||
|
/// full volume. The fade is implemented via a Godot <c>Tween</c>
|
||||||
|
/// that calls <c>AudioService.SetVolume</c> each frame. If a
|
||||||
|
/// previous fade is still running it is killed before starting the
|
||||||
|
/// new one. The final reset to volume=1 is necessary because the
|
||||||
|
/// settings poll would otherwise keep the bus at zero until the
|
||||||
|
/// next <c>ApplySettings</c> cycle.
|
||||||
|
/// </summary>
|
||||||
|
public void FadeMusic()
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", "FadeMusic start (1s delay + 6s fade)");
|
||||||
|
|
||||||
|
fadeTween?.Kill();
|
||||||
|
fadeTween = CreateTween();
|
||||||
|
fadeTween.TweenInterval(1.0);
|
||||||
|
fadeTween.TweenMethod(
|
||||||
|
Callable.From<float>(vol =>
|
||||||
|
{
|
||||||
|
var service = GetNodeOrNull<AudioService>("/root/AudioService");
|
||||||
|
service?.SetVolume(MixerType.Musics, vol);
|
||||||
|
}),
|
||||||
|
1f, 0f, 6.0
|
||||||
|
);
|
||||||
|
fadeTween.TweenCallback(Callable.From(() =>
|
||||||
|
{
|
||||||
|
StopMusic();
|
||||||
|
var service = GetNodeOrNull<AudioService>("/root/AudioService");
|
||||||
|
service?.SetVolume(MixerType.Musics, 1f);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryFindAudioPlayer()
|
||||||
|
{
|
||||||
|
var service = GetNodeOrNull<AudioService>("/root/AudioService");
|
||||||
|
if (service != null)
|
||||||
|
{
|
||||||
|
audioPlayer = service.GetNodeOrNull<AudioStreamPlayer>("MusicPlayer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.Audio;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializable scene-to-music mapping with an explicit Index for
|
||||||
|
/// ordering. Defaults to <c>Ambient</c> type. Used for serialised
|
||||||
|
/// music configs that need a deterministic per-scene track order
|
||||||
|
/// rather than random selection.
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class MusicSceneItem
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Asset name used as a key in <c>MusicFactory</c> lookups.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zero-based ordering index within the scene's track list.
|
||||||
|
/// Used for deterministic playback order rather than random
|
||||||
|
/// selection.
|
||||||
|
/// </summary>
|
||||||
|
public int Index { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The music category for this track. Defaults to
|
||||||
|
/// <see cref="MusicType.Ambient"/>.
|
||||||
|
/// </summary>
|
||||||
|
public MusicType Type { get; set; } = MusicType.Ambient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parameterless constructor for serialisation. Initialises
|
||||||
|
/// <c>Name</c> to <see cref="string.Empty"/>.
|
||||||
|
/// </summary>
|
||||||
|
public MusicSceneItem()
|
||||||
|
{
|
||||||
|
Name = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructs an entry with a name, an ordering index, and an
|
||||||
|
/// optional music type (defaults to <c>Ambient</c>).
|
||||||
|
/// </summary>
|
||||||
|
public MusicSceneItem(string name, int index, MusicType type = MusicType.Ambient)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Index = index;
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the item name for debug/log display.
|
||||||
|
/// </summary>
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"MusicSceneItem: Name={Name}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Low-level Ogg Vorbis page parser. Reads raw OGG container bytes and
|
||||||
|
/// produces an <c>OggPacketSequence</c> that Godot's
|
||||||
|
/// <c>AudioStreamOggVorbis</c> can consume directly.
|
||||||
|
/// Handles multi-page continuation (a packet split across page
|
||||||
|
/// boundaries via the continuation flag), extracts granule positions
|
||||||
|
/// for accurate seeking, and reads the Vorbis identification header
|
||||||
|
/// to recover the sampling rate. Falls back to 44100 Hz if the header
|
||||||
|
/// is absent or unparseable. Returns <c>null</c> on any corruption,
|
||||||
|
/// logging the offset of the invalid page.
|
||||||
|
/// </summary>
|
||||||
|
public static class OggPacketParser
|
||||||
|
{
|
||||||
|
private const int OGG_PAGE_HEADER_SIZE = 27;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new <c>AudioStreamOggVorbis</c> with audio data
|
||||||
|
/// starting from the given time. Header packets (identification,
|
||||||
|
/// comment, setup) are preserved. Granule positions are normalised
|
||||||
|
/// so <c>Play(0)</c> starts at the target time without the O(N)
|
||||||
|
/// seek penalty that <c>Play(fromPosition)</c> incurs on a full
|
||||||
|
/// OGG stream.
|
||||||
|
/// Returns <c>null</c> if the stream cannot be trimmed (e.g. not
|
||||||
|
/// an OGG, too short, or corrupted).
|
||||||
|
/// </summary>
|
||||||
|
public static AudioStreamOggVorbis CreateTrimmedStream(
|
||||||
|
AudioStream source, double startTimeSeconds)
|
||||||
|
{
|
||||||
|
if (source is not AudioStreamOggVorbis ogg || ogg.PacketSequence == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var seq = ogg.PacketSequence;
|
||||||
|
var packetData = seq.PacketData;
|
||||||
|
var granulePositions = seq.GranulePositions;
|
||||||
|
var samplingRate = seq.SamplingRate;
|
||||||
|
const int HEADER_COUNT = 3;
|
||||||
|
|
||||||
|
if (packetData == null || packetData.Count <= HEADER_COUNT)
|
||||||
|
return null;
|
||||||
|
if (granulePositions == null || granulePositions.Length < packetData.Count)
|
||||||
|
return null;
|
||||||
|
if (startTimeSeconds <= 0 || samplingRate <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int startPacketIndex = -1;
|
||||||
|
long baseGranule = 0;
|
||||||
|
|
||||||
|
for (int i = HEADER_COUNT; i < packetData.Count; i++)
|
||||||
|
{
|
||||||
|
if (i >= granulePositions.Length)
|
||||||
|
break;
|
||||||
|
|
||||||
|
long granule = granulePositions[i];
|
||||||
|
if (granule < 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
double timeSeconds = (double)granule / samplingRate;
|
||||||
|
if (timeSeconds >= startTimeSeconds)
|
||||||
|
{
|
||||||
|
startPacketIndex = i;
|
||||||
|
baseGranule = granule;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startPacketIndex < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var trimmedPacketData = new Godot.Collections.Array<Godot.Collections.Array>();
|
||||||
|
var trimmedGranulePositions = new long[packetData.Count - startPacketIndex + HEADER_COUNT];
|
||||||
|
|
||||||
|
for (int i = 0; i < HEADER_COUNT; i++)
|
||||||
|
{
|
||||||
|
trimmedPacketData.Add(packetData[i]);
|
||||||
|
trimmedGranulePositions[i] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = startPacketIndex; i < packetData.Count; i++)
|
||||||
|
{
|
||||||
|
int trimmedIdx = HEADER_COUNT + (i - startPacketIndex);
|
||||||
|
trimmedPacketData.Add(packetData[i]);
|
||||||
|
|
||||||
|
if (i < granulePositions.Length)
|
||||||
|
{
|
||||||
|
long granule = granulePositions[i];
|
||||||
|
trimmedGranulePositions[trimmedIdx] = granule >= 0
|
||||||
|
? granule - baseGranule
|
||||||
|
: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedSeq = new OggPacketSequence();
|
||||||
|
trimmedSeq.PacketData = trimmedPacketData;
|
||||||
|
trimmedSeq.GranulePositions = trimmedGranulePositions;
|
||||||
|
trimmedSeq.SamplingRate = samplingRate;
|
||||||
|
|
||||||
|
var trimmedStream = new AudioStreamOggVorbis();
|
||||||
|
trimmedStream.PacketSequence = trimmedSeq;
|
||||||
|
return trimmedStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses raw OGG container bytes into a Godot
|
||||||
|
/// <c>OggPacketSequence</c> ready for consumption by
|
||||||
|
/// <c>AudioStreamOggVorbis</c>. Walks the page structure:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Validates the "OggS" magic at each page header.</item>
|
||||||
|
/// <item>Reads the segment table to split pages into individual
|
||||||
|
/// packets, correctly reassembling packets that span page
|
||||||
|
/// boundaries via the continuation flag.</item>
|
||||||
|
/// <item>Assigns the page's granule position to each packet
|
||||||
|
/// extracted from that page, preserving seek metadata.</item>
|
||||||
|
/// <item>Extracts the sampling rate from the first packet's
|
||||||
|
/// Vorbis identification header; falls back to 44100 Hz if
|
||||||
|
/// the header is missing or malformed.</item>
|
||||||
|
/// <item>Any unflushed pending packet at EOF is appended as
|
||||||
|
/// a final packet.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Returns <c>null</c> on corruption (bad magic, segment table
|
||||||
|
/// overflow) or if no packets were extracted, logging the
|
||||||
|
/// error with the offset for debugging.
|
||||||
|
/// </summary>
|
||||||
|
public static OggPacketSequence CreateFromOggBytes(byte[] data)
|
||||||
|
{
|
||||||
|
if (data == null || data.Length < OGG_PAGE_HEADER_SIZE)
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", "OggPacketParser: data is null or too short", LogLevel.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var packets = new List<byte[]>();
|
||||||
|
var granulePositions = new List<long>();
|
||||||
|
int offset = 0;
|
||||||
|
byte[] pendingPacket = null;
|
||||||
|
float samplingRate = 0;
|
||||||
|
bool gotSamplingRate = false;
|
||||||
|
|
||||||
|
while (offset < data.Length)
|
||||||
|
{
|
||||||
|
if (offset + OGG_PAGE_HEADER_SIZE > data.Length)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (!IsOggPage(data, offset))
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", $"OggPacketParser: invalid page marker at offset {offset}", LogLevel.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isContinued = (data[offset + 5] & 0x01) != 0;
|
||||||
|
long granulePos = ReadInt64LE(data, offset + 6);
|
||||||
|
|
||||||
|
int segmentCount = data[offset + 26];
|
||||||
|
int segTableOffset = offset + OGG_PAGE_HEADER_SIZE;
|
||||||
|
|
||||||
|
if (segTableOffset + segmentCount > data.Length)
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", $"OggPacketParser: segment table exceeds data at offset {offset}", LogLevel.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int segDataOffset = segTableOffset + segmentCount;
|
||||||
|
int segDataEnd = CalculateSegmentDataEnd(data, offset, segmentCount);
|
||||||
|
|
||||||
|
int packetsBefore = packets.Count;
|
||||||
|
packets.AddRange(ExtractPackets(
|
||||||
|
data, segTableOffset, segmentCount, segDataOffset,
|
||||||
|
ref pendingPacket, isContinued));
|
||||||
|
int packetsAdded = packets.Count - packetsBefore;
|
||||||
|
|
||||||
|
for (int i = 0; i < packetsAdded; i++)
|
||||||
|
granulePositions.Add(granulePos);
|
||||||
|
|
||||||
|
if (!gotSamplingRate && packets.Count >= 1)
|
||||||
|
{
|
||||||
|
samplingRate = ExtractSamplingRate(packets[0]);
|
||||||
|
gotSamplingRate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = segDataEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingPacket != null && pendingPacket.Length > 0)
|
||||||
|
{
|
||||||
|
packets.Add(pendingPacket);
|
||||||
|
granulePositions.Add(granulePositions.Count > 0
|
||||||
|
? granulePositions[granulePositions.Count - 1]
|
||||||
|
: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packets.Count == 0)
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", "OggPacketParser: no packets extracted", LogLevel.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (granulePositions.Count == 0)
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", "OggPacketParser: no granule positions extracted", LogLevel.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildPacketSequence(packets, granulePositions, samplingRate);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
GameLogger.Log("AUDIO", $"OggPacketParser: error: {ex.Message}", LogLevel.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOggPage(byte[] data, int offset)
|
||||||
|
{
|
||||||
|
return data[offset] == 0x4F && data[offset + 1] == 0x67
|
||||||
|
&& data[offset + 2] == 0x67 && data[offset + 3] == 0x53;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ReadInt64LE(byte[] data, int offset)
|
||||||
|
{
|
||||||
|
return (long)data[offset]
|
||||||
|
| ((long)data[offset + 1] << 8)
|
||||||
|
| ((long)data[offset + 2] << 16)
|
||||||
|
| ((long)data[offset + 3] << 24)
|
||||||
|
| ((long)data[offset + 4] << 32)
|
||||||
|
| ((long)data[offset + 5] << 40)
|
||||||
|
| ((long)data[offset + 6] << 48)
|
||||||
|
| ((long)data[offset + 7] << 56);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CalculateSegmentDataEnd(byte[] data, int pageOffset, int segmentCount)
|
||||||
|
{
|
||||||
|
int segTableOffset = pageOffset + OGG_PAGE_HEADER_SIZE;
|
||||||
|
int end = segTableOffset + segmentCount;
|
||||||
|
for (int i = 0; i < segmentCount; i++)
|
||||||
|
end += data[segTableOffset + i];
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ExtractSamplingRate(byte[] firstPacket)
|
||||||
|
{
|
||||||
|
if (firstPacket == null || firstPacket.Length < 16)
|
||||||
|
return 44100f;
|
||||||
|
|
||||||
|
if (firstPacket[0] == 0x01
|
||||||
|
&& firstPacket[1] == 'v'
|
||||||
|
&& firstPacket[2] == 'o'
|
||||||
|
&& firstPacket[3] == 'r'
|
||||||
|
&& firstPacket[4] == 'b'
|
||||||
|
&& firstPacket[5] == 'i'
|
||||||
|
&& firstPacket[6] == 's')
|
||||||
|
{
|
||||||
|
return BitConverter.ToInt32(firstPacket, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 44100f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<byte[]> ExtractPackets(
|
||||||
|
byte[] data, int segTableOffset, int segmentCount,
|
||||||
|
int segDataOffset, ref byte[] pendingPacket, bool isContinued)
|
||||||
|
{
|
||||||
|
var packets = new List<byte[]>();
|
||||||
|
|
||||||
|
if (!isContinued && pendingPacket != null && pendingPacket.Length > 0)
|
||||||
|
{
|
||||||
|
packets.Add(pendingPacket);
|
||||||
|
}
|
||||||
|
|
||||||
|
int currentOffset = segDataOffset;
|
||||||
|
var currentPacketChunks = new List<byte[]>();
|
||||||
|
|
||||||
|
if (isContinued && pendingPacket != null)
|
||||||
|
{
|
||||||
|
currentPacketChunks.Add(pendingPacket);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < segmentCount; i++)
|
||||||
|
{
|
||||||
|
int segSize = data[segTableOffset + i];
|
||||||
|
if (currentOffset + segSize > data.Length)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (segSize > 0)
|
||||||
|
{
|
||||||
|
var chunk = new byte[segSize];
|
||||||
|
Buffer.BlockCopy(data, currentOffset, chunk, 0, segSize);
|
||||||
|
currentPacketChunks.Add(chunk);
|
||||||
|
currentOffset += segSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isLastSegment = (i == segmentCount - 1);
|
||||||
|
bool packetComplete = segSize < 255 || isLastSegment;
|
||||||
|
|
||||||
|
if (packetComplete && currentPacketChunks.Count > 0)
|
||||||
|
{
|
||||||
|
packets.Add(ConcatChunks(currentPacketChunks));
|
||||||
|
currentPacketChunks.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPacket = currentPacketChunks.Count > 0
|
||||||
|
? ConcatChunks(currentPacketChunks)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return packets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ConcatChunks(List<byte[]> chunks)
|
||||||
|
{
|
||||||
|
if (chunks.Count == 1)
|
||||||
|
return chunks[0];
|
||||||
|
|
||||||
|
int total = 0;
|
||||||
|
foreach (var c in chunks)
|
||||||
|
total += c.Length;
|
||||||
|
|
||||||
|
var result = new byte[total];
|
||||||
|
int offset = 0;
|
||||||
|
foreach (var c in chunks)
|
||||||
|
{
|
||||||
|
Buffer.BlockCopy(c, 0, result, offset, c.Length);
|
||||||
|
offset += c.Length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OggPacketSequence BuildPacketSequence(
|
||||||
|
List<byte[]> packets, List<long> granulePositions, float samplingRate)
|
||||||
|
{
|
||||||
|
var packetArray = new Godot.Collections.Array<Godot.Collections.Array>();
|
||||||
|
foreach (var packet in packets)
|
||||||
|
{
|
||||||
|
var inner = new Godot.Collections.Array();
|
||||||
|
inner.Add(packet);
|
||||||
|
packetArray.Add(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
var packetSequence = new OggPacketSequence();
|
||||||
|
packetSequence.PacketData = packetArray;
|
||||||
|
packetSequence.GranulePositions = granulePositions.ToArray();
|
||||||
|
packetSequence.SamplingRate = samplingRate;
|
||||||
|
return packetSequence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Audio;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime playlist model: a scene name mapped to lists of track names
|
||||||
|
/// grouped by MusicType. Built by PlaylistFactory from the flatter
|
||||||
|
/// <see cref="PlaylistData"/> JSON format.
|
||||||
|
/// </summary>
|
||||||
|
public class Playlist
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The scene name this playlist belongs to (e.g. "Tavern", "Battle").
|
||||||
|
/// Used as the lookup key in <c>PlaylistContext</c> and
|
||||||
|
/// <c>MusicPlayerBehaviour</c> to avoid redundant factory calls
|
||||||
|
/// when the scene hasn't changed.
|
||||||
|
/// </summary>
|
||||||
|
public string Scene { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks grouped by <see cref="MusicType"/>. The dictionary
|
||||||
|
/// provides O(1) access to the track list for the current music
|
||||||
|
/// type during auto-advance. An inner <c>List<string></c>
|
||||||
|
/// is used (rather than a set) so duplicate entries are
|
||||||
|
/// preserved and random selection is uniform.
|
||||||
|
/// </summary>
|
||||||
|
public IDictionary<MusicType, List<string>> Musics { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Cthangover.Core.Audio;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mutable playback state carried across track switches and scene
|
||||||
|
/// transitions. Holds the active Playlist, the last played track/type/time,
|
||||||
|
/// and the saved ambient state (<c>SavedAmbientMusicName</c> /
|
||||||
|
/// <c>SavedAmbientMusicTime</c>) used to restore ambient music after
|
||||||
|
/// combat interruptions.
|
||||||
|
/// </summary>
|
||||||
|
public class PlaylistContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The currently active playlist, resolved by scene name.
|
||||||
|
/// </summary>
|
||||||
|
public Playlist Playlist { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="MusicType"/> of the last played (or
|
||||||
|
/// currently playing) track. Initialised to <c>Force</c>
|
||||||
|
/// and normalised to <c>Ambient</c> on the first playlist
|
||||||
|
/// init.
|
||||||
|
/// </summary>
|
||||||
|
public MusicType LastMusicType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asset name of the last track. Saved across stop/start
|
||||||
|
/// cycles so <c>EnabledAutoPlay</c> can restore it from
|
||||||
|
/// the factory.
|
||||||
|
/// </summary>
|
||||||
|
public string LastMusicName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playback position of the last track, in seconds. Captured
|
||||||
|
/// when auto-play is disabled so the track can resume near
|
||||||
|
/// where it left off.
|
||||||
|
/// </summary>
|
||||||
|
public float LastMusicTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ambient track name saved when entering combat. Restored
|
||||||
|
/// on return to ambient so the player hears the same
|
||||||
|
/// background music that was interrupted.
|
||||||
|
/// </summary>
|
||||||
|
public string SavedAmbientMusicName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ambient track position (seconds) saved alongside
|
||||||
|
/// <see cref="SavedAmbientMusicName"/>. Used to create a
|
||||||
|
/// trimmed OGG stream via <c>OggPacketParser</c> when
|
||||||
|
/// the saved time exceeds 0.5s.
|
||||||
|
/// </summary>
|
||||||
|
public float SavedAmbientMusicTime { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Cthangover.Core.Audio;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Assembles <c>Playlist</c> objects from JSON files under
|
||||||
|
/// <c>music/playlists/</c> across all loaded mods. Each playlist maps
|
||||||
|
/// music tracks to a scene name and categorises them by
|
||||||
|
/// <c>MusicType</c> (ambient, combat, tension, etc.). If a scene has no
|
||||||
|
/// dedicated playlist the factory falls back to <c>"default"</c>,
|
||||||
|
/// guaranteeing that music never cuts to silence when entering an
|
||||||
|
/// unconfigured scene.
|
||||||
|
///
|
||||||
|
/// When several mods define playlists for the same scene, a later-loaded
|
||||||
|
/// mod <b>replaces</b> the whole track list of a matching
|
||||||
|
/// <see cref="MusicType"/> bucket (last one wins) — per-type buckets are
|
||||||
|
/// assigned, not concatenated, so a mod can override stock scene music
|
||||||
|
/// without editing the base game files.
|
||||||
|
/// <para/>
|
||||||
|
/// The single instance is owned by this assembly as a lazily created
|
||||||
|
/// singleton, exposed through the static <see cref="Instance"/> facade.
|
||||||
|
/// </summary>
|
||||||
|
public class PlaylistFactory
|
||||||
|
{
|
||||||
|
private static readonly Lazy<PlaylistFactory> instance = new(() => new PlaylistFactory());
|
||||||
|
|
||||||
|
/// <summary>The lazily created shared instance.</summary>
|
||||||
|
public static PlaylistFactory Instance => instance.Value;
|
||||||
|
|
||||||
|
private const string PlaylistsSubDir = "music/playlists";
|
||||||
|
|
||||||
|
/// <summary>Enum overload delegating to <see cref="CreatePlaylist(string)"/> with the scene name.</summary>
|
||||||
|
public Playlist CreatePlaylist(GodotSceneType sceneName)
|
||||||
|
{
|
||||||
|
return CreatePlaylist(sceneName.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the playlist for a scene from every mod's
|
||||||
|
/// <c>music/playlists/*.json</c>, falling back to the <c>"default"</c>
|
||||||
|
/// playlist when the scene has none (or it is empty).
|
||||||
|
/// </summary>
|
||||||
|
public Playlist CreatePlaylist(string sceneName)
|
||||||
|
{
|
||||||
|
var playlist = CreatePlaylistInternal(sceneName);
|
||||||
|
if (playlist.Musics.Count > 0)
|
||||||
|
return playlist;
|
||||||
|
|
||||||
|
if (string.Equals(sceneName, "default", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return playlist;
|
||||||
|
|
||||||
|
GameLogger.Log("AUDIO", $"No playlist for scene '{sceneName}', falling back to 'default'");
|
||||||
|
return CreatePlaylistInternal("default");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Playlist CreatePlaylistInternal(string sceneName)
|
||||||
|
{
|
||||||
|
var playlist = new Playlist();
|
||||||
|
playlist.Scene = sceneName;
|
||||||
|
playlist.Musics = new Dictionary<MusicType, List<string>>();
|
||||||
|
|
||||||
|
var modManager = ModManager.Instance;
|
||||||
|
foreach (var kvp in modManager.Mods)
|
||||||
|
{
|
||||||
|
var modId = kvp.Key;
|
||||||
|
var provider = kvp.Value.FileProvider;
|
||||||
|
|
||||||
|
foreach (var entry in provider.ListFiles(PlaylistsSubDir))
|
||||||
|
{
|
||||||
|
if (entry.EndsWith("/"))
|
||||||
|
continue;
|
||||||
|
if (!entry.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var jsonText = modManager.ReadResolvedText(modId, entry, provider);
|
||||||
|
if (jsonText == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = JsonSerializer.Deserialize<PlaylistData>(jsonText, JsonOptionsProvider.ReadOptions);
|
||||||
|
if (data == null || string.IsNullOrEmpty(data.Scene) || data.Musics == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (data.Scene != sceneName)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
GameLogger.Log("FACTORY", $"Playlist for scene '{sceneName}' from mod '{modId}' ({entry})");
|
||||||
|
|
||||||
|
foreach (var musicEntry in data.Musics)
|
||||||
|
{
|
||||||
|
if (musicEntry.MusicNames == null || musicEntry.MusicNames.Count == 0)
|
||||||
|
continue;
|
||||||
|
playlist.Musics[musicEntry.MusicType] = new List<string>(musicEntry.MusicNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MODS", $"JSON parse failed for playlist '{modId}/{entry}': {ex.Message}", LogLevel.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// JSON deserialization shape for playlist config files. Flat: one scene
|
||||||
|
/// with a list of <see cref="PlaylistMusicEntry"/> objects.
|
||||||
|
/// The factory resolves this into a <see cref="Playlist"/> where
|
||||||
|
/// entries are already grouped by MusicType for O(1) lookup.
|
||||||
|
/// </summary>
|
||||||
|
public class PlaylistData
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The scene name this config entry targets.
|
||||||
|
/// </summary>
|
||||||
|
public string Scene { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flat list of per-type entries. The factory groups these
|
||||||
|
/// into the dictionary form in <see cref="Playlist.Musics"/>
|
||||||
|
/// for efficient runtime lookup.
|
||||||
|
/// </summary>
|
||||||
|
public List<PlaylistMusicEntry> Musics { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Cthangover.Core.Audio;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A single playlist entry in JSON — pairs a MusicType with the list of
|
||||||
|
/// asset names that belong to it. <c>JsonStringEnumConverter</c> is
|
||||||
|
/// used so the type is serialised as a string rather than an integer.
|
||||||
|
/// </summary>
|
||||||
|
public class PlaylistMusicEntry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The music category for this group of tracks. Serialised as a
|
||||||
|
/// string (e.g. "Combat", "Ambient") via
|
||||||
|
/// <c>JsonStringEnumConverter</c> so JSON configs remain
|
||||||
|
/// human-readable.
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||||
|
public MusicType MusicType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asset names belonging to this <see cref="MusicType"/> group.
|
||||||
|
/// These are resolved by <c>MusicFactory</c> at playback time.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> MusicNames { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.Factories;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Thin <c>AudioFactory</c> for sound effects. While structurally
|
||||||
|
/// identical to <c>MusicFactory</c>, the distinct group name
|
||||||
|
/// <c>"sounds"</c> routes audio through a separate Godot mixer bus —
|
||||||
|
/// sound effects bypass the music channel's reverb and ducking
|
||||||
|
/// processing, and have their own volume slider in settings.
|
||||||
|
/// <para/>
|
||||||
|
/// The single instance is owned by this assembly as a lazily created
|
||||||
|
/// singleton, exposed through the static <see cref="Instance"/> facade.
|
||||||
|
/// </summary>
|
||||||
|
public class SoundFactory : AudioFactory
|
||||||
|
{
|
||||||
|
private static readonly Lazy<SoundFactory> instance = new(() => new SoundFactory());
|
||||||
|
|
||||||
|
internal SoundFactory() : base("sounds", 64) { }
|
||||||
|
|
||||||
|
/// <summary>The lazily created shared instance.</summary>
|
||||||
|
public static SoundFactory Instance => instance.Value;
|
||||||
|
|
||||||
|
/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>sounds</b>.</summary>
|
||||||
|
public override string GroupName => "sounds";
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Cthangover.Mods.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Migration marker for the coverage pipeline
|
||||||
|
/// (<c>SubsystemsBridge</c> reflection scan): declares the audio
|
||||||
|
/// subsystem fully moved out of the core assembly into this engine mod.
|
||||||
|
/// The id must match the <c>DeclaredTests</c> entry in
|
||||||
|
/// <c>SubsystemsCoverageTestSetup</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class SubsystemRegistration
|
||||||
|
{
|
||||||
|
public static string SubsystemId => "audio";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user