This commit is contained in:
iisaev committed 2026-10-18 12:41:40 +03:00
commit 62dcf9d0fd
17 files changed
+1789

No files matched your search

+2
View File
@@ -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).
+2
View File
@@ -0,0 +1,2 @@
mod_audio_base_name = Аудиодвижок
mod_audio_base_desc = Звуковая подсистема движка: реализация IAudioService (шины Music/SFX/Ambient), музыкальный плеер с автопродвижением и фабрики аудио (музыка, звуки, плейлисты).
+13
View File
@@ -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": []
}
+48
View File
@@ -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;
}
}
}
+95
View File
@@ -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);
}
}
}
}
}
+434
View File
@@ -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;
}
}
}
+29
View File
@@ -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";
}
}
+450
View File
@@ -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");
}
}
}
}
+63
View File
@@ -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}";
}
}
}
+353
View File
@@ -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;
}
}
}
+32
View File
@@ -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&lt;string&gt;</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; }
}
}
+58
View File
@@ -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; }
}
}
+113
View File
@@ -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;
}
}
}
+25
View File
@@ -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; }
}
}
+29
View File
@@ -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; }
}
}
+29
View File
@@ -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";
}
}
+14
View File
@@ -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";
}
}