manager/Manager.Music.cs

Music manager partial for the game's audio system. Defines music data types (patterns, clips, element defs), builds a clip registry from files, maintains active layers, advances a cycle-driven conductor that adds/removes layers based on intensity, fires clips each cycle, handles menu vs game contexts, and updates volumes and tails.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Sandbox.Audio;

/// <summary>
/// Defines a rhythmic on/off pattern for a music element across cycles.
/// E.g. On=3, Off=1 means play for 3 cycles then silent for 1, repeating.
/// </summary>
public class MusicPattern
{
	public int On = 1;
	public int Off = 0;
	public float Weight = 1f;
}

/// <summary>
/// A single audio clip within a music element, with its own intensity eligibility range.
/// All clips in an element with overlapping ranges may be selected randomly at boundaries.
/// </summary>
public class MusicClip
{
	/// <summary>Filename within the element's folder (e.g. "drums-main.wav"). Full path is populated by BuildClipRegistry.</summary>
	public string File;
	/// <summary>Minimum game intensity [0-1] at which this clip is eligible for selection.</summary>
	public float IntensityMin = 0f;
	/// <summary>Maximum game intensity [0-1] at which this clip is eligible. Overlapping ranges with other clips allow variation at intensity boundaries.</summary>
	public float IntensityMax = 1f;
	/// <summary>If true, this clip plays only once per element activation. On the next cycle the system picks a different in-range clip; if none exist, the element drains.</summary>
	public bool PlayOnce = false;
	/// <summary>If true, this clip is always chosen first on a fresh element activation (before any clips have played). Useful for lead-in clips that must precede a main loop.</summary>
	public bool PlayFirst = false;
	/// <summary>Full virtual path. Set automatically by BuildClipRegistry — do not assign manually.</summary>
	public string Path;
}

/// <summary>
/// Template definition for a music element. Multiple defs can share the same LayerName,
/// meaning only one of them can be active at a time (e.g. bass_light and bass_heavy both on "bass").
/// The conductor picks eligible elements based on the current intensity value.
/// </summary>
public class MusicElementDef
{
	/// <summary>Unique identifier and folder name for clip discovery. Clips are scanned from sounds/music/{Motif}/{FolderName}/.</summary>
	public string FolderName;
	/// <summary>Shared layer slot. Only one element per LayerName can play at a time.</summary>
	public string LayerName;
	/// <summary>Which audio Mixer to route to (found via Mixer.FindMixerByName).</summary>
	public string MixerName = "Music";
	/// <summary>Volume multiplier 0-1, applied on top of MUSIC_VOLUME.</summary>
	public float BaseVolume = 1f;
	/// <summary>Lower value = filled earlier in the run. Layers are sorted by this when the conductor adds elements.</summary>
	public int Priority;
	/// <summary>Core elements (e.g. pad) have a much higher chance of staying each conductor tick.</summary>
	public bool IsCore;
	/// <summary>Minimum intensity (0-1) at which this element becomes eligible.</summary>
	public float IntensityMin;
	/// <summary>Maximum intensity (0-1) at which this element is eligible. Overlapping ranges with other defs on the same layer create organic crossfade zones.</summary>
	public float IntensityMax = 1f;
	/// <summary>Motif group: "shared" is always available, others (e.g. "motif_circuit") are only included when selected for the run.</summary>
	public string Motif = "shared";
	/// <summary>
	/// Clips for this element. Leave empty for auto-discovery (all files in the folder get the element's intensity range).
	/// Populate manually for tiered elements where each clip has its own intensity range (e.g. drums variants).
	/// Paths are auto-populated by BuildClipRegistry — only set File, IntensityMin, IntensityMax.
	/// </summary>
	public List<MusicClip> Clips = new();
	/// <summary>Weighted list of on/off patterns. Null or empty = always on. One is chosen randomly when activated.</summary>
	public List<MusicPattern> Patterns;
}

/// <summary>
/// Runtime state for a currently-playing music layer.
/// </summary>
public class ActiveMusicLayer
{
	/// <summary>Name of the MusicElementDef driving this layer.</summary>
	public string ElementName;
	/// <summary>Which layer slot this occupies (matches MusicElementDef.LayerName).</summary>
	public string LayerName;
	/// <summary>The specific audio file being played (re-fired each cycle).</summary>
	public string ClipPath;
	/// <summary>The MusicPlayer instance for the current cycle's clip.</summary>
	public MusicPlayer Player;
	/// <summary>Number of full cycles this element has been active.</summary>
	public int CyclesSinceAdded;
	/// <summary>Randomized minimum cycles before the conductor can remove this element.</summary>
	public int MinStayCycles;
	/// <summary>Cycles to play before going silent.</summary>
	public int PatternOn = 1;
	/// <summary>Cycles to stay silent before playing again. 0 = always on.</summary>
	public int PatternOff;
	/// <summary>Current position within the pattern (0 to PatternOn+PatternOff-1).</summary>
	public int PatternCycle;
	/// <summary>True when in the silent phase of the pattern.</summary>
	public bool IsPatternMuted => PatternOff > 0 && PatternCycle >= PatternOn;
	/// <summary>True when the conductor has decided to remove this layer. No new clips will fire; player moves to tails at next cycle boundary.</summary>
	public bool Draining;
	/// <summary>Paths of play-once clips that have already fired for this activation. Cleared on re-activation.</summary>
	public HashSet<string> PlayedOnceClips = new();
}

/// <summary>
/// Which song the conductor is currently playing. The same conductor machinery drives both;
/// only the element pool and intensity source differ between contexts.
/// </summary>
public enum MusicContext { None, Game, Menu }

public partial class Manager
{
	private MusicContext _musicContext = MusicContext.None;

	private Dictionary<string, ActiveMusicLayer> _activeLayers = new();
	private List<(MusicPlayer Player, string ClipPath)> _tailPlayers = new();
	private List<MusicElementDef> _runElementPool = new();
	private List<string> _selectedMotifs = new();
	private RealTimeSince _conductorTickTimer;
	private bool _showMusicDebug;
	private bool _conductorTickedDuringChoosing;
	private RealTimeSince _choosingSessionTimer;
	private bool _wasChoosingLastFrame;

	public const float MUSIC_VOLUME = 0.6f;

	/// <summary>Fixed intensity used while in the main menu (lobby). Doesn't affect anything currently.</summary>
	private const float MENU_INTENSITY = 0.3f;

	/// <summary>How long the menu song fades in (seconds) when arriving at the lobby.</summary>
	private const float MENU_FADE_IN_DURATION = 0.5f;
	private RealTimeSince _menuMusicStarted;

	private const float CYCLE_DURATION = 22.588f; // 8 bars at 85 BPM (= 16 bars at 170 BPM)
	private const float CORE_STAY_CHANCE = 0.85f;
	private const float NONCORE_STAY_CHANCE = 0.5f;
	private const float ADD_CHANCE_BASE = 0.65f;
	private const float ADD_CHANCE_CORE = 0.85f;
	private const int MIN_STAY_CYCLES = 1;
	private const int MAX_STAY_CYCLES = 3;
	private const float OUT_OF_RANGE_REMOVE_CHANCE = 0.85f;

	// --- Public API (called from Manager.cs) ---

	void RestartMusic()
	{
		InitMusic();
	}

	void StopMusic()
	{
		StopAllLayers();
	}

	void HandleMusic()
	{
		//if ( Input.Keyboard.Pressed( "M" ) && Game.IsEditor )
		//	_showMusicDebug = !_showMusicDebug;

		// Keep the menu song in sync with lobby state. The same conductor drives both songs.
		if ( GameState == GameState.Lobby )
		{
			if ( _musicContext != MusicContext.Menu )
				StartMenuMusic();
		}
		else if ( _musicContext == MusicContext.Menu )
		{
			// Left the lobby — tear down the menu song. Game music (if any) is started via RestartMusic.
			StopAllLayers();
			_musicContext = MusicContext.None;
		}

		// Detect the start of a new choosing session and reset the per-session tick flag
		if ( IsPausedForChoosing && !_wasChoosingLastFrame )
		{
			_conductorTickedDuringChoosing = false;
			_choosingSessionTimer = 0f;
		}
		_wasChoosingLastFrame = IsPausedForChoosing;

		UpdateLayerVolumes();
		CleanupTailPlayers();

		if ( _conductorTickTimer >= CYCLE_DURATION )
		{
			float overshoot = (float)_conductorTickTimer - CYCLE_DURATION;
			_conductorTickTimer = overshoot;

			// Advance patterns at every cycle boundary (even during pause)
			AdvancePatterns();

			if ( _musicContext == MusicContext.Menu )
			{
				// Menu music has no gameplay pause/choosing concept — tick every cycle.
				ConductorTick();
			}
			else if ( !IsPaused && !IsGameOver && GameState == GameState.Playing )
			{
				if ( IsPausedForChoosing )
				{
					// Allow at most one conductor tick per choosing session, only if it started recently
					if ( !_conductorTickedDuringChoosing && (float)_choosingSessionTimer < 5f )
					{
						ConductorTick();
						_conductorTickedDuringChoosing = true;
					}
				}
				else
				{
					ConductorTick();
				}
			}

			// Fire new clips for all active layers; orphaned players play out their tails naturally
			FireAllClips( overshoot );
		}

		if ( _showMusicDebug )
			DrawMusicDebug();
	}

	void DrawMusicDebug()
	{
		float intensity = CalculateIntensity();
		float cyclePos = (float)_conductorTickTimer;
		float minutes = ElapsedTime / 60f;

		var str = "--- MUSIC DEBUG ---\n";
		str += $"Intensity: {intensity.ToString("0.###")}  Minutes: {minutes.ToString("0.#")}  Paused: {IsPaused}\n";
		str += $"Motifs: [{string.Join( ", ", _selectedMotifs )}]  Pool: {_runElementPool.Count} defs\n";
		str += $"Cycle: {cyclePos:F3}s / {CYCLE_DURATION:F3}s";
		if ( IsPaused ) str += " (PAUSED)";
		str += $"  Tails: {_tailPlayers.Count}";
		str += $"\nActive layers: {_activeLayers.Count}\n\n";

		var sortedKeys = _activeLayers.Keys
			.OrderBy( k => {
				var layer = _activeLayers[k];
				var def = GetElementDef( layer.ElementName );
				return def?.Priority ?? 99;
			} )
			.ToList();

		foreach ( var key in sortedKeys )
		{
			var layer = _activeLayers[key];
			var def = GetElementDef( layer.ElementName );
			int priority = def?.Priority ?? -1;
			bool isCore = def?.IsCore ?? false;

			int stayCyclesLeft = Math.Max( 0, layer.MinStayCycles - layer.CyclesSinceAdded );
			string intensityRange = def != null ? $"{def.IntensityMin:F2}-{def.IntensityMax:F2}" : "?";
			bool inRange = def != null && intensity >= def.IntensityMin && intensity <= def.IntensityMax;

			string clipFile = layer.ClipPath?.Split( '/' ).Last() ?? "?";
			float clipTime = layer.Player?.PlaybackTime ?? 0f;
			float clipDuration = layer.Player?.Duration ?? 0f;
			float loopPct = clipTime / CYCLE_DURATION * 100f;
			string drainTag = layer.Draining ? " [DRAINING]" : "";

			var activeClip = def?.Clips.FirstOrDefault( c => c.Path == layer.ClipPath );
			string clipRange = activeClip != null ? $"{activeClip.IntensityMin:F2}-{activeClip.IntensityMax:F2}" : "?";
			string clipFlags = activeClip != null
				? string.Join( " ", new[]
				{
					activeClip.PlayOnce ? "ONCE" : null,
					activeClip.PlayFirst ? "FIRST" : null,
					layer.PlayedOnceClips.Count > 0 ? $"(used:{layer.PlayedOnceClips.Count})" : null,
				}.Where( s => s != null ) )
				: "";

			str += $"[{key}]{drainTag} {layer.ElementName} ({clipFile})\n";
			str += $"  loop: {clipTime:F3}s / {CYCLE_DURATION:F3}s ({loopPct:F0}%)  |  file: {clipDuration:F3}s\n";
			str += $"  clip range: {clipRange}  {clipFlags}\n";
			str += $"  pri: {priority}{(isCore ? " CORE" : "")}  elem range: {intensityRange} {(inRange ? "OK" : "OUT")}  stay: {stayCyclesLeft} cycle(s)  age: {layer.CyclesSinceAdded} cycle(s)\n";
			if ( layer.PatternOff > 0 )
			{
				int total = layer.PatternOn + layer.PatternOff;
				string blocks = "";
				for ( int i = 0; i < total; i++ )
					blocks += i == layer.PatternCycle ? (i < layer.PatternOn ? "[#]" : "[_]") : (i < layer.PatternOn ? " # " : " _ ");
				str += $"  pattern:{blocks} (play {layer.PatternOn}, skip {layer.PatternOff})\n";
			}
			str += "\n";
		}

		// Show eligible but inactive layers
		var activeLayerNames = _activeLayers
			.Where( p => !p.Value.Draining )
			.Select( p => p.Value.LayerName )
			.ToHashSet();

		var inactiveLayers = _runElementPool
			.Select( d => d.LayerName )
			.Distinct()
			.Where( l => !activeLayerNames.Contains( l ) )
			.ToList();

		if ( inactiveLayers.Count > 0 )
		{
			str += "\nInactive layers:\n";
			foreach ( var layerName in inactiveLayers )
			{
				var allOnLayer = _runElementPool.Where( d => d.LayerName == layerName ).ToList();
				var eligible = allOnLayer.Where( d => intensity >= d.IntensityMin && intensity <= d.IntensityMax ).ToList();
				var ineligible = allOnLayer.Where( d => intensity < d.IntensityMin || intensity > d.IntensityMax ).ToList();

				if ( eligible.Count > 0 )
				{
					str += $"  [{layerName}] {eligible.Count} eligible: ";
					str += string.Join( ", ", eligible.Select( d => $"{d.FolderName} ({d.IntensityMin:F2}-{d.IntensityMax:F2})" ) );
					str += "\n";
				}

				if ( ineligible.Count > 0 )
				{
					foreach ( var d in ineligible )
					{
						string reason = intensity < d.IntensityMin
							? $"need intensity >= {d.IntensityMin:F2}"
							: $"need intensity <= {d.IntensityMax:F2}";
						str += $"  [{layerName}] {d.FolderName} NOT ELIGIBLE ({reason}, current: {intensity:F3})\n";
					}
				}
			}
		}

		if ( _tailPlayers.Count > 0 )
		{
			str += $"\nTails ({_tailPlayers.Count}):\n";
			foreach ( var t in _tailPlayers )
			{
				string clipFile = t.ClipPath?.Split( '/' ).Last() ?? "?";
				float tTime = t.Player?.PlaybackTime ?? 0f;
				float tDur = t.Player?.Duration ?? 0f;
				float tPct = tDur > 0f ? tTime / tDur * 100f : 0f;
				str += $"  {clipFile}  {tTime:F3}s / {tDur:F3}s ({tPct:F0}%)\n";
			}
		}

		Gizmo.Draw.Color = Color.White.WithAlpha( 0.75f );
		Gizmo.Draw.ScreenText( str, new Vector2( 150, 20 ), size: 20f );
	}

	void SetMusicMinute( int minute )
	{
		// Conductor is now cycle-driven — no action on minute change
	}

	// --- Setup ---

	void InitMusic()
	{
		StopAllLayers();

		_musicContext = MusicContext.Game;

		var allDefs = BuildClipRegistry( BuildElementDefs() );

		var motifNames = allDefs
			.Where( d => d.Motif != "shared" )
			.Select( d => d.Motif )
			.Distinct()
			.ToList();

		_selectedMotifs = new List<string>();
		if ( motifNames.Count > 0 )
		{
			_selectedMotifs.Add( motifNames[Game.Random.Next( motifNames.Count )] );

			if ( motifNames.Count > 1 && Game.Random.Float( 0f, 1f ) < 0.5f )
			{
				var remaining = motifNames.Where( m => !_selectedMotifs.Contains( m ) ).ToList();
				if ( remaining.Count > 0 )
					_selectedMotifs.Add( remaining[Game.Random.Next( remaining.Count )] );
			}
		}

		_runElementPool = FilterPoolForMotifs( allDefs, _selectedMotifs );

		// Start with exactly one element: the highest-priority eligible one
		var firstElement = _runElementPool
			.Where( d => 0f >= d.IntensityMin && 0f <= d.IntensityMax )
			.OrderBy( d => d.Priority )
			.FirstOrDefault();

		if ( firstElement != null )
			ActivateElement( firstElement, firstElement.LayerName );

		_conductorTickTimer = 0f;

		//Log.Info( $"Music: InitMusic with motifs [{string.Join( ", ", _selectedMotifs )}], pool size {_runElementPool.Count}" );
	}

	/// <summary>
	/// Starts the main-menu (lobby) song. Reuses the same conductor as gameplay, but with a
	/// separate element pool (BuildMenuElementDefs) and a fixed intensity (MENU_INTENSITY).
	/// </summary>
	void StartMenuMusic()
	{
		StopAllLayers();

		_musicContext = MusicContext.Menu;
		_menuMusicStarted = 0f;
		_selectedMotifs = new List<string>();
		_runElementPool = BuildClipRegistry( BuildMenuElementDefs() );

		// Start with exactly one element: the highest-priority element eligible at the menu intensity
		var firstElement = _runElementPool
			.Where( d => MENU_INTENSITY >= d.IntensityMin && MENU_INTENSITY <= d.IntensityMax )
			.OrderBy( d => d.Priority )
			.FirstOrDefault();

		if ( firstElement != null )
			ActivateElement( firstElement, firstElement.LayerName );

		_conductorTickTimer = 0f;
	}

	void StopAllLayers()
	{
		foreach ( var pair in _activeLayers )
			pair.Value.Player?.Stop();
		_activeLayers.Clear();

		foreach ( var t in _tailPlayers )
			t.Player?.Stop();
		_tailPlayers.Clear();
	}

	// --- Fire / Sync ---

	/// <summary>
	/// At each cycle boundary, fires a new clip for every active non-draining layer.
	/// Old players are moved to _tailPlayers and play out naturally (reverb tails, etc.).
	/// </summary>
	void FireAllClips( float overshoot )
	{
		var toRemove = new List<string>();

		foreach ( var pair in _activeLayers )
		{
			var layer = pair.Value;

			if ( layer.Draining )
			{
				// Move current player to tails so its reverb tail can finish, then drop this layer
				if ( layer.Player != null )
					_tailPlayers.Add( (layer.Player, layer.ClipPath) );
				toRemove.Add( pair.Key );
				//Log.Info( $"Music: FireAllClips [{layer.ElementName}] drained, {_tailPlayers.Count} tail(s)" );
				continue;
			}

			if ( layer.IsPatternMuted )
			{
				// Silent cycle — move current player to tails (or stop it if already silent)
				if ( layer.Player != null )
				{
					_tailPlayers.Add( (layer.Player, layer.ClipPath) );
					layer.Player = null;
				}
				//Log.Info( $"Music: FireAllClips [{layer.ElementName}] pattern muted, no new clip" );
				continue;
			}

			// If the player was just created this cycle boundary (by ActivateElement), keep it — don't cycle into tails
			if ( layer.Player != null && layer.Player.PlaybackTime < 0.5f )
			{
				if ( overshoot > 0f ) layer.Player.Seek( overshoot );
				//Log.Info( $"Music: FireAllClips [{layer.ElementName}] keeping just-activated player (t={layer.Player.PlaybackTime:F3}s)" );
				continue;
			}

			var def = GetElementDef( layer.ElementName );

			// If the outgoing clip was play-once, record it so we never pick it again this activation
			if ( def != null && layer.Player != null )
			{
				var outgoingClip = def.Clips.FirstOrDefault( c => c.Path == layer.ClipPath );
				if ( outgoingClip != null && outgoingClip.PlayOnce )
					layer.PlayedOnceClips.Add( layer.ClipPath );
			}

			// Move current player to tails (it may have a reverb tail beyond the loop point)
			if ( layer.Player != null )
				_tailPlayers.Add( (layer.Player, layer.ClipPath) );

			// Re-pick based on current intensity; exclude exhausted play-once clips.
			// Returns null if all in-range clips are exhausted — element drains in that case.
			string newClipPath = def != null
				? PickClipForIntensity( def, CalculateIntensity(), layer.PlayedOnceClips )
				: layer.ClipPath;

			if ( newClipPath == null )
			{
				// No eligible clip remains (e.g. lead-in played once, nothing else in range)
				toRemove.Add( pair.Key );
				//Log.Info( $"Music: FireAllClips [{layer.ElementName}] draining — no eligible clip after play-once exhaustion" );
				continue;
			}

			var newPlayer = MusicPlayer.Play( FileSystem.Mounted, newClipPath );
			newPlayer.Repeat = false;
			newPlayer.ListenLocal = true;
			newPlayer.TargetMixer = def != null ? (Mixer.FindMixerByName( def.MixerName ) ?? MusicMixer) : MusicMixer;
			newPlayer.Volume = (def?.BaseVolume ?? 1f) * GLOBAL_VOLUME * MUSIC_VOLUME;
			if ( overshoot > 0f ) newPlayer.Seek( overshoot );
			layer.ClipPath = newClipPath;
			layer.Player = newPlayer;

			//Log.Info( $"Music: FireAllClips [{layer.ElementName}] fired new clip, {_tailPlayers.Count} tail(s)" );
		}

		foreach ( var key in toRemove )
			_activeLayers.Remove( key );
	}

	/// <summary>
	/// Removes tail players that have finished playing naturally.
	/// </summary>
	void CleanupTailPlayers()
	{
		_tailPlayers.RemoveAll( t =>
		{
			if ( t.Player == null ) return true;
			bool done = t.Player.Duration > 0f && t.Player.PlaybackTime >= t.Player.Duration - 0.05f;
			if ( done ) t.Player.Stop();
			return done;
		} );
	}

	// --- Clip Registry ---

	[ConCmd( "reload_music_registry" )]
	public static void ReloadMusicRegistry()
	{
		if ( Instance == null )
			return;

		Instance._runElementPool = Instance.FilterPoolForMotifs( BuildClipRegistry( BuildElementDefs() ), Instance._selectedMotifs );
		Log.Info( $"Music registry reloaded, pool size {Instance._runElementPool.Count}" );
	}

	List<MusicElementDef> FilterPoolForMotifs( List<MusicElementDef> allDefs, List<string> motifs )
	{
		return allDefs
			.Where( d => d.Motif == "shared" || motifs.Contains( d.Motif ) )
			.ToList();
	}

	static List<MusicElementDef> BuildElementDefs()
	{
		var defs = new List<MusicElementDef>();

		// shared elements

		defs.Add( new MusicElementDef
		{
			FolderName = "drums",
			LayerName = "drums",
			BaseVolume = 1.0f,
			Priority = 0,
			IsCore = true,
			IntensityMin = 0.0f,
			IntensityMax = 1.0f,
			Motif = "shared",
			Clips = new List<MusicClip>
			{
				new() { File = "sausage-survivors-music-drums-lead-in.ogg",    IntensityMin = 0.00f, IntensityMax = 0.30f, PlayOnce = true, PlayFirst = true },
				new() { File = "sausage-survivors-music-drums-main.ogg",        IntensityMin = 0.00f, IntensityMax = 0.2f },
				new() { File = "sausage-survivors-music-drums-double-time.ogg", IntensityMin = 0.2f, IntensityMax = 0.55f },
				new() { File = "sausage-survivors-music-drums-4x4.ogg",         IntensityMin = 0.7f, IntensityMax = 1.00f },
			},
		} );

		defs.Add( new MusicElementDef
		{
			FolderName = "shakers",
			LayerName = "shakers",
			BaseVolume = 1.0f,
			Priority = 2,
			IsCore = false,
			IntensityMin = 0.25f,
			IntensityMax = 1.0f,
			Motif = "shared",
			Clips = new List<MusicClip>
			{
				//new() { File = "sausage-survivors-music-shakers-lead-in.ogg",    IntensityMin = 0.00f, IntensityMax = 0.55f, PlayOnce = true },
				new() { File = "sausage-survivors-music-shakers-double-time.ogg", IntensityMin = 0.25f, IntensityMax = 1.00f },
			},
		} );

		defs.Add( new MusicElementDef
		{
			FolderName = "bassline",
			LayerName = "bassline",
			BaseVolume = 0.9f,
			Priority = 2,
			IsCore = true,
			IntensityMin = 0.0f,
			IntensityMax = 1.0f,
			Motif = "shared",
			Clips = new List<MusicClip>
			{
				new() { File = "sausage-survivors-music-basslline-acoustic.ogg",     IntensityMin = 0.00f, IntensityMax = 0.20f },
				new() { File = "sausage-survivors-music-basslline-main.ogg",          IntensityMin = 0.10f, IntensityMax = 0.55f },
				new() { File = "sausage-survivors-music-basslline-double-time.ogg",   IntensityMin = 0.45f, IntensityMax = 0.75f },
				new() { File = "sausage-survivors-music-basslline-4x4.ogg",           IntensityMin = 0.65f, IntensityMax = 1.00f },
			},
		} );

		defs.Add( new MusicElementDef
		{
			FolderName = "alien-swag",
			LayerName = "alien-swag",
			BaseVolume = 0.9f,
			Priority = 3,
			IsCore = false,
			IntensityMin = 0.15f,
			IntensityMax = 1.0f,
			Motif = "shared",
			Clips = new List<MusicClip>
			{
				new() { File = "sausage-survivors-music-alien-swag-main.ogg",   IntensityMin = 0.15f, IntensityMax = 0.45f },
				new() { File = "sausage-survivors-music-alien-swag-higher.ogg", IntensityMin = 0.3f, IntensityMax = 0.70f },
				new() { File = "sausage-survivors-music-alien-swag-dense.ogg",  IntensityMin = 0.60f, IntensityMax = 1.00f },
			},
		} );

		defs.Add( new MusicElementDef
		{
			FolderName = "supersaw",
			LayerName = "supersaw",
			BaseVolume = 0.9f,
			Priority = 2,
			IsCore = false,
			IntensityMin = 0.1f,
			IntensityMax = 1.0f,
			Motif = "shared",
			Clips = new List<MusicClip>
			{
				new() { File = "sausage-survivors-music-supersaw-lead-in.ogg", IntensityMin = 0.1f, IntensityMax = 0.40f, PlayOnce = true },
				new() { File = "sausage-survivors-music-supersaw-main.ogg",    IntensityMin = 0.30f, IntensityMax = 0.7f },
				new() { File = "sausage-survivors-music-supersaw-4x4.ogg",     IntensityMin = 0.65f, IntensityMax = 1.00f },
			},
		} );

		defs.Add( new MusicElementDef
		{
			FolderName = "synth",
			LayerName = "synth",
			BaseVolume = 0.9f,
			Priority = 2,
			IsCore = false,
			IntensityMin = 0.1f,
			IntensityMax = 1.0f,
			Motif = "shared",
			// Single clip — auto-discovered from folder
		} );

		return defs;
	}

	/// <summary>
	/// Element defs for the main-menu (lobby) song. The menu plays at a single fixed intensity, so each element
	/// uses one always-eligible "main" loop (range 0-1) with no lead-ins — drums-main plays first, every time.
	/// These are independent def objects: edit them (add tiers, swap clips, point at a dedicated folder/motif) to
	/// customize the menu song without affecting the game song.
	/// </summary>
	static List<MusicElementDef> BuildMenuElementDefs()
	{
		var defs = new List<MusicElementDef>();

		//defs.Add( new MusicElementDef
		//{
		//	FolderName = "drums",
		//	LayerName = "drums",
		//	BaseVolume = 1.0f,
		//	Priority = 0,
		//	IsCore = true,
		//	Motif = "shared",
		//	Clips = new List<MusicClip>
		//	{
		//		new() { File = "sausage-survivors-music-drums-lead-in.ogg", },
		//	},
		//} );

		defs.Add( new MusicElementDef
		{
			FolderName = "bassline",
			LayerName = "bassline",
			BaseVolume = 0.9f,
			Priority = 0,
			IsCore = true,
			Motif = "shared",
			Clips = new List<MusicClip>
			{
				new() { File = "sausage-survivors-music-basslline-acoustic.ogg",     IntensityMin = 0.00f, IntensityMax = 1.0f, PlayFirst = true, PlayOnce = true },
				new() { File = "sausage-survivors-music-basslline-main.ogg",          IntensityMin = 0.00f, IntensityMax = 1.0f },
			},
		} );

		defs.Add( new MusicElementDef
		{
			FolderName = "shakers",
			LayerName = "shakers",
			BaseVolume = 1.0f,
			Priority = 2,
			IsCore = false,
			Motif = "shared",
			Clips = new List<MusicClip>
			{
				new() { File = "sausage-survivors-music-shakers-double-time.ogg" },
			},
		} );

		//defs.Add( new MusicElementDef
		//{
		//	FolderName = "supersaw",
		//	LayerName = "supersaw",
		//	BaseVolume = 0.9f,
		//	Priority = 2,
		//	IsCore = false,
		//	Motif = "shared",
		//	Clips = new List<MusicClip>
		//	{
		//		new() { File = "sausage-survivors-music-supersaw-main.ogg" },
		//	},
		//} );

		defs.Add( new MusicElementDef
		{
			FolderName = "alien-swag",
			LayerName = "alien-swag",
			BaseVolume = 0.9f,
			Priority = 2,
			IsCore = false,
			Motif = "shared",
			Clips = new List<MusicClip>
			{
				new() { File = "sausage-survivors-music-alien-swag-main.ogg" },
			},
		} );

		defs.Add( new MusicElementDef
		{
			FolderName = "synth",
			LayerName = "synth",
			BaseVolume = 0.9f,
			Priority = 2,
			IsCore = false,
			Motif = "shared",
			// Single clip — auto-discovered from folder
		} );

		return defs;
	}

	/// <summary>
	/// Builds the full clip registry by combining element defs with clips.
	/// Tiered elements (Clips pre-populated in BuildElementDefs) get full paths constructed from their filenames.
	/// Simple elements (empty Clips) are auto-discovered from sounds/music/{Motif}/{FolderName}/ and each clip inherits the element's intensity range.
	/// Defs with no clips are excluded.
	/// </summary>
	static List<MusicElementDef> BuildClipRegistry( List<MusicElementDef> defs )
	{
		const string musicRoot = "sounds/music";

		foreach ( var def in defs )
		{
			string folder = $"{musicRoot}/{def.Motif}/{def.FolderName}";

			if ( def.Clips.Count > 0 )
			{
				// Tiered mode: clips were manually specified; construct full paths.
				// Clips with unset intensity (0-1 defaults) inherit the element's range — same effect in practice
				// since a clip can only play while its element is active.
				foreach ( var clip in def.Clips )
				{
					clip.Path = $"{folder}/{clip.File}";
					if ( clip.IntensityMin == 0f && clip.IntensityMax == 1f )
					{
						clip.IntensityMin = def.IntensityMin;
						clip.IntensityMax = def.IntensityMax;
					}
				}
			}
			else
			{
				// Simple mode: auto-discover files; all clips inherit element intensity range
				var files = FindClipsInFolder( folder );
				def.Clips = files.Select( f => new MusicClip
				{
					File = f.Split( '/' ).Last(),
					Path = f,
					IntensityMin = def.IntensityMin,
					IntensityMax = def.IntensityMax,
				} ).ToList();
			}
		}

		// Remove defs that have no clips
		int removed = defs.RemoveAll( d => d.Clips.Count == 0 );
		//if ( removed > 0 )
		//	Log.Warning( $"Music: {removed} element def(s) removed (no clips found in folder)" );

		//foreach ( var def in defs )
		//	Log.Info( $"Music: [{def.FolderName}] ({def.Motif}/{def.LayerName}) - {def.Clips.Count} clip(s)" );

		return defs;
	}

	static readonly string[] CLIP_EXTENSIONS = { "*.ogg", "*.wav", "*.mp3", "*.flac" };

	static List<string> FindClipsInFolder( string folder )
	{
		try
		{
			var clips = CLIP_EXTENSIONS
				.SelectMany( ext => FileSystem.Mounted.FindFile( folder, ext ) )
				.Select( f => $"{folder}/{f}" )
				.ToList();

			if ( clips.Count > 0 )
				Log.Info( $"Music: Found {clips.Count} clip(s) in [{folder}]: {string.Join( ", ", clips.Select( c => c.Split( '/' ).Last() ) )}" );
			else
				Log.Warning( $"Music: No clips found in [{folder}]" );

			return clips;
		}
		catch ( Exception e )
		{
			Log.Warning( $"Music: Failed to scan [{folder}]: {e.Message}" );
			return new List<string>();
		}
	}

	// --- Intensity ---

	float CalculateIntensity()
	{
		if ( _musicContext == MusicContext.Menu )
			return MENU_INTENSITY;

		float minutes = ElapsedTime / 60f;

		if ( HasSpawnedBoss )
		{
			float postBossMinutes = minutes - BOSS_SPAWN_MINUTES_DEFAULT;
			float oscillation = Utils.FastSin( postBossMinutes * 1.5f );
			return Utils.Map( oscillation, -1f, 1f, 0.7f, 1.0f );
		}

		if ( minutes < 2f )
			return Utils.Map( minutes, 0f, 2f, 0.0f, 0.15f, EasingType.SineOut );
		if ( minutes < 5f )
			return Utils.Map( minutes, 2f, 5f, 0.15f, 0.4f );
		if ( minutes < 10f )
			return Utils.Map( minutes, 5f, 10f, 0.4f, 0.7f, EasingType.SineIn );

		return Utils.Map( minutes, 10f, BOSS_SPAWN_MINUTES_DEFAULT, 0.7f, 0.95f, EasingType.QuadIn );
	}

	// --- Conductor ---

	void AdvancePatterns()
	{
		foreach ( var pair in _activeLayers )
		{
			if ( pair.Value.Draining )
				continue;

			int totalLen = pair.Value.PatternOn + pair.Value.PatternOff;
			if ( totalLen > 0 )
				pair.Value.PatternCycle = (pair.Value.PatternCycle + 1) % totalLen;
		}
	}

	void ConductorTick()
	{
		// Increment cycle count for all active (non-draining) layers
		foreach ( var pair in _activeLayers )
		{
			if ( !pair.Value.Draining )
				pair.Value.CyclesSinceAdded++;
		}

		float intensity = CalculateIntensity();
		int activeCount = _activeLayers.Values.Count( l => !l.Draining );

		//Log.Info( $"Music: === ConductorTick at {ElapsedTime.Relative:F1}s | intensity:{intensity:F3} | active:{activeCount} ===" );

		// Phase 1: Consider removing elements.
		// Core elements are only pruned when 4+ layers are active (keep a full arrangement before culling).
		// Non-core elements are always eligible for removal once MinStayCycles is met.
		var layerKeys = _activeLayers.Keys.ToList();

		foreach ( var key in layerKeys )
		{
			var active = _activeLayers[key];
			if ( active.Draining )
				continue;

			var def = GetElementDef( active.ElementName );
			if ( def == null )
				continue;

			// Core elements: skip removal when arrangement is sparse
			if ( def.IsCore && activeCount < 4 )
			{
				//Log.Info( $"Music: Phase1 [{active.ElementName}] KEPT (core, only {activeCount} active)" );
				continue;
			}

			bool minStayMet = active.CyclesSinceAdded >= active.MinStayCycles;
			if ( !minStayMet )
			{
				int remaining = active.MinStayCycles - active.CyclesSinceAdded;
				//Log.Info( $"Music: Phase1 [{active.ElementName}] KEPT (min stay not met, {remaining} cycle(s) left)" );
				continue;
			}

			bool outOfRange = intensity < def.IntensityMin || intensity > def.IntensityMax;

			if ( outOfRange )
			{
				float roll = Game.Random.Float( 0f, 1f );
				if ( roll < OUT_OF_RANGE_REMOVE_CHANCE )
				{
					//Log.Info( $"Music: Phase1 [{active.ElementName}] DRAINING (out of range {def.IntensityMin:F2}-{def.IntensityMax:F2}, roll {roll:F2} < {OUT_OF_RANGE_REMOVE_CHANCE})" );
					active.Draining = true;
				}
				else
				{
					//Log.Info( $"Music: Phase1 [{active.ElementName}] KEPT (out of range but roll {roll:F2} >= {OUT_OF_RANGE_REMOVE_CHANCE})" );
				}
			}
			else
			{
				float stayChance = def.IsCore ? CORE_STAY_CHANCE : NONCORE_STAY_CHANCE;
				float roll = Game.Random.Float( 0f, 1f );
				if ( roll > stayChance )
				{
					//Log.Info( $"Music: Phase1 [{active.ElementName}] DRAINING (in range, roll {roll:F2} > stayChance {stayChance}, core:{def.IsCore})" );
					active.Draining = true;
				}
				else
				{
					//Log.Info( $"Music: Phase1 [{active.ElementName}] KEPT (in range, roll {roll:F2} <= stayChance {stayChance})" );
				}
			}
		}

		// Phase 2: Consider adding elements to empty/draining layers
		var allLayerNames = _runElementPool
			.Select( d => d.LayerName )
			.Distinct()
			.ToList();

		allLayerNames.Sort( ( a, b ) =>
		{
			int pa = _runElementPool.Where( d => d.LayerName == a ).Min( d => d.Priority );
			int pb = _runElementPool.Where( d => d.LayerName == b ).Min( d => d.Priority );
			return pa.CompareTo( pb );
		} );

		foreach ( var layerName in allLayerNames )
		{
			bool occupied = _activeLayers.ContainsKey( layerName ) && !_activeLayers[layerName].Draining;
			if ( occupied )
			{
				//Log.Info( $"Music: Phase2 [{layerName}] SKIP (occupied by {_activeLayers[layerName].ElementName})" );
				continue;
			}

			// Avoid re-adding the element that is currently draining on this layer
			string drainingElement = null;
			if ( _activeLayers.ContainsKey( layerName ) && _activeLayers[layerName].Draining )
				drainingElement = _activeLayers[layerName].ElementName;

			var eligible = _runElementPool
				.Where( d => d.LayerName == layerName
						  && d.FolderName != drainingElement
						  && intensity >= d.IntensityMin
						  && intensity <= d.IntensityMax )
				.ToList();

			if ( eligible.Count == 0 )
			{
				var allOnLayer = _runElementPool.Where( d => d.LayerName == layerName ).ToList();
				string reason = allOnLayer.Count == 0 ? "no defs" : $"none in range (have: {string.Join( ", ", allOnLayer.Select( d => $"{d.FolderName} {d.IntensityMin:F2}-{d.IntensityMax:F2}" ) )})";
				if ( drainingElement != null )
					reason += $", excluding draining {drainingElement}";
				//Log.Info( $"Music: Phase2 [{layerName}] SKIP ({reason})" );
				continue;
			}

			bool hasCore = eligible.Any( d => d.IsCore );
			float addChance = hasCore ? ADD_CHANCE_CORE : ADD_CHANCE_BASE;
			float addRoll = Game.Random.Float( 0f, 1f );

			if ( addRoll > addChance )
			{
				//Log.Info( $"Music: Phase2 [{layerName}] SKIP (roll {addRoll:F2} > addChance {addChance}, eligible: {string.Join( ", ", eligible.Select( d => d.FolderName ) )})" );
				continue;
			}

			// Weight by closeness to intensity range center
			var weights = new Dictionary<MusicElementDef, float>();
			foreach ( var def in eligible )
			{
				float rangeMid = (def.IntensityMin + def.IntensityMax) / 2f;
				float rangeHalf = (def.IntensityMax - def.IntensityMin) / 2f;
				float closeness = rangeHalf > 0f
					? 1f - MathF.Abs( intensity - rangeMid ) / rangeHalf
					: 1f;
				weights[def] = MathF.Max( 0.1f, closeness );
			}

			var chosen = Utils.GetWeightedRandom( weights );
			if ( chosen == null )
				continue;

			//Log.Info( $"Music: Phase2 [{layerName}] ADDING {chosen.FolderName} (roll {addRoll:F2} <= {addChance}, weight {weights[chosen]:F2} from {eligible.Count} eligible)" );
			ActivateElement( chosen, layerName );
			break; // only add 1 element per tick
		}
	}

	void ActivateElement( MusicElementDef def, string layerName )
	{
		string clipPath = PickClipForIntensity( def, CalculateIntensity() );
		if ( clipPath == null ) return;

		// If there's an existing player on this layer, move it to tails
		if ( _activeLayers.ContainsKey( layerName ) )
		{
			var old = _activeLayers[layerName];
			if ( old.Player != null )
				_tailPlayers.Add( (old.Player, old.ClipPath) );
			_activeLayers.Remove( layerName );
		}

		// Start the new clip immediately at full volume
		var player = MusicPlayer.Play( FileSystem.Mounted, clipPath );
		player.Repeat = false;
		player.ListenLocal = true;
		player.TargetMixer = Mixer.FindMixerByName( def.MixerName ) ?? MusicMixer;
		player.Volume = def.BaseVolume * GLOBAL_VOLUME * MUSIC_VOLUME;

		// Pick a pattern (if defined)
		int patternOn = 1;
		int patternOff = 0;
		if ( def.Patterns != null && def.Patterns.Count > 0 )
		{
			var patternWeights = new Dictionary<MusicPattern, float>();
			foreach ( var p in def.Patterns )
				patternWeights[p] = p.Weight;
			var chosen = Utils.GetWeightedRandom( patternWeights );
			if ( chosen != null )
			{
				patternOn = chosen.On;
				patternOff = chosen.Off;
			}
		}

		var active = new ActiveMusicLayer
		{
			ElementName = def.FolderName,
			LayerName = layerName,
			ClipPath = clipPath,
			Player = player,
			CyclesSinceAdded = 0,
			MinStayCycles = Game.Random.Int( MIN_STAY_CYCLES, MAX_STAY_CYCLES ),
			PatternOn = patternOn,
			PatternOff = patternOff,
			PatternCycle = 0,
			Draining = false,
		};

		_activeLayers[layerName] = active;

		string patternLog = patternOff > 0 ? $" pattern:{patternOn}/{patternOff}" : "";
		//Log.Info( $"Music: +[{def.FolderName}] on [{layerName}] clip:{clipPath}{patternLog}" );
	}

	// --- Volume ---

	void UpdateLayerVolumes()
	{
		float pauseMult = IsPaused ? 0.5f : IsPausedForChoosing ? 0.8f : 1f;

		// Fade the menu song in over MENU_FADE_IN_DURATION when arriving at the lobby.
		float fadeMult = _musicContext == MusicContext.Menu
			? MathX.Clamp( (float)_menuMusicStarted / MENU_FADE_IN_DURATION, 0f, 1f )
			: 1f;

		foreach ( var pair in _activeLayers )
		{
			var layer = pair.Value;
			if ( layer.Player == null )
				continue;

			var def = GetElementDef( layer.ElementName );
			float baseVol = def?.BaseVolume ?? 1f;
			float patternMult = layer.IsPatternMuted ? 0f : 1f;
			layer.Player.Volume = baseVol * GLOBAL_VOLUME * MUSIC_VOLUME * pauseMult * patternMult * fadeMult;
		}

		foreach ( var t in _tailPlayers )
		{
			if ( t.Player != null )
				t.Player.Volume = GLOBAL_VOLUME * MUSIC_VOLUME * pauseMult * fadeMult;
		}
	}

	// --- Helpers ---

	/// <summary>
	/// Picks a clip path from the element's clip list that matches the current intensity.
	/// When <paramref name="exclude"/> is non-empty (exhausted play-once clips), only in-range non-excluded clips
	/// are considered; returns null if none remain, signalling the element should drain.
	/// Without exclusions, falls back to the closest clip by range midpoint if no clip covers the exact intensity.
	/// </summary>
	string PickClipForIntensity( MusicElementDef def, float intensity, HashSet<string> exclude = null )
	{
		var inRange = def.Clips
			.Where( c => intensity >= c.IntensityMin && intensity <= c.IntensityMax )
			.ToList();

		// Fresh activation (exclude == null): if any in-range clips are marked PlayFirst, pick exclusively from those.
		// This guarantees a lead-in clip plays before the main loop, even if they share an intensity range.
		if ( exclude == null )
		{
			var firstPicks = inRange.Where( c => c.PlayFirst ).ToList();
			if ( firstPicks.Count > 0 )
				return firstPicks[Game.Random.Next( firstPicks.Count )].Path;
		}

		if ( exclude is { Count: > 0 } )
		{
			// Play-once mode: only pick from in-range clips that haven't been used yet.
			// Return null if none remain — the element will drain.
			var eligible = inRange.Where( c => !exclude.Contains( c.Path ) ).ToList();
			if ( eligible.Count == 0 ) return null;
			return eligible[Game.Random.Next( eligible.Count )].Path;
		}

		// Standard mode: fall back to closest clip if nothing is in range
		if ( inRange.Count == 0 )
		{
			var fallback = def.Clips
				.OrderBy( c => MathF.Abs( (c.IntensityMin + c.IntensityMax) / 2f - intensity ) )
				.FirstOrDefault();
			return fallback?.Path;
		}

		return inRange[Game.Random.Next( inRange.Count )].Path;
	}

	MusicElementDef GetElementDef( string name )
	{
		return _runElementPool.FirstOrDefault( d => d.FolderName == name );
	}
}