DiamondMusicMixer.cs
using System;

namespace Diamonds;

/// <summary>
/// Sample-clocked 150 BPM clip mixer. Each layer plays at most one clip, aligned to the run's timeline.
/// Tiers rise one per 4-bar phrase and drop at the next bar, but only change which clips are eligible:
/// layers change clips solely through swap rolls every <see cref="SwapBars"/> bars, picking among clips
/// that fit the current tier. Layers out of step with the tier roll first, and every swap in a roll makes
/// the next one less likely, so the music never changes wholesale. Everything is mixed here, so layers
/// can never drift apart.
/// </summary>
public sealed class DiamondMusicMixer
{
	public const int Rate = 44100;
	public const int Bpm = 150;
	public const int BarFrames = Rate * 60 * 4 / Bpm;
	public const int BarsPerPhrase = 4;
	/// <summary>Swap rolls land on the start of the 8-bar chord loop.</summary>
	public const int SwapBars = 8;
	public const int MaxTier = 3;
	const int LayerCount = 5;
	const int SwapFrames = Rate * 3 / 100;
	// Per-sample easing for muffling: about 99% settled in 0.15 s (time constant ~33 ms).
	const float MuffleStep = 0.0007f;
	const float SampleScale = 1f / 32768;

	/// <summary>A decoded clip: 44.1 kHz mono or interleaved stereo, looping at a whole number of bars.</summary>
	public sealed class Track
	{
		/// <summary>Refreshed from the inspector, so weight and tier edits apply live.</summary>
		DiamondMusicClip clip;
		public DiamondMusicClip Clip
		{
			get => clip;
			set { clip = value; Volume = value.Gain(); }
		}
		/// <summary>Cached linear gain of the clip's volume trim.</summary>
		public float Volume { get; private set; }
		public readonly short[] Samples;
		readonly int channels;
		// Existing tracks survive editor hotload with zero in newly added fields.
		public int Channels => channels == 1 ? 1 : 2;
		public readonly int LoopFrames;

		public Track( DiamondMusicClip clip, short[] samples, int channels = 2 )
		{
			if ( channels is not (1 or 2) ) throw new ArgumentOutOfRangeException( nameof(channels) );
			Clip = clip;
			Samples = samples;
			this.channels = channels;
			LoopFrames = Math.Max( 1, (int)MathF.Round( samples.Length / (float)channels / BarFrames ) ) * BarFrames;
		}
	}

	struct Slot
	{
		public Track Track;
		public int Position;
		public float Gain, Step;
	}

	readonly Slot[] current = new Slot[LayerCount];
	// Clips fading out, FadeSlots per layer. A new fade takes a free slot rather than replacing one still
	// audible, which would cut that clip off mid-fade with a click.
	const int FadeSlots = 3;
	readonly Slot[] fading = new Slot[LayerCount * FadeSlots];
	/// <summary>False when hotload left a newly added field unset, so the owner rebuilds the mixer.</summary>
	public bool IsIntact => current is not null && fading is not null && fading.Length == LayerCount * FadeSlots;
	readonly Random random;
	string pendingMotif;
	int pendingTier = -1;
	bool hasPending;
	long runBar;
	/// <summary>Clock frame of bar 0; each new run moves it to the run's first sample.</summary>
	long barOrigin;
	float master, duck = 1, muffle, lowLeft, lowRight;

	public DiamondMusicMixer( int? seed = null ) => random = seed is int value ? new Random( value ) : new Random();

	/// <summary>Every decoded clip available to pick from.</summary>
	public List<Track> Tracks { get; } = new();
	/// <summary>Stereo frames rendered so far; the only music clock.</summary>
	public long Clock { get; private set; }
	public string Motif { get; private set; } = "";
	/// <summary>Latched tier. Rises one step per phrase, drops at the next bar.</summary>
	public int Tier { get; private set; }
	public int RequestedTier { get; set; }
	/// <summary>Chance per swap roll that a layer changes to another clip that fits the current tier.</summary>
	public float SwapChance { get; set; } = 0.25f;
	/// <summary>Each swap in a roll multiplies the chance for the layers still to roll.</summary>
	public float SwapFalloff { get; set; } = 0.35f;
	/// <summary>Freeze the tier and clip choices (no drops, rises or swaps) until the next run: the menu and game over.</summary>
	public bool Hold { get; set; }
	public float MasterTarget { get; set; }
	public float DuckTarget { get; set; } = 1;
	/// <summary>0–1 low-pass over the whole mix, for pause and game over.</summary>
	public float MuffleTarget { get; set; }

	public Track Playing( DiamondMusicLayer layer ) => current[(int)layer].Track;
	public int Position( DiamondMusicLayer layer ) => current[(int)layer].Position;
	public float Gain( DiamondMusicLayer layer ) => current[(int)layer].Gain;
	/// <summary>The loudest clip still fading out of a layer after a swap, or null.</summary>
	public Track Outgoing( DiamondMusicLayer layer )
	{
		Track loudest = null;
		float gain = -1;
		for ( int f = (int)layer * FadeSlots; f < ((int)layer + 1) * FadeSlots; f++ )
			if ( fading[f].Track is not null && fading[f].Gain > gain ) (loudest, gain) = (fading[f].Track, fading[f].Gain);
		return loudest;
	}
	/// <summary>Frames since the current run's first bar.</summary>
	public long RunFrames => Clock - barOrigin - runBar * BarFrames;
	public float MasterGain => master;
	public float DuckGain => duck;
	public float Muffle => muffle;

	/// <summary>
	/// Start a new run on the next sample (a screen switch or restart): restart the bar grid and timeline,
	/// cut over from the old run with a quick crossfade and pick fresh clips for this motif. A
	/// non-negative <paramref name="tier"/> starts the run straight at that tier (the menu); otherwise the
	/// latched tier carries over and follows <see cref="RequestedTier"/> as usual.
	/// </summary>
	public void QueueRun( string motif, int tier = -1 )
	{
		pendingMotif = motif ?? "";
		pendingTier = tier;
		hasPending = true;
	}

	/// <summary>Render interleaved stereo.</summary>
	public void Render( Span<float> stereo )
	{
		for ( int i = 0; i + 1 < stereo.Length; i += 2 )
		{
			// A queued run is a screen switch: restart the bar grid so it begins now, not on a bar line.
			if ( hasPending && (Clock - barOrigin) % BarFrames != 0 ) barOrigin = Clock;
			if ( (Clock - barOrigin) % BarFrames == 0 ) StartBar( (Clock - barOrigin) / BarFrames );
			float left = 0, right = 0;
			for ( int l = 0; l < LayerCount; l++ ) Mix( ref current[l], ref left, ref right );
			for ( int f = 0; f < fading.Length; f++ ) Mix( ref fading[f], ref left, ref right );
			master += (Math.Clamp( MasterTarget, 0, 1 ) - master) * 0.0004f;
			duck += (Math.Clamp( DuckTarget, 0, 1 ) - duck) * 0.0002f;
			muffle += (Math.Clamp( MuffleTarget, 0, 1 ) - muffle) * MuffleStep;
			float filter = 1 - muffle * 0.94f;
			lowLeft += (left - lowLeft) * filter;
			lowRight += (right - lowRight) * filter;
			float gain = duck * master;
			stereo[i] = MathF.Tanh( lowLeft * gain );
			stereo[i + 1] = MathF.Tanh( lowRight * gain );
			Clock++;
		}
	}

	static void Mix( ref Slot slot, ref float left, ref float right )
	{
		if ( slot.Track is null ) return;
		slot.Gain = Math.Clamp( slot.Gain + slot.Step, 0, 1 );
		if ( slot.Gain <= 0 && slot.Step < 0 ) { slot.Track = null; return; }
		var samples = slot.Track.Samples;
		int channels = slot.Track.Channels;
		int index = slot.Position * channels;
		// Clips shorter than their rounded loop are padded with silence.
		if ( index + channels <= samples.Length )
		{
			float gain = slot.Gain * slot.Track.Volume * SampleScale;
			left += samples[index] * gain;
			right += samples[index + channels - 1] * gain;
		}
		if ( ++slot.Position >= slot.Track.LoopFrames ) slot.Position = 0;
	}

	void StartBar( long bar )
	{
		bool fresh = hasPending;
		if ( fresh )
		{
			Motif = pendingMotif;
			hasPending = false;
			runBar = bar;
			if ( pendingTier >= 0 ) Tier = Math.Min( pendingTier, MaxTier );
		}
		// Held (menu, game over): keep the tier and every playing clip until a new run starts.
		if ( Hold && !fresh ) return;
		long songBar = bar - runBar;
		int requested = Math.Clamp( RequestedTier, 0, MaxTier );
		if ( requested < Tier ) Tier = requested;
		else if ( requested > Tier && songBar % BarsPerPhrase == 0 ) Tier++;
		for ( int l = 0; l < LayerCount; l++ )
		{
			// A new run starts every layer fresh; a clip removed from the list is replaced at once.
			var playing = current[l].Track;
			if ( fresh || playing is not null && !Tracks.Contains( playing ) )
				Play( l, Pick( Tracks, (DiamondMusicLayer)l, Tier, Motif, null, random ), songBar, quick: fresh );
		}
		if ( fresh || songBar % SwapBars != 0 ) return;

		// Layers whose clip no longer fits the tier (or that are silent with clips waiting) roll first, so
		// the falloff never starves them; each group rolls in a random order.
		Span<int> order = stackalloc int[LayerCount];
		int stale = 0;
		for ( int l = 0; l < LayerCount; l++ )
			if ( !InStep( l ) ) order[stale++] = l;
		int count = stale;
		for ( int l = 0; l < LayerCount; l++ )
			if ( InStep( l ) ) order[count++] = l;
		Shuffle( order[..stale] );
		Shuffle( order[stale..] );
		float chance = SwapChance;
		foreach ( int l in order )
		{
			if ( random.NextSingle() >= chance ) continue;
			var playing = current[l].Track;
			bool fits = playing is not null && playing.Clip.Fits( Tier, Motif );
			var next = Pick( Tracks, (DiamondMusicLayer)l, Tier, Motif, fits ? playing : null, random );
			// A fitting clip with no alternative, or a silent layer with nothing to play, stays as it is.
			if ( next is null && (fits || playing is null) ) continue;
			Play( l, next, songBar );
			chance *= SwapFalloff;
		}
	}

	/// <summary>True when the layer's clip fits the tier, or it is silent and no clip fits.</summary>
	bool InStep( int layer )
	{
		var playing = current[layer].Track;
		if ( playing is not null ) return playing.Clip.Fits( Tier, Motif );
		foreach ( var track in Tracks )
			if ( track.Clip.Layer == (DiamondMusicLayer)layer && track.Clip.Fits( Tier, Motif ) ) return false;
		return true;
	}

	void Shuffle( Span<int> items )
	{
		for ( int i = items.Length - 1; i > 0; i-- )
		{
			int j = random.Next( i + 1 );
			(items[i], items[j]) = (items[j], items[i]);
		}
	}

	void Play( int layer, Track track, long songBar, bool quick = false )
	{
		ref var slot = ref current[layer];
		// A new run cutting over also hurries fades already under way, from their current level.
		int first = layer * FadeSlots;
		if ( quick )
			for ( int f = first; f < first + FadeSlots; f++ )
				fading[f].Step = MathF.Min( fading[f].Step, -1f / SwapFrames );
		if ( track is null && slot.Track is null ) return;
		// A swap is a quick crossfade on the bar line; a layer going silent fades over half a bar, except
		// when a new run cuts over.
		if ( slot.Track is not null )
		{
			// Take a free fade slot; only if every one is busy, replace the quietest.
			int free = first;
			for ( int f = first; f < first + FadeSlots; f++ )
			{
				if ( fading[f].Track is null ) { free = f; break; }
				if ( fading[f].Gain < fading[free].Gain ) free = f;
			}
			fading[free] = slot;
			fading[free].Step = -1f / (track is null && !quick ? BarFrames / 2 : SwapFrames);
		}
		slot = new Slot
		{
			Track = track,
			Position = track is null ? 0 : (int)(songBar * BarFrames % track.LoopFrames),
			Step = 1f / SwapFrames,
		};
	}

	/// <summary>Weighted random choice among the clips that fit this layer, tier and motif.</summary>
	public static Track Pick( IReadOnlyList<Track> tracks, DiamondMusicLayer layer, int tier, string motif, Track exclude, Random random )
	{
		float total = 0;
		foreach ( var track in tracks )
			if ( track != exclude && track.Clip.Layer == layer && track.Clip.Fits( tier, motif ) ) total += track.Clip.Weight;
		if ( total <= 0 ) return null;
		float roll = random.NextSingle() * total;
		Track last = null;
		foreach ( var track in tracks )
		{
			if ( track == exclude || track.Clip.Layer != layer || !track.Clip.Fits( tier, motif ) ) continue;
			last = track;
			roll -= track.Clip.Weight;
			if ( roll < 0 ) return track;
		}
		return last;
	}
}