From 62dcf9d0fd98d9fe3ade05af9199d2aca7a052b6 Mon Sep 17 00:00:00 2001 From: iisaev Date: Sun, 18 Oct 2026 12:41:40 +0300 Subject: [PATCH] init mod --- locale/en.properties | 2 + locale/ru.properties | 2 + manifest.json | 13 + src/AudioFactory.cs | 48 ++++ src/AudioModInitializer.cs | 95 ++++++++ src/AudioService.cs | 434 +++++++++++++++++++++++++++++++++ src/MusicFactory.cs | 29 +++ src/MusicPlayerBehaviour.cs | 450 +++++++++++++++++++++++++++++++++++ src/MusicSceneItem.cs | 63 +++++ src/OggPacketParser.cs | 353 +++++++++++++++++++++++++++ src/Playlist.cs | 32 +++ src/PlaylistContext.cs | 58 +++++ src/PlaylistFactory.cs | 113 +++++++++ src/PlaylistItemInfo.cs | 25 ++ src/PlaylistMusicEntry.cs | 29 +++ src/SoundFactory.cs | 29 +++ src/SubsystemRegistration.cs | 14 ++ 17 files changed, 1789 insertions(+) create mode 100644 locale/en.properties create mode 100644 locale/ru.properties create mode 100644 manifest.json create mode 100644 src/AudioFactory.cs create mode 100644 src/AudioModInitializer.cs create mode 100644 src/AudioService.cs create mode 100644 src/MusicFactory.cs create mode 100644 src/MusicPlayerBehaviour.cs create mode 100644 src/MusicSceneItem.cs create mode 100644 src/OggPacketParser.cs create mode 100644 src/Playlist.cs create mode 100644 src/PlaylistContext.cs create mode 100644 src/PlaylistFactory.cs create mode 100644 src/PlaylistItemInfo.cs create mode 100644 src/PlaylistMusicEntry.cs create mode 100644 src/SoundFactory.cs create mode 100644 src/SubsystemRegistration.cs diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..7a481ad --- /dev/null +++ b/locale/en.properties @@ -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). diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..cd3794e --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_audio_base_name = Аудиодвижок +mod_audio_base_desc = Звуковая подсистема движка: реализация IAudioService (шины Music/SFX/Ambient), музыкальный плеер с автопродвижением и фабрики аудио (музыка, звуки, плейлисты). diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..f9035cb --- /dev/null +++ b/manifest.json @@ -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": [] +} diff --git a/src/AudioFactory.cs b/src/AudioFactory.cs new file mode 100644 index 0000000..68e666a --- /dev/null +++ b/src/AudioFactory.cs @@ -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 +{ + /// + /// Base factory for loading audio streams from mod archives. + /// .ogg files are parsed through OggPacketParser to + /// produce AudioStreamOggVorbis (Godot needs the raw packet + /// sequence, not just file bytes). .wav files are loaded + /// directly into AudioStreamWav. Inherits the mod-aware + /// caching and fallback logic from PrefabFactory. + /// + public abstract class AudioFactory : PrefabFactory + { + protected AudioFactory(string factoryKey, int fallbackCacheSize) + : base(factoryKey, fallbackCacheSize) { } + + /// Audio extensions from mod config (typically .ogg and .wav), tried in order. + protected override List Extensions { get; } = ModConfig.Instance.AudioExtensions; + + /// Builds an AudioStreamOggVorbis from parsed OGG packets, or an AudioStreamWav from raw PCM bytes. + 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; + } + + } +} diff --git a/src/AudioModInitializer.cs b/src/AudioModInitializer.cs new file mode 100644 index 0000000..6f9517d --- /dev/null +++ b/src/AudioModInitializer.cs @@ -0,0 +1,95 @@ +using Cthangover.Core.Events; +using Cthangover.Core.Mods; +using Cthangover.Core.Utils; + +namespace Cthangover.Mods.Audio +{ + /// + /// Engine-mod initializer for the audio subsystem. Registers the + /// /root/AudioService and /root/MusicPlayer nodes when mod + /// resources become ready and subscribes the music player to scene events, + /// replacing the former core autoloads. Both operations are idempotent: + /// ModManager.Reload() re-runs OnModResourcesReady, so node + /// creation and event subscription must tolerate repeat invocations. + /// + public class AudioModInitializer : IModInitializer + { + private static bool subscribed; + + /// No per-mod setup is required; node registration happens on resources-ready. + public void OnModLoaded(string modId) { } + + /// + /// 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. + /// + 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(OnSceneEntered, priority: 10); + ModEventBus.Subscribe(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); + } + } + } + } +} diff --git a/src/AudioService.cs b/src/AudioService.cs new file mode 100644 index 0000000..0607b0c --- /dev/null +++ b/src/AudioService.cs @@ -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 +{ + /// + /// 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. + /// + /// Lives in the audio mod: the node is created and named + /// AudioService at /root by , so + /// node-path consumers (/root/AudioService) and the + /// AudioService group keep working. Because mod assemblies get no + /// Godot source generation, the class derives from the core + /// ModNode forwarder and hooks the engine through + /// OnReady/OnProcess instead of _Ready/_Process. + /// + public class AudioService : ModNode, IAudioService + { + private AudioStreamPlayer musicPlayer; + private AudioStreamPlayer ambientPlayer; + private readonly Dictionary 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(); + + /// Creates the three audio buses (Music/SFX/Ambient) if missing, the music and ambient players, and applies saved settings. + protected override void OnReady() + { + AddToGroup("AudioService"); + SetupBuses(); + SetupMusicPlayer(); + SetupAmbientPlayer(); + ApplySettings(); + } + + /// Per-frame settings poll — reapplies volumes only when an enabled/volume setting actually changed. + 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; + } + + /// + /// Loads a music stream via MusicFactory 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. + /// + 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(); + } + + /// + /// Stops playback on the Music player. The stream reference is + /// kept intact so a subsequent Play() would restart the + /// same track — external logic (e.g. auto-advance) is expected + /// to replace the stream before the next play. + /// + public void StopMusic() + { + musicPlayer?.Stop(); + } + + /// + /// Toggles the StreamPaused flag on the Music player. + /// When is true playback is + /// suspended in-place; when false it resumes from the + /// exact sample. No stream replacement occurs. + /// + public void PauseMusic(bool pause) + { + if (musicPlayer == null) + return; + musicPlayer.StreamPaused = pause; + } + + /// + /// Returns true 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. + /// + public bool IsMusicPlaying() + { + return musicPlayer?.Playing ?? false; + } + + /// + /// Resolves an ambient loop from SoundFactory 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 + /// Finished signal. + /// + 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(); + } + + /// + /// 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 PlayAmbient loads the stream + /// from scratch. + /// + public void StopAmbient() + { + if (ambientPlayer == null || !ambientPlayer.Playing) + return; + + GameLogger.Log("AUDIO", "AudioService.StopAmbient"); + + ambientPlayer.Stop(); + } + + /// + /// Plays a sound effect from SoundFactory with optional + /// random variation. If is greater + /// than 1, a suffix "_N" is appended to + /// where N is uniformly random in [1, variations]. The result is + /// played on the per- pool player, + /// cutting off any previous sound of the same type. + /// + 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(); + } + + /// + /// Convenience overload that delegates to + /// PlaySound(id, 1, soundType) — no variation suffix is + /// appended, so the exact asset name is used. + /// + public void PlaySound(string id, SoundType soundType) + { + PlaySound(id, 1, soundType); + } + + /// + /// Stops the pooled player for a specific . + /// Other sound-type players are not affected, so e.g. stopping + /// CardEffect won't interrupt an ongoing UI sound. + /// + public void StopSound(SoundType type) + { + if (soundPlayers.TryGetValue(type, out var player) && IsInstanceValid(player)) + player.Stop(); + } + + /// + /// Pauses the player for the given by + /// setting StreamPaused = true. A subsequent + /// PlaySound call will restart it from the paused + /// position because the stream is already loaded. + /// + public void PauseSound(SoundType type) + { + if (soundPlayers.TryGetValue(type, out var player) && IsInstanceValid(player)) + player.StreamPaused = true; + } + + /// + /// Applies a linear volume (0–1) directly to the Godot audio bus + /// identified by . The value is converted + /// to dB via LinearToDb. 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. + /// + 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); + } + + /// + /// Silences the bus by forcing -80 dB when + /// is false. When true + /// the method returns immediately without restoring volume — + /// the next settings poll or SetVolume 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 SetEnabled(false) is a hard mute overlay. + /// + 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); + } + + /// + /// Computes a logarithmic (dB) level for scenarios that need + /// exponential volume scaling (e.g. UI sliders). First clamps + /// the current bus level scaled by + /// to (0.0001, 100], then converts to dB via + /// log10(level/100) * 20. The clamping avoids + /// -infinity dB at zero. + /// + 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; + } + + /// + /// 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. + /// + 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, + }; + } + + /// + /// Converts a linear amplitude (0–1) to decibels using the + /// 20 * log10(linear) formula. Values at or below + /// 0.0001 are clamped to -80 dB to avoid -infinity. + /// Exposed as public static so external code (UI + /// sliders, settings panels) can perform the conversion + /// without holding a reference to the service. + /// + public static float LinearToDb(float linear) + { + if (linear <= 0.0001f) + return -80f; + return Mathf.Log(linear) / Mathf.Log(10) * 20f; + } + } +} diff --git a/src/MusicFactory.cs b/src/MusicFactory.cs new file mode 100644 index 0000000..c3bcf1e --- /dev/null +++ b/src/MusicFactory.cs @@ -0,0 +1,29 @@ +using System; +using Cthangover.Core.Factories; + +namespace Cthangover.Mods.Audio +{ + /// + /// Thin AudioFactory for background music tracks. The separate + /// "music" 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 "sounds" bus used by + /// SoundFactory. + /// + /// The single instance is owned by this assembly as a lazily created + /// singleton, exposed through the static facade. + /// + public class MusicFactory : AudioFactory + { + private static readonly Lazy instance = new(() => new MusicFactory()); + + internal MusicFactory() : base("music", 64) { } + + /// The lazily created shared instance. + public static MusicFactory Instance => instance.Value; + + /// Mod asset group scanned by this factory — files live under mods/*/music. + public override string GroupName => "music"; + + } +} diff --git a/src/MusicPlayerBehaviour.cs b/src/MusicPlayerBehaviour.cs new file mode 100644 index 0000000..0e788a2 --- /dev/null +++ b/src/MusicPlayerBehaviour.cs @@ -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 +{ + /// + /// 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. + /// + /// Lives in the audio mod: the node is created and named + /// MusicPlayer at /root by , + /// keeps the music_player group, and is notified about scene + /// music from the mod's own SceneEnteredEvent / + /// SceneRouteActivatingEvent subscriptions. Because mod + /// assemblies get no Godot source generation, the class derives from + /// the core ModNode forwarder and hooks the engine through + /// OnReady/OnProcess instead of _Ready/_Process. + /// + public class MusicPlayerBehaviour : ModNode + { + private AudioStreamPlayer audioPlayer; + private readonly PlaylistContext playlistContext = new(); + + /// + /// Top-level toggle for the entire auto-advance state machine. + /// When false, NextSound is skipped, no new + /// tracks are selected, and UpdateMusicType does not + /// trigger a switch. Set by EnabledAutoPlay / + /// DisabledAutoPlay and also forced to false + /// when a playlist has no tracks. + /// + public bool IsCanAutoPlay { get; set; } = true; + + private Tween fadeTween; + private bool playlistsInited; + private double autoAdvanceCooldown; + + /// Registers in the music_player group and locates the AudioService music player. + protected override void OnReady() + { + AddToGroup("music_player"); + TryFindAudioPlayer(); + } + + /// + /// 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. + /// + 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("/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; + } + } + + /// + /// Re-enables auto-play after it was disabled. Stops current + /// playback and, if a LastMusicName is saved, restores + /// that track from MusicFactory — effectively resuming + /// the previously-interrupted song. If no track is saved the + /// auto-advance loop in OnProcess will pick the next + /// random track within 0.5s. + /// + 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(); + } + } + + /// + /// Disables auto-play and saves the current playback position + /// into PlaylistContext.LastMusicTime so it can be + /// restored later via EnabledAutoPlay. Stops playback + /// immediately after saving. + /// + public void DisabledAutoPlay() + { + GameLogger.Log("AUDIO", "Autoplay disabled"); + + IsCanAutoPlay = false; + + if (playlistContext.LastMusicName != null && audioPlayer != null && audioPlayer.Playing) + playlistContext.LastMusicTime = audioPlayer.GetPlaybackPosition(); + + StopMusic(); + } + + /// + /// Overload that delegates to + /// UpdateMusicType(scene.ToString()). + /// + public void UpdateMusicType(GodotSceneType scene) + { + UpdateMusicType(scene.ToString()); + } + + /// + /// Scene-transition entry point that orchestrates the + /// Combat ↔ Ambient music handoff. Re-initialises the + /// playlist for the new scene and determines the appropriate + /// : + /// + /// If the type is unchanged and the current track exists + /// in the new playlist, playback continues uninterrupted. + /// If the type is unchanged but the track is absent from + /// the new playlist, a new random track is picked. + /// When transitioning Ambient → Combat: the + /// current ambient track name and playback position are saved + /// to PlaylistContext so they can be restored after + /// combat ends. + /// When transitioning Combat → Ambient: the + /// saved ambient state is restored. If the saved time is past + /// 0.5s, OggPacketParser.CreateTrimmedStream is used to + /// create a truncated OGG stream starting from the saved + /// position — this avoids the O(N) seek penalty of + /// Play(fromPosition) on large OGG files. + /// + /// When auto-play is disabled the entire method is a no-op + /// except for playlist initialisation. + /// + 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(); + } + + /// + /// Picks a random track from the current playlist's + /// LastMusicType bucket, avoiding the immediately + /// previous track when the bucket has more than one entry. + /// Stops the current player and starts the new track via + /// PlayMusic. Skips silently when auto-play is + /// disabled or the playlist is empty. + /// + 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; + } + } + + /// + /// Overload that delegates to + /// InitPlaylists(scene.ToString()). + /// + public void InitPlaylists(GodotSceneType scene) + { + InitPlaylists(scene.ToString()); + } + + /// + /// Builds the playlist for a scene via + /// PlaylistFactory.CreatePlaylist. If the playlist for + /// the same scene is already loaded the call is a no-op. Forces + /// LastMusicType from Force to Ambient so + /// transient forced tracks don't persist across scenes. If the + /// resulting playlist has no tracks, disables auto-play + /// entirely. + /// + 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; + } + + /// + /// Manually plays a specific track with an explicit + /// AudioStream. Updates LastMusicName and resets + /// the auto-advance cooldown to prevent an immediate skip. + /// The parameter is accepted but + /// currently unused — OGG streams are expected to be + /// non-looping and auto-advance handles the next-track + /// selection. + /// + 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; + } + + /// + /// Plays a track by name, resolving the stream from + /// MusicFactory. Delegates to the + /// PlayMusic(name, stream, isLooped) overload. + /// + public void PlayMusic(string name, bool isLooped = false) + { + PlayMusic(name, MusicFactory.Instance.Get(name), isLooped); + } + + /// + /// Resumes the currently-loaded stream on the audio player. + /// Unlike the named overloads, does not change the stream + /// or LastMusicName. Resets the auto-advance cooldown + /// so the just-resumed track isn't replaced immediately. + /// + public void PlayMusic() + { + audioPlayer?.Play(); + autoAdvanceCooldown = Godot.Time.GetTicksUsec() / 1_000_000.0 + 0.5; + } + + /// + /// Pauses the music player in-place via StreamPaused. + /// Unlike StopMusic, the stream stays loaded and + /// PlayMusic() will resume from the paused sample. + /// + public void PauseMusic() + { + GameLogger.Log("AUDIO", "PauseMusic"); + + if (audioPlayer != null) + audioPlayer.StreamPaused = true; + } + + /// + /// Immediately stops playback on the audio player. The stream + /// reference is preserved; a subsequent PlayMusic() + /// would restart the same track from the beginning. + /// LastMusicName is not cleared, so EnabledAutoPlay + /// can restore it from the factory. + /// + public void StopMusic() + { + GameLogger.Log("AUDIO", "StopMusic"); + + audioPlayer?.Stop(); + } + + /// + /// 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 Tween + /// that calls AudioService.SetVolume 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 ApplySettings cycle. + /// + public void FadeMusic() + { + GameLogger.Log("AUDIO", "FadeMusic start (1s delay + 6s fade)"); + + fadeTween?.Kill(); + fadeTween = CreateTween(); + fadeTween.TweenInterval(1.0); + fadeTween.TweenMethod( + Callable.From(vol => + { + var service = GetNodeOrNull("/root/AudioService"); + service?.SetVolume(MixerType.Musics, vol); + }), + 1f, 0f, 6.0 + ); + fadeTween.TweenCallback(Callable.From(() => + { + StopMusic(); + var service = GetNodeOrNull("/root/AudioService"); + service?.SetVolume(MixerType.Musics, 1f); + })); + } + + private void TryFindAudioPlayer() + { + var service = GetNodeOrNull("/root/AudioService"); + if (service != null) + { + audioPlayer = service.GetNodeOrNull("MusicPlayer"); + } + } + } +} diff --git a/src/MusicSceneItem.cs b/src/MusicSceneItem.cs new file mode 100644 index 0000000..a4d2afb --- /dev/null +++ b/src/MusicSceneItem.cs @@ -0,0 +1,63 @@ +using System; +using Cthangover.Core.Audio; + +namespace Cthangover.Mods.Audio +{ + + /// + /// Serializable scene-to-music mapping with an explicit Index for + /// ordering. Defaults to Ambient type. Used for serialised + /// music configs that need a deterministic per-scene track order + /// rather than random selection. + /// + [Serializable] + public class MusicSceneItem + { + /// + /// Asset name used as a key in MusicFactory lookups. + /// + public string Name { get; set; } + + /// + /// Zero-based ordering index within the scene's track list. + /// Used for deterministic playback order rather than random + /// selection. + /// + public int Index { get; set; } + + /// + /// The music category for this track. Defaults to + /// . + /// + public MusicType Type { get; set; } = MusicType.Ambient; + + /// + /// Parameterless constructor for serialisation. Initialises + /// Name to . + /// + public MusicSceneItem() + { + Name = string.Empty; + } + + /// + /// Constructs an entry with a name, an ordering index, and an + /// optional music type (defaults to Ambient). + /// + public MusicSceneItem(string name, int index, MusicType type = MusicType.Ambient) + { + Name = name; + Index = index; + Type = type; + } + + /// + /// Returns the item name for debug/log display. + /// + public override string ToString() + { + return $"MusicSceneItem: Name={Name}"; + } + } + +} diff --git a/src/OggPacketParser.cs b/src/OggPacketParser.cs new file mode 100644 index 0000000..5299578 --- /dev/null +++ b/src/OggPacketParser.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Mods.Audio +{ + /// + /// Low-level Ogg Vorbis page parser. Reads raw OGG container bytes and + /// produces an OggPacketSequence that Godot's + /// AudioStreamOggVorbis 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 null on any corruption, + /// logging the offset of the invalid page. + /// + public static class OggPacketParser + { + private const int OGG_PAGE_HEADER_SIZE = 27; + + /// + /// Creates a new AudioStreamOggVorbis with audio data + /// starting from the given time. Header packets (identification, + /// comment, setup) are preserved. Granule positions are normalised + /// so Play(0) starts at the target time without the O(N) + /// seek penalty that Play(fromPosition) incurs on a full + /// OGG stream. + /// Returns null if the stream cannot be trimmed (e.g. not + /// an OGG, too short, or corrupted). + /// + 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(); + 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; + } + + /// + /// Parses raw OGG container bytes into a Godot + /// OggPacketSequence ready for consumption by + /// AudioStreamOggVorbis. Walks the page structure: + /// + /// Validates the "OggS" magic at each page header. + /// Reads the segment table to split pages into individual + /// packets, correctly reassembling packets that span page + /// boundaries via the continuation flag. + /// Assigns the page's granule position to each packet + /// extracted from that page, preserving seek metadata. + /// Extracts the sampling rate from the first packet's + /// Vorbis identification header; falls back to 44100 Hz if + /// the header is missing or malformed. + /// Any unflushed pending packet at EOF is appended as + /// a final packet. + /// + /// Returns null on corruption (bad magic, segment table + /// overflow) or if no packets were extracted, logging the + /// error with the offset for debugging. + /// + 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(); + var granulePositions = new List(); + 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 ExtractPackets( + byte[] data, int segTableOffset, int segmentCount, + int segDataOffset, ref byte[] pendingPacket, bool isContinued) + { + var packets = new List(); + + if (!isContinued && pendingPacket != null && pendingPacket.Length > 0) + { + packets.Add(pendingPacket); + } + + int currentOffset = segDataOffset; + var currentPacketChunks = new List(); + + 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 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 packets, List granulePositions, float samplingRate) + { + var packetArray = new 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; + } + } +} diff --git a/src/Playlist.cs b/src/Playlist.cs new file mode 100644 index 0000000..79c7026 --- /dev/null +++ b/src/Playlist.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using Cthangover.Core.Audio; + +namespace Cthangover.Mods.Audio +{ + + /// + /// Runtime playlist model: a scene name mapped to lists of track names + /// grouped by MusicType. Built by PlaylistFactory from the flatter + /// JSON format. + /// + public class Playlist + { + /// + /// The scene name this playlist belongs to (e.g. "Tavern", "Battle"). + /// Used as the lookup key in PlaylistContext and + /// MusicPlayerBehaviour to avoid redundant factory calls + /// when the scene hasn't changed. + /// + public string Scene { get; set; } + + /// + /// Tracks grouped by . The dictionary + /// provides O(1) access to the track list for the current music + /// type during auto-advance. An inner List<string> + /// is used (rather than a set) so duplicate entries are + /// preserved and random selection is uniform. + /// + public IDictionary> Musics { get; set; } + } + +} diff --git a/src/PlaylistContext.cs b/src/PlaylistContext.cs new file mode 100644 index 0000000..20cc214 --- /dev/null +++ b/src/PlaylistContext.cs @@ -0,0 +1,58 @@ +using Cthangover.Core.Audio; + +namespace Cthangover.Mods.Audio +{ + + /// + /// 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 (SavedAmbientMusicName / + /// SavedAmbientMusicTime) used to restore ambient music after + /// combat interruptions. + /// + public class PlaylistContext + { + /// + /// The currently active playlist, resolved by scene name. + /// + public Playlist Playlist { get; set; } + + /// + /// The of the last played (or + /// currently playing) track. Initialised to Force + /// and normalised to Ambient on the first playlist + /// init. + /// + public MusicType LastMusicType { get; set; } + + /// + /// Asset name of the last track. Saved across stop/start + /// cycles so EnabledAutoPlay can restore it from + /// the factory. + /// + public string LastMusicName { get; set; } + + /// + /// Playback position of the last track, in seconds. Captured + /// when auto-play is disabled so the track can resume near + /// where it left off. + /// + public float LastMusicTime { get; set; } + + /// + /// Ambient track name saved when entering combat. Restored + /// on return to ambient so the player hears the same + /// background music that was interrupted. + /// + public string SavedAmbientMusicName { get; set; } + + /// + /// Ambient track position (seconds) saved alongside + /// . Used to create a + /// trimmed OGG stream via OggPacketParser when + /// the saved time exceeds 0.5s. + /// + public float SavedAmbientMusicTime { get; set; } + } + +} diff --git a/src/PlaylistFactory.cs b/src/PlaylistFactory.cs new file mode 100644 index 0000000..1c430cc --- /dev/null +++ b/src/PlaylistFactory.cs @@ -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 +{ + /// + /// Assembles Playlist objects from JSON files under + /// music/playlists/ across all loaded mods. Each playlist maps + /// music tracks to a scene name and categorises them by + /// MusicType (ambient, combat, tension, etc.). If a scene has no + /// dedicated playlist the factory falls back to "default", + /// 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 replaces the whole track list of a matching + /// 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. + /// + /// The single instance is owned by this assembly as a lazily created + /// singleton, exposed through the static facade. + /// + public class PlaylistFactory + { + private static readonly Lazy instance = new(() => new PlaylistFactory()); + + /// The lazily created shared instance. + public static PlaylistFactory Instance => instance.Value; + + private const string PlaylistsSubDir = "music/playlists"; + + /// Enum overload delegating to with the scene name. + public Playlist CreatePlaylist(GodotSceneType sceneName) + { + return CreatePlaylist(sceneName.ToString()); + } + + /// + /// Builds the playlist for a scene from every mod's + /// music/playlists/*.json, falling back to the "default" + /// playlist when the scene has none (or it is empty). + /// + 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>(); + + 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(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(musicEntry.MusicNames); + } + } + catch (JsonException ex) + { + GameLogger.Log("MODS", $"JSON parse failed for playlist '{modId}/{entry}': {ex.Message}", LogLevel.Error); + } + } + } + + return playlist; + } + } +} diff --git a/src/PlaylistItemInfo.cs b/src/PlaylistItemInfo.cs new file mode 100644 index 0000000..d84aa6f --- /dev/null +++ b/src/PlaylistItemInfo.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Cthangover.Mods.Audio +{ + /// + /// JSON deserialization shape for playlist config files. Flat: one scene + /// with a list of objects. + /// The factory resolves this into a where + /// entries are already grouped by MusicType for O(1) lookup. + /// + public class PlaylistData + { + /// + /// The scene name this config entry targets. + /// + public string Scene { get; set; } + + /// + /// Flat list of per-type entries. The factory groups these + /// into the dictionary form in + /// for efficient runtime lookup. + /// + public List Musics { get; set; } + } +} diff --git a/src/PlaylistMusicEntry.cs b/src/PlaylistMusicEntry.cs new file mode 100644 index 0000000..e78d1d1 --- /dev/null +++ b/src/PlaylistMusicEntry.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Cthangover.Core.Audio; + +namespace Cthangover.Mods.Audio +{ + /// + /// A single playlist entry in JSON — pairs a MusicType with the list of + /// asset names that belong to it. JsonStringEnumConverter is + /// used so the type is serialised as a string rather than an integer. + /// + public class PlaylistMusicEntry + { + /// + /// The music category for this group of tracks. Serialised as a + /// string (e.g. "Combat", "Ambient") via + /// JsonStringEnumConverter so JSON configs remain + /// human-readable. + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public MusicType MusicType { get; set; } + + /// + /// Asset names belonging to this group. + /// These are resolved by MusicFactory at playback time. + /// + public List MusicNames { get; set; } + } +} diff --git a/src/SoundFactory.cs b/src/SoundFactory.cs new file mode 100644 index 0000000..db1ba79 --- /dev/null +++ b/src/SoundFactory.cs @@ -0,0 +1,29 @@ +using System; +using Cthangover.Core.Factories; + +namespace Cthangover.Mods.Audio +{ + /// + /// Thin AudioFactory for sound effects. While structurally + /// identical to MusicFactory, the distinct group name + /// "sounds" 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. + /// + /// The single instance is owned by this assembly as a lazily created + /// singleton, exposed through the static facade. + /// + public class SoundFactory : AudioFactory + { + private static readonly Lazy instance = new(() => new SoundFactory()); + + internal SoundFactory() : base("sounds", 64) { } + + /// The lazily created shared instance. + public static SoundFactory Instance => instance.Value; + + /// Mod asset group scanned by this factory — files live under mods/*/sounds. + public override string GroupName => "sounds"; + + } +} diff --git a/src/SubsystemRegistration.cs b/src/SubsystemRegistration.cs new file mode 100644 index 0000000..dcba78e --- /dev/null +++ b/src/SubsystemRegistration.cs @@ -0,0 +1,14 @@ +namespace Cthangover.Mods.Audio +{ + /// + /// Migration marker for the coverage pipeline + /// (SubsystemsBridge reflection scan): declares the audio + /// subsystem fully moved out of the core assembly into this engine mod. + /// The id must match the DeclaredTests entry in + /// SubsystemsCoverageTestSetup. + /// + public static class SubsystemRegistration + { + public static string SubsystemId => "audio"; + } +}