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;
}
}