DiamondMusic.cs
using System;
using Sandbox.Audio;
namespace Diamonds;
/// <summary>
/// Decodes the inspector's music clips, picks a motif per run, maps run progress onto tiers and streams
/// the clip mixer through the Music mixer.
/// </summary>
public sealed class DiamondMusic
{
const float BufferSeconds = 0.12f;
readonly DiamondMusicMixer mixer = new();
readonly Random random = new();
readonly Dictionary<SoundFile, (short[] Samples, int Channels)> decoded = new();
readonly HashSet<SoundFile> loading = new();
readonly List<string> motifs = new();
SoundStream stream;
SoundHandle handle;
float[] mix = new float[8192];
short[] pcm = new short[8192];
int resetVersion = -1;
bool menuRun;
// A run was queued for a screen switch still fading out; its reset must not queue another.
bool anticipated;
// Times the current wait for clips to decode from when it begins (decoding starts from Update), not from
// construction during scene load, whose own loading and warm-up would otherwise use up the allowance.
bool waitingForClips;
RealTimeSince sinceWaitStarted;
RealTimeSince sinceStartFailed = 10;
public IReadOnlyList<DiamondMusicClip> Clips { get; set; } = Array.Empty<DiamondMusicClip>();
/// <summary>Pieces placed to request tiers 1, 2 and 3. The mixer still climbs one tier per phrase.</summary>
public (int Tier1, int Tier2, int Tier3) TierPieces { get; set; } = (4, 12, 28);
public float Volume { get; set; } = 0.5f;
/// <summary>Forces a tier for auditioning; negative follows pieces placed.</summary>
public int TierOverride { get; set; } = -1;
/// <summary>The fixed tier the menu plays at, with a fresh random motif and clips on every visit.</summary>
public int MenuTier { get; set; } = 2;
public float SwapChance
{
get => mixer.SwapChance;
set => mixer.SwapChance = value;
}
public float SwapFalloff
{
get => mixer.SwapFalloff;
set => mixer.SwapFalloff = value;
}
public int Tier => mixer.Tier;
/// <summary>False for an instance hotloaded from an older shape, whose new fields were never initialized.</summary>
public bool IsIntact => mixer is not null && mixer.IsIntact && decoded is not null && loading is not null && motifs is not null && mix is not null;
public string Motif => mixer.Motif;
/// <summary>
/// Maps the board onto a tier, then tops up the stream. Outside a game (<paramref name="inGame"/> false,
/// the menu) every visit plays a fresh random mix held at <see cref="MenuTier"/>; <paramref name="suspended"/>
/// (paused, tutorial or escape menu) muffles it without stopping the clock, and <paramref name="ducked"/>
/// (the escape menu) also lowers its volume. While <paramref name="switching"/>, <paramref name="inGame"/>
/// names the screen being faded to, whose music starts at once rather than after the fade-out.
/// </summary>
public void Update( DiamondBoard board, bool inGame, bool muted, bool suspended, bool ducked, bool switching = false )
{
#if STANDALONE
#endif
// Wait for every clip to decode, so held runs (the menu) never start with silent layers; a clip that
// never loads stops holding music back after a few seconds.
bool synced = SyncTracks();
if ( synced ) waitingForClips = false;
else if ( !waitingForClips )
{
waitingForClips = true;
sinceWaitStarted = 0;
}
bool ready = synced || sinceWaitStarted > 5;
bool menu = !inGame;
if ( ready && switching )
{
if ( !anticipated )
{
anticipated = true;
menuRun = menu;
mixer.QueueRun( PickMotif(), menu ? Math.Clamp( MenuTier, 0, DiamondMusicMixer.MaxTier ) : -1 );
}
resetVersion = board.ResetVersion;
}
else if ( ready )
{
// The switch's own board reset belongs to the run already queued (unless it was cancelled).
if ( anticipated && menu == menuRun ) resetVersion = board.ResetVersion;
anticipated = false;
}
if ( ready && !switching && (resetVersion != board.ResetVersion || menu != menuRun) )
{
// Every run and every menu visit gets a fresh motif and clips, starting at once (a screen switch).
resetVersion = board.ResetVersion;
menuRun = menu;
mixer.QueueRun( PickMotif(), menu ? Math.Clamp( MenuTier, 0, DiamondMusicMixer.MaxTier ) : -1 );
}
var (tier1, tier2, tier3) = TierPieces;
// The menu and game over keep the clips that are playing; the next run picks fresh ones.
bool over = !menu && board.GameOver && !switching;
mixer.Hold = menu || over;
// A game being faded to starts from tier 0, not the outgoing board's progress.
int tier = switching ? 0 : board.Placed >= tier3 ? 3 : board.Placed >= tier2 ? 2 : board.Placed >= tier1 ? 1 : 0;
mixer.RequestedTier = menu ? Math.Clamp( MenuTier, 0, DiamondMusicMixer.MaxTier ) : TierOverride >= 0 ? TierOverride : tier;
mixer.MasterTarget = muted ? 0 : Volume;
// Pauses and game over only muffle, keeping the music at full volume.
mixer.DuckTarget = ducked ? 0.75f : suspended || over ? 1 : !menu && board.IsSliding ? 0.85f : 1;
mixer.MuffleTarget = suspended || over ? 1 : 0;
Pump();
}
/// <summary>A random motif from the clips' tags, avoiding the previous run's when there is a choice.</summary>
string PickMotif()
{
motifs.Clear();
foreach ( var clip in Clips ?? Array.Empty<DiamondMusicClip>() )
{
if ( clip.Motifs is null ) continue;
foreach ( var tag in clip.Motifs )
{
var motif = tag?.Trim();
if ( !string.IsNullOrEmpty( motif ) && !motifs.Contains( motif, StringComparer.OrdinalIgnoreCase ) ) motifs.Add( motif );
}
}
if ( motifs.Count > 1 ) motifs.RemoveAll( m => string.Equals( m, mixer.Motif, StringComparison.OrdinalIgnoreCase ) );
return motifs.Count == 0 ? "" : motifs[random.Next( motifs.Count )];
}
/// <summary>Mirror the inspector list into mixer tracks. Returns false while any assigned clip is still loading.</summary>
bool SyncTracks()
{
var tracks = mixer.Tracks;
int count = 0;
bool ready = true;
foreach ( var clip in Clips ?? Array.Empty<DiamondMusicClip>() )
{
var sound = clip.Sound;
if ( sound is null ) continue;
if ( !sound.IsValid ) { ready = false; continue; }
if ( !decoded.TryGetValue( sound, out var audio ) ) { Load( sound ); ready = false; continue; }
var samples = audio.Samples;
if ( samples is null ) continue;
if ( count < tracks.Count && tracks[count].Samples == samples ) tracks[count].Clip = clip;
else if ( count < tracks.Count ) tracks[count] = new( clip, samples, audio.Channels );
else tracks.Add( new( clip, samples, audio.Channels ) );
count++;
}
tracks.RemoveRange( count, tracks.Count - count );
return ready;
}
async void Load( SoundFile sound )
{
if ( !loading.Add( sound ) ) return;
short[] result = null;
int channels = 1;
try
{
var samples = await sound.GetSamplesInterleavedAsync();
channels = sound.Channels == 2 ? 2 : 1;
if ( samples is { Length: > 0 } ) result = Resample( samples, channels, sound.Rate );
else Log.Warning( $"Music clip '{sound.ResourcePath}' could not be decoded" );
}
catch ( Exception e ) { Log.Warning( e, $"Music clip '{sound.ResourcePath}' failed to decode" ); }
decoded[sound] = (result, channels);
loading.Remove( sound );
if ( result is null ) return;
float bars = result.Length / (float)channels / DiamondMusicMixer.BarFrames;
if ( MathF.Abs( bars - MathF.Round( bars ) ) > 1 / 16f )
Log.Warning( $"Music clip '{sound.ResourcePath}' is {bars:0.00} bars at {DiamondMusicMixer.Bpm} BPM; it loops at {Math.Max( 1, MathF.Round( bars ) )} bars" );
}
/// <summary>Linear-resample to 44.1 kHz, preserving mono/stereo. Matching PCM is already owned by us.</summary>
static short[] Resample( short[] source, int channels, int rate )
{
int frames = source.Length / channels;
if ( rate <= 0 ) rate = DiamondMusicMixer.Rate;
if ( rate == DiamondMusicMixer.Rate ) return source;
int length = (int)((long)frames * DiamondMusicMixer.Rate / rate);
var result = new short[length * channels];
double step = rate / (double)DiamondMusicMixer.Rate;
for ( int i = 0; i < length; i++ )
{
double position = i * step;
int a = Math.Min( (int)position, frames - 1 );
int b = Math.Min( a + 1, frames - 1 );
float t = (float)(position - a);
for ( int channel = 0; channel < channels; channel++ )
{
result[i * channels + channel] = (short)(source[a * channels + channel] * (1 - t) + source[b * channels + channel] * t);
}
}
return result;
}
/// <summary>Multi-line state for the editor debug overlay.</summary>
public string DebugText( DiamondBoard board )
{
var text = new System.Text.StringBuilder();
long frames = Math.Max( 0, mixer.RunFrames );
long bar = frames / DiamondMusicMixer.BarFrames;
float beat = frames % DiamondMusicMixer.BarFrames / (DiamondMusicMixer.BarFrames / 4f) + 1;
var (tier1, tier2, tier3) = TierPieces;
string target = mixer.RequestedTier == mixer.Tier ? "" : mixer.RequestedTier > mixer.Tier ? $" -> {mixer.RequestedTier} next phrase" : $" -> {mixer.RequestedTier} next bar";
text.AppendLine( $"MUSIC {DiamondMusicMixer.Bpm} BPM phrase {bar / DiamondMusicMixer.BarsPerPhrase + 1} bar {bar % DiamondMusicMixer.BarsPerPhrase + 1}/{DiamondMusicMixer.BarsPerPhrase} beat {beat:0.0}" );
text.AppendLine( $"motif {(string.IsNullOrEmpty( mixer.Motif ) ? "(none)" : mixer.Motif)}" );
if ( mixer.Hold ) target = " (held: game over)";
if ( menuRun ) target = " (menu)";
text.AppendLine( $"tier {mixer.Tier}{target}{(TierOverride >= 0 && !menuRun ? " (override)" : "")}" );
text.AppendLine( $"pieces {board.Placed} (tiers at {tier1}/{tier2}/{tier3})" );
text.AppendLine();
foreach ( var layer in Enum.GetValues<DiamondMusicLayer>() )
{
var track = mixer.Playing( layer );
var fading = mixer.Outgoing( layer );
string clip = track is null ? "-" : $"{Name( track ),-22}bar {mixer.Position( layer ) / DiamondMusicMixer.BarFrames + 1}/{track.LoopFrames / DiamondMusicMixer.BarFrames}{(track.Clip.VolumeDb != 0 ? $" {track.Clip.VolumeDb:+0.#;-0.#} dB" : "")}{(mixer.Gain( layer ) < 1 ? $" fade {mixer.Gain( layer ):0.00}" : "")}";
text.AppendLine( $"{layer,-8}{clip}{(fading is null ? "" : $" (fading {Name( fading )})")}" );
}
text.AppendLine();
int configured = Clips?.Count ?? 0;
string stream = handle is not null && handle.IsValid && handle.IsPlaying ? "playing" : "stopped";
text.AppendLine( $"clips {mixer.Tracks.Count}/{configured} loaded{(loading.Count > 0 ? $", {loading.Count} decoding" : "")} swap {SwapChance:P0} x{SwapFalloff:0.##} per swap, every {DiamondMusicMixer.SwapBars} bars" );
text.Append( $"mix master {mixer.MasterGain:0.00} duck {mixer.DuckGain:0.00} muffle {mixer.Muffle:0.00} stream {stream}" );
return text.ToString();
}
static string Name( DiamondMusicMixer.Track track ) => track.Clip.Sound?.ResourceName ?? "(unnamed)";
void Pump()
{
if ( handle is null || !handle.IsValid )
{
if ( sinceStartFailed < 2 ) return;
Start();
if ( handle is null ) { sinceStartFailed = 0; return; }
}
// Keep a short lead: the mixer clock only advances by what is written, so frame
// timing can never shift the beat, and changes are heard within ~0.1 s.
int frames = (int)(DiamondMusicMixer.Rate * BufferSeconds) - stream.QueuedSampleCount;
int limit = stream.MaxWriteSampleCount;
if ( limit > 0 ) frames = Math.Min( frames, limit );
if ( frames <= 0 ) return;
int length = frames * 2;
if ( mix.Length < length ) { mix = new float[length]; pcm = new short[length]; }
var samples = mix.AsSpan( 0, length );
mixer.Render( samples );
for ( int i = 0; i < length; i++ ) pcm[i] = (short)(samples[i] * short.MaxValue);
stream.WriteData( pcm.AsSpan( 0, length ) );
}
void Start()
{
Stop();
stream = new SoundStream( DiamondMusicMixer.Rate, 2 );
handle = stream.Play();
if ( handle is null || !handle.IsValid ) { Stop(); return; }
handle.ListenLocal = true;
handle.SpacialBlend = 0;
handle.DistanceAttenuation = false;
handle.OcclusionEnabled = false;
handle.ReverbEnabled = false;
handle.AirAbsorption = false;
handle.TargetMixer = Mixer.FindMixerByName( "Music" ) ?? Mixer.Default;
}
public void Stop()
{
handle?.Stop();
handle = null;
stream?.Dispose();
stream = null;
}
}