A Component that procedurally generates and streams endless ska/reggae-rock music. It exposes many inspector properties for synthesis parameters, generates songs on worker threads, writes audio into a SoundStream with crossfades and supports shareable seed strings, saving WAV exports, and optional progress persistence to FileSystem.Data.
using System;
using System.Threading.Tasks;
using Sandbox;
namespace Skafinity;
/// <summary>
/// Streams an endless, deterministic procedural ska / reggae-rock track (see
/// <see cref="MusicGen"/>) through Web Audio-style scheduling over a <see cref="SoundStream"/>.
///
/// Drop this <see cref="Component"/> on any GameObject. It generates a ~80s loop from the
/// seed <c>tag:n</c>, plays it <see cref="LoopsPerSong"/> times, then equal-power crossfades
/// into the pre-generated next song (<c>tag:n+1</c>), forever. Every generator knob is an
/// inspector <c>[Property]</c>; with <see cref="LiveReload"/> on, tweaking one regenerates
/// after a short settle so you can dial in a vibe in play mode.
///
/// A whole song is just its seed, so the arrangement is shareable: copy <see cref="CurrentSeed"/>
/// (<c>vibe:tag:n</c>) and anyone who calls <see cref="PlaySeed"/> with it hears the same track.
///
/// This is a self-contained extraction of the Rotaliate music engine with no game-specific
/// dependencies (no player data, networking, or UI). Persistence of the song index is opt-in
/// via <see cref="PersistProgress"/>.
/// </summary>
public sealed class SkafinityPlayer : Component
{
// ── Master ──
/// <summary>Music master switch. 'new' so it's distinct from <see cref="Component.Enabled"/>.</summary>
[Property, Group( "Music" )] public new bool Enabled { get; set; } = true;
[Property, Group( "Music" ), Range( 0f, 2f )] public float Volume { get; set; } = 0.7f;
/// <summary>Regenerate automatically a moment after any generator knob changes (editor tuning).</summary>
[Property, Group( "Music" )] public bool LiveReload { get; set; } = true;
/// <summary>Optional mixer name to route the music to (e.g. "Music"). Empty = default mixer.</summary>
[Property, Group( "Music" )] public string MixerName { get; set; } = "";
/// <summary>Begin playing automatically in <see cref="OnStart"/>. Off = call <see cref="StartSequence"/> yourself.</summary>
[Property, Group( "Music" )] public bool AutoPlay { get; set; } = true;
/// <summary>Shuffle mode: re-randomise every knob (incl. genre + volumes) as each new song
/// begins, so the sequence keeps reinventing itself. Off = the seed's vibe stays put.</summary>
[Property, Group( "Music" )] public bool RandomEverySong { get; set; } = false;
// ── Seed ──
/// <summary>Seed tag — any string (a name, a word). Empty falls back to "skafinity".</summary>
[Property, Group( "Seed" )] public string Tag { get; set; } = "";
/// <summary>Song index in the infinite sequence (0,1,2…). <see cref="StepN"/>/<see cref="NextSong"/> walk it.</summary>
[Property, Group( "Seed" )] public int StartN { get; set; } = 0;
/// <summary>Optional base-36 vibe override (see <see cref="VibeCodec"/>). When set it overrides
/// the matching inspector knobs, so a shared vibe reproduces the same voicing on any client.</summary>
[Property, Group( "Seed" )] public string Vibe { get; set; } = "";
/// <summary>Persist the current song index across sessions (FileSystem.Data, keyed by <see cref="SaveSlot"/>).</summary>
[Property, Group( "Seed" )] public bool PersistProgress { get; set; } = false;
[Property, Group( "Seed" )] public string SaveSlot { get; set; } = "default";
// ── Output ──
[Property, Group( "Output" ), Range( 8000, 48000 )] public int SampleRate { get; set; } = 32000;
/// <summary>Target track length; bar count adapts to tempo to hit this.</summary>
[Property, Group( "Output" ), Range( 30f, 180f )] public float TargetSeconds { get; set; } = 80f;
/// <summary>Worker threads the pitched-voice synthesis is split across (composition + drums
/// stay single-threaded). Keeps each worker burst under s&box's ~1000ms no-yield advisory.</summary>
[Property, Group( "Output" ), Range( 1, 8 )] public int RenderThreads { get; set; } = 6;
// ── Crossfade / scheduling ──
/// <summary>Crossfade window (also the first song's fade-in from silence), seconds. The two
/// songs only both-audible for <see cref="CrossfadeOverlap"/> of this, centred.</summary>
[Property, Group( "Crossfade" ), Range( 0.5f, 8f )] public float Crossfade { get; set; } = 3.75f;
[Property, Group( "Crossfade" ), Range( 0f, 1f )] public float CrossfadeOverlap { get; set; } = 0.5f;
/// <summary>How many times each loop plays before crossfading on (2 = play through, loop once, switch).</summary>
[Property, Group( "Crossfade" ), Range( 1, 4 )] public int LoopsPerSong { get; set; } = 2;
/// <summary>How many upcoming songs to keep pre-generated (built one-per-tick so the fill never stalls a frame).</summary>
[Property, Group( "Crossfade" ), Range( 1, 8 )] public int AheadCount { get; set; } = 5;
// ── Tempo (main = laid-back reggae-rock; Fast = uptempo ska) ──
[Property, Group( "Tempo" ), Range( 60, 200 )] public int BpmMin { get; set; } = 130;
[Property, Group( "Tempo" ), Range( 60, 200 )] public int BpmMax { get; set; } = 185;
[Property, Group( "Tempo" ), Range( 0f, 1f )] public float FastChance { get; set; } = 0.30f;
[Property, Group( "Tempo" ), Range( 100, 220 )] public int FastBpmMin { get; set; } = 150;
[Property, Group( "Tempo" ), Range( 100, 220 )] public int FastBpmMax { get; set; } = 168;
[Property, Group( "Tempo" ), Range( 0f, 0.4f )] public float Swing { get; set; } = 0.14f;
[Property, Group( "Tempo" ), Range( 0f, 0.4f )] public float FastSwing { get; set; } = 0.05f;
// ── Mix ──
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float BassVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float SkankVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float OrganVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float MelodyVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float HornVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float KickVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float SnareVol { get; set; } = 0.70f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float TomVol { get; set; } = 0.60f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float HatVol { get; set; } = 0.22f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float CrashVol { get; set; } = 0.35f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float DrumVol { get; set; } = 1.00f;
// ── Tone ──
[Property, Group( "Tone" ), Range( 0f, 40f )] public float Detune { get; set; } = 14f;
[Property, Group( "Tone" ), Range( 80f, 1200f )] public float BassCutoff { get; set; } = 380f;
[Property, Group( "Tone" ), Range( 500f, 8000f )] public float SkankCutoff { get; set; } = 3000f;
[Property, Group( "Tone" ), Range( 0f, 2000f )] public float SkankHighpass { get; set; } = 500f;
[Property, Group( "Tone" ), Range( 0.15f, 1f )] public float SkankChop { get; set; } = 0.5f;
[Property, Group( "Tone" ), Range( 500f, 8000f )] public float LeadCutoff { get; set; } = 3200f;
[Property, Group( "Tone" ), Range( 500f, 8000f )] public float OrganCutoff { get; set; } = 1400f;
[Property, Group( "Tone" ), Range( 0f, 12f )] public float OrganVibrato { get; set; } = 5.5f;
[Property, Group( "Tone" ), Range( 500f, 8000f )] public float HornCutoff { get; set; } = 3200f;
[Property, Group( "Tone" ), Range( 0.2f, 2f )] public float Resonance { get; set; } = 1.0f;
[Property, Group( "Tone" ), Range( 1f, 4f )] public float BassDrive { get; set; } = 1.5f;
[Property, Group( "Tone" ), Range( 1f, 4f )] public float SkankDrive { get; set; } = 1.3f;
[Property, Group( "Tone" ), Range( 1f, 4f )] public float MelodyDrive { get; set; } = 1.3f;
[Property, Group( "Tone" ), Range( 1f, 4f )] public float HornDrive { get; set; } = 1.4f;
[Property, Group( "Tone" ), Range( 0.5f, 3f )] public float MasterDrive { get; set; } = 1.1f;
[Property, Group( "Tone" ), Range( 0.2f, 1f )] public float MasterPeak { get; set; } = 0.95f;
// ── Feel ──
[Property, Group( "Feel" ), Range( 0f, 1f )] public float OctavePopChance { get; set; } = 0.30f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float OrganBubbleChance { get; set; } = 0.55f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float KickSyncChance { get; set; } = 0.25f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float GhostSnareChance { get; set; } = 0.35f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float FillChance { get; set; } = 0.6f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float DrumBusy { get; set; } = 0.6f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float DrumTone { get; set; } = 0.5f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float DrumDrive { get; set; } = 0.5f;
[Property, Group( "Feel" ), Range( 0f, 0.2f )] public float TripletChance { get; set; } = 0.06f;
[Property, Group( "Feel" ), Range( 0f, 0.1f )] public float BassTriplets { get; set; } = 0.06f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float MelodyRestChance { get; set; } = 0.30f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float MelodyLeapChance { get; set; } = 0.18f;
[Property, Group( "Feel" ), Range( 0f, 12f )] public float MelodyVibrato { get; set; } = 5.0f;
// ── Stereo ──
[Property, Group( "Stereo" ), Range( 0f, 1f )] public float PanAmount { get; set; } = 0.4f;
// ── Lead instrument (RNG picks one per tag, weighted; Force overrides) ──
[Property, Group( "Instrument" ), Range( 0f, 4f )] public float TrumpetWeight { get; set; } = 1.0f;
[Property, Group( "Instrument" ), Range( 0f, 4f )] public float SaxWeight { get; set; } = 1.0f;
[Property, Group( "Instrument" ), Range( 0f, 4f )] public float OrganWeight { get; set; } = 0.8f;
[Property, Group( "Instrument" ), Range( 0f, 4f )] public float TromboneWeight { get; set; } = 0.4f;
/// <summary>-1 = RNG; 0=Trumpet 1=Sax 2=Organ 3=Trombone.</summary>
[Property, Group( "Instrument" ), Range( -1, 3 )] public int ForceInstrument { get; set; } = -1;
// ── Backing horns ──
[Property, Group( "Horns" ), Range( 0f, 1f )] public float HornSectionChance { get; set; } = 0.5f;
[Property, Group( "Horns" ), Range( 0f, 1f )] public float HornDensity { get; set; } = 0.35f;
// ── Genre & rock instruments ──
// Genre selects the instrument set: 0 = Ska, 1 = Rock (drums/bass/rhythm-gtr/lead-gtr).
[Property, Group( "Genre" ), Range( 0, 1 )] public int Genre { get; set; } = 0;
// KEYS — the offbeat-chord comp (was the "rhythm guitar"; it reads as keys).
[Property, Group( "Rock" ), Range( 0f, 1.5f )] public float KeysVol { get; set; } = 1.00f;
[Property, Group( "Rock" ), Range( 500f, 8000f )] public float KeysCutoff { get; set; } = 1700f;
[Property, Group( "Rock" ), Range( 1f, 5f )] public float KeysDrive { get; set; } = 3.2f;
[Property, Group( "Rock" ), Range( 0f, 1f )] public float KeysChug { get; set; } = 0.5f;
// RHYTHM GTR — twangy distorted power chords (shares the lead voice, lower base distortion).
[Property, Group( "Rock" ), Range( 0f, 1.5f )] public float RhythmGtrVol { get; set; } = 1.00f;
[Property, Group( "Rock" ), Range( 500f, 8000f )] public float RhythmGtrCutoff { get; set; } = 2600f;
[Property, Group( "Rock" ), Range( 1f, 5f )] public float RhythmGtrDrive { get; set; } = 2.8f;
[Property, Group( "Rock" ), Range( 0f, 1f )] public float RhythmGtrChug { get; set; } = 0.5f;
[Property, Group( "Rock" ), Range( 0f, 1.5f )] public float LeadGtrVol { get; set; } = 1.00f;
[Property, Group( "Rock" ), Range( 500f, 8000f )] public float LeadGtrCutoff { get; set; } = 2600f;
[Property, Group( "Rock" ), Range( 1f, 5f )] public float LeadGtrDrive { get; set; } = 3.6f;
[Property, Group( "Rock" ), Range( 0f, 1f )] public float LeadGtrBend { get; set; } = 0.30f;
SoundStream _stream;
SoundHandle _handle;
int _sr;
short[] _curRaw; // current song, full single loop (raw, for export)
readonly System.Collections.Generic.List<short[]> _ahead = new(); // pre-generated n+1, n+2, …
int _curN; // index of the currently-playing song
int _curReserve; // samples of the current song's tail held back for the crossfade
double _pushedSeconds; // total audio pushed to the stream
TimeSince _sinceStart; // wall clock since playback started
int _lastConfigHash;
bool _dirty;
TimeSince _dirtySince;
bool _starting; // StartSequenceAsync is in flight
bool _fillingAhead; // FillAhead is in flight
bool Generating => _starting || _fillingAhead;
int _seq; // bumped on each StartSequence; stale async results are discarded
bool _flatConfigured; // ConfigureFlat applied to the live handle
bool _restartPending; // a debounced restart (vibe edit) is queued
TimeSince _restartPendingSince;
/// <summary>Currently-playing song index.</summary>
public int N => _curN;
/// <summary>The effective vibe — the override if set, else the encoded inspector knobs.</summary>
public string CurrentVibe => VibeCodec.Encode( BuildConfig() );
/// <summary>Shareable seed for the playing song: <c>vibe:tag:n</c>.</summary>
public string CurrentSeed => $"{CurrentVibe}:{SeedTag}:{_curN}";
/// <summary>True once a stream handle is live and audible.</summary>
public bool IsPlaying => _handle != null;
string SeedTag => string.IsNullOrEmpty( Tag ) ? "" : Tag;
// Build the PRNG seed string from a resolved tag, so worker code never re-reads state.
static string SeedFor( string tag, int n ) => $"{(string.IsNullOrEmpty( tag ) ? "skafinity" : tag.ToLowerInvariant())}:{n}";
string Seed( int n ) => SeedFor( SeedTag, n );
protected override void OnStart()
{
_lastConfigHash = ConfigHash();
_curN = Math.Max( 0, PersistProgress ? LoadN() ?? StartN : StartN );
if ( AutoPlay ) StartSequence();
}
protected override void OnDestroy()
{
_seq++; // invalidate any in-flight worker generation
_handle?.Stop();
_handle = null;
_stream = null;
}
protected override void OnUpdate()
{
if ( _handle != null )
_handle.Volume = TargetVolume();
// Keep the look-ahead buffer topped up. Generation runs on a worker thread so this
// never blocks the frame.
if ( !Generating && _curRaw != null && _ahead.Count < Math.Max( 1, AheadCount ) )
_ = FillAhead( _seq );
// When the queued audio is about to run out, crossfade into the next song.
if ( _stream != null && _ahead.Count > 0 && _curRaw != null
&& _pushedSeconds - _sinceStart < 2.0 )
PushTransition();
int h = ConfigHash();
if ( h != _lastConfigHash )
{
_lastConfigHash = h;
_dirty = true;
_dirtySince = 0;
}
if ( _dirty && LiveReload && !Generating && _dirtySince > 0.5f )
{
_dirty = false;
StartSequence();
}
// Debounced restart for vibe edits: only regenerate once edits have settled.
if ( _restartPending && !Generating && _restartPendingSince > 0.35f )
{
_restartPending = false;
StartSequence();
}
}
/// <summary>Make the stream play as flat 2D (SpacialBlend=0, parented to the camera with
/// FollowParent so the listener can't pan/attenuate it). Optionally routes to a named mixer.</summary>
void ConfigureFlat()
{
if ( _handle == null || _flatConfigured ) return;
_handle.SpacialBlend = 0f;
var camGo = Scene?.Camera?.GameObject;
if ( camGo.IsValid() )
{
_handle.Parent = camGo;
_handle.FollowParent = true;
}
if ( !string.IsNullOrEmpty( MixerName ) )
{
var mixer = Sandbox.Audio.Mixer.FindMixerByName( MixerName );
if ( mixer != null )
_handle.TargetMixer = mixer;
}
_flatConfigured = true;
}
float TargetVolume() => Enabled ? Volume : 0f;
/// <summary>The config currently in effect (inspector knobs with any <see cref="Vibe"/> applied).</summary>
public MusicGen.Config EffectiveConfig() => BuildConfig();
MusicGen.Config BuildConfig()
{
var cfg = BuildKnobConfig();
// A vibe override sets the important knobs (so a shared vibe:tag:n reproduces the same
// voicing regardless of this client's inspector knobs).
if ( !string.IsNullOrEmpty( Vibe ) )
VibeCodec.Apply( Vibe, cfg );
return cfg;
}
MusicGen.Config BuildKnobConfig() => new()
{
SampleRate = SampleRate,
TargetSeconds = TargetSeconds,
BpmMin = BpmMin,
BpmMax = BpmMax,
FastChance = FastChance,
FastBpmMin = FastBpmMin,
FastBpmMax = FastBpmMax,
Swing = Swing,
FastSwing = FastSwing,
BassVol = BassVol,
SkankVol = SkankVol,
OrganVol = OrganVol,
MelodyVol = MelodyVol,
HornVol = HornVol,
KickVol = KickVol,
SnareVol = SnareVol,
TomVol = TomVol,
HatVol = HatVol,
CrashVol = CrashVol,
DrumVol = DrumVol,
Detune = Detune,
BassCutoff = BassCutoff,
SkankCutoff = SkankCutoff,
SkankHighpass = SkankHighpass,
SkankChop = SkankChop,
LeadCutoff = LeadCutoff,
OrganCutoff = OrganCutoff,
OrganVibrato = OrganVibrato,
HornCutoff = HornCutoff,
Resonance = Resonance,
BassDrive = BassDrive,
SkankDrive = SkankDrive,
MelodyDrive = MelodyDrive,
HornDrive = HornDrive,
MasterDrive = MasterDrive,
MasterPeak = MasterPeak,
OctavePopChance = OctavePopChance,
OrganBubbleChance = OrganBubbleChance,
KickSyncChance = KickSyncChance,
GhostSnareChance = GhostSnareChance,
FillChance = FillChance,
DrumBusy = DrumBusy,
TripletChance = TripletChance,
BassTriplets = BassTriplets,
MelodyRestChance = MelodyRestChance,
MelodyLeapChance = MelodyLeapChance,
MelodyVibrato = MelodyVibrato,
PanAmount = PanAmount,
TrumpetWeight = TrumpetWeight,
SaxWeight = SaxWeight,
OrganWeight = OrganWeight,
TromboneWeight = TromboneWeight,
ForceInstrument = ForceInstrument,
HornSectionChance = HornSectionChance,
HornDensity = HornDensity,
Genre = Genre,
DrumTone = DrumTone,
DrumDrive = DrumDrive,
KeysVol = KeysVol,
KeysCutoff = KeysCutoff,
KeysDrive = KeysDrive,
KeysChug = KeysChug,
RhythmGtrVol = RhythmGtrVol,
RhythmGtrCutoff = RhythmGtrCutoff,
RhythmGtrDrive = RhythmGtrDrive,
RhythmGtrChug = RhythmGtrChug,
LeadGtrVol = LeadGtrVol,
LeadGtrCutoff = LeadGtrCutoff,
LeadGtrDrive = LeadGtrDrive,
LeadGtrBend = LeadGtrBend,
};
int ConfigHash()
{
var h = new HashCode();
h.Add( SampleRate ); h.Add( TargetSeconds );
h.Add( BpmMin ); h.Add( BpmMax ); h.Add( FastChance );
h.Add( FastBpmMin ); h.Add( FastBpmMax ); h.Add( Swing ); h.Add( FastSwing );
h.Add( BassVol ); h.Add( SkankVol ); h.Add( OrganVol ); h.Add( MelodyVol ); h.Add( HornVol );
h.Add( KickVol ); h.Add( SnareVol ); h.Add( TomVol ); h.Add( HatVol ); h.Add( CrashVol ); h.Add( DrumVol );
h.Add( Detune ); h.Add( BassCutoff ); h.Add( SkankCutoff ); h.Add( SkankHighpass ); h.Add( SkankChop );
h.Add( LeadCutoff ); h.Add( OrganCutoff ); h.Add( OrganVibrato ); h.Add( HornCutoff ); h.Add( Resonance );
h.Add( BassDrive ); h.Add( SkankDrive ); h.Add( MelodyDrive ); h.Add( HornDrive );
h.Add( MasterDrive ); h.Add( MasterPeak );
h.Add( OctavePopChance ); h.Add( OrganBubbleChance ); h.Add( KickSyncChance );
h.Add( GhostSnareChance ); h.Add( FillChance );
h.Add( DrumBusy ); h.Add( DrumTone ); h.Add( DrumDrive ); h.Add( TripletChance ); h.Add( BassTriplets );
h.Add( MelodyRestChance ); h.Add( MelodyLeapChance ); h.Add( MelodyVibrato );
h.Add( PanAmount );
h.Add( TrumpetWeight ); h.Add( SaxWeight ); h.Add( OrganWeight ); h.Add( TromboneWeight );
h.Add( ForceInstrument );
h.Add( HornSectionChance ); h.Add( HornDensity );
h.Add( Genre );
h.Add( KeysVol ); h.Add( KeysCutoff ); h.Add( KeysDrive ); h.Add( KeysChug );
h.Add( RhythmGtrVol ); h.Add( RhythmGtrCutoff ); h.Add( RhythmGtrDrive ); h.Add( RhythmGtrChug );
h.Add( LeadGtrVol ); h.Add( LeadGtrCutoff ); h.Add( LeadGtrDrive ); h.Add( LeadGtrBend );
h.Add( Tag ); h.Add( Vibe );
return h.ToHashCode();
}
// Run the (pure, CPU-heavy) synthesis on worker threads so it never blocks the frame AND so
// no single worker burst runs long enough to trip s&box's ~1000ms no-yield advisory.
// Composition + drum synthesis are RNG-bound and stay sequential (BeginPlan, one worker); the
// pitched voices pull no RNG, so they fan out across RenderThreads disjoint windows joined by
// Task.WhenAll; the master+downmix runs on one worker.
async Task<short[]> GenerateMonoAsync( string seedStr, MusicGen.Config cfg )
{
MusicGen g = null;
await GameTask.RunInThreadAsync( () => { g = MusicGen.BeginPlan( seedStr, cfg ); return Task.CompletedTask; } );
int total = g.TotalSamples;
int k = Math.Clamp( RenderThreads, 1, 8 );
if ( k <= 1 )
{
await GameTask.RunInThreadAsync( () => { g.RenderPitchedRange( 0, total ); return Task.CompletedTask; } );
}
else
{
var jobs = new Task[k];
for ( int i = 0; i < k; i++ )
{
int from = (int)((long)total * i / k);
int to = (int)((long)total * (i + 1) / k);
jobs[i] = GameTask.RunInThreadAsync( () => { g.RenderPitchedRange( from, to ); return Task.CompletedTask; } );
}
await Task.WhenAll( jobs );
}
short[] mono = null;
await GameTask.RunInThreadAsync( () => { mono = g.FinishMono(); return Task.CompletedTask; } );
_sr = g.SampleRate;
return mono;
}
int FadeSamples => Math.Max( 1, (int)(Math.Clamp( Crossfade, 0.25f, 8f ) * _sr) );
/// <summary>Top the look-ahead buffer up to <see cref="AheadCount"/>, generating each song on
/// a worker thread. Fire-and-forget from OnUpdate; <paramref name="seq"/> guards against a
/// sequence restart landing a stale song in the buffer.</summary>
async Task FillAhead( int seq )
{
if ( Generating ) return;
try
{
_fillingAhead = true;
string tag = SeedTag;
var cfg = BuildConfig();
while ( seq == _seq && _curRaw != null && _ahead.Count < Math.Max( 1, AheadCount ) )
{
int n = _curN + 1 + _ahead.Count;
var song = await GenerateMonoAsync( SeedFor( tag, n ), cfg );
if ( seq != _seq ) return; // sequence restarted while we were generating
_ahead.Add( song );
}
}
catch ( Exception e ) { Log.Warning( $"SkafinityPlayer: FillAhead failed: {e.Message}" ); }
finally { _fillingAhead = false; }
}
/// <summary>Write <see cref="LoopsPerSong"/> passes of <paramref name="raw"/> to the stream,
/// given the first <paramref name="headConsumed"/> samples of pass 0 were already emitted,
/// holding back the final <paramref name="reserve"/> samples for the next crossfade. Optional
/// fade-in over the first <paramref name="fadeIn"/> samples of pass 0. Returns samples written.</summary>
int WriteSongBody( short[] raw, int headConsumed, int reserve, int fadeIn )
{
int loops = Math.Max( 1, LoopsPerSong );
int total = 0;
for ( int loop = 0; loop < loops; loop++ )
{
int start = loop == 0 ? headConsumed : 0;
int end = loop == loops - 1 ? raw.Length - reserve : raw.Length;
if ( end <= start ) continue;
int len = end - start;
var seg = new short[len];
for ( int i = 0; i < len; i++ )
{
int idx = start + i;
float g = (loop == 0 && fadeIn > 0 && idx < fadeIn) ? (float)idx / fadeIn : 1f;
seg[i] = (short)(raw[idx] * g);
}
_stream.WriteData( seg );
total += len;
}
return total;
}
/// <summary>(Re)start the infinite sequence at the current tag/n. Bumps the sequence token
/// (invalidating any in-flight generation), stops the current handle, then kicks the async
/// (worker-thread) start so the caller never blocks.</summary>
public void StartSequence()
{
int seq = ++_seq;
_ahead.Clear();
_handle?.Stop();
_handle = null;
_stream = null;
_flatConfigured = false;
_curRaw = null;
_ = StartSequenceAsync( seq );
}
// The first song fades in; thereafter songs play LoopsPerSong passes and crossfade into the
// pre-generated next. Synthesis is offloaded; stream setup is on the main thread.
async Task StartSequenceAsync( int seq )
{
try
{
_starting = true;
int n = Math.Max( 0, _curN );
string tag = SeedTag;
var cfg = BuildConfig();
var raw = await GenerateMonoAsync( SeedFor( tag, n ), cfg );
if ( seq != _seq ) return; // superseded by a newer StartSequence
_curN = n;
_curRaw = raw;
int fade = Math.Min( FadeSamples, _curRaw.Length / 3 );
_curReserve = fade;
_stream = new SoundStream( _sr );
// First song: LoopsPerSong passes, fade-in over the first `fade`, last `fade` of the
// final pass held back for the crossfade into the next song.
int written = WriteSongBody( _curRaw, 0, _curReserve, fade );
_pushedSeconds = written / (double)_sr;
_handle = _stream.Play();
if ( _handle != null )
{
_handle.Volume = TargetVolume();
ConfigureFlat();
}
_sinceStart = 0;
}
catch ( Exception e )
{
Log.Warning( $"SkafinityPlayer: StartSequence failed: {e.Message}" );
}
finally { _starting = false; }
}
// Queue the crossfade from the current song's tail into the next song's head, then the next
// song's body. Advances n (persisted if enabled); the following song is topped up by OnUpdate.
void PushTransition()
{
try
{
var next = _ahead[0];
_ahead.RemoveAt( 0 );
// Crossfade window = the current song's held-back tail (so there's no gap or overlap
// even when songs differ in length). The two songs only overlap for CrossfadeOverlap
// of this window, centred — the rest plays in the clear.
int W = Math.Min( _curReserve, next.Length / 3 );
int curStart = _curRaw.Length - W;
int cross = Math.Clamp( (int)(W * CrossfadeOverlap), 1, W );
int ws = (W - cross) / 2; // overlap starts here
int we = ws + cross; // overlap ends here
var xf = new short[W];
for ( int i = 0; i < W; i++ )
{
float gOut, gIn;
if ( i < ws ) { gOut = 1f; gIn = 0f; } // outgoing in the clear
else if ( i >= we ) { gOut = 0f; gIn = 1f; } // incoming in the clear
else
{
double t = (i - ws + 0.5) / cross * (Math.PI / 2); // equal-power cross
gOut = (float)Math.Cos( t );
gIn = (float)Math.Sin( t );
}
xf[i] = (short)Math.Clamp( _curRaw[curStart + i] * gOut + next[i] * gIn, -32768, 32767 );
}
_stream.WriteData( xf );
// next song: LoopsPerSong passes, first W of pass 0 already in the crossfade, last
// `nextReserve` of the final pass held back for the following crossfade.
int nextReserve = Math.Min( FadeSamples, next.Length / 3 );
int written = WriteSongBody( next, W, nextReserve, 0 );
_pushedSeconds += (W + written) / (double)_sr;
_curRaw = next;
_curReserve = nextReserve;
_curN++;
if ( PersistProgress ) SaveN( _curN );
// Shuffle mode: each new song gets a fresh set of knobs. Reroll the vibe so the
// look-ahead fill (OnUpdate) generates the upcoming songs with the new voicing.
if ( RandomEverySong ) RerollVibe( includeVolumes: true, includeGenre: true );
}
catch ( Exception e )
{
Log.Warning( $"SkafinityPlayer: PushTransition failed: {e.Message}" );
}
}
// ── Public control surface ──
// Parse a shareable seed in any of vibe:tag:n / tag:n / tag. Missing parts stay null.
static void ParseSeed( string seed, out string vibe, out string tag, out int? n )
{
vibe = null; tag = null; n = null;
seed = seed?.Trim();
if ( string.IsNullOrEmpty( seed ) ) return;
var p = seed.Split( ':' );
if ( p.Length >= 3 ) { vibe = p[0]; tag = p[1]; if ( int.TryParse( p[2], out var v ) ) n = v; }
else if ( p.Length == 2 )
{
if ( int.TryParse( p[1], out var v ) ) { tag = p[0]; n = v; }
else if ( VibeCodec.LooksLikeVibe( p[0] ) ) { vibe = p[0]; tag = p[1]; }
else tag = p[0];
}
else tag = p[0];
}
/// <summary>Play a shareable seed in any of the forms <c>vibe:tag:n</c>, <c>tag:n</c>, or
/// <c>tag</c>. Missing components are left unchanged; a vibe is only applied when present.
/// Restarts the sequence.</summary>
public void PlaySeed( string seed )
{
ParseSeed( seed, out string vibe, out string tag, out int? n );
if ( tag != null ) Tag = tag.Trim().ToLowerInvariant();
if ( vibe != null ) Vibe = VibeCodec.LooksLikeVibe( vibe ) ? vibe.ToLowerInvariant() : "";
if ( n.HasValue ) _curN = Math.Max( 0, n.Value );
if ( PersistProgress ) SaveN( _curN );
StartSequence();
}
/// <summary>Set just the seed tag (empty = the default "skafinity" seed). Restarts.</summary>
public void SetTag( string tag )
{
Tag = string.IsNullOrEmpty( tag ) ? "" : tag.Trim().ToLowerInvariant();
StartSequence();
}
/// <summary>Jump to song index n in the sequence (clamped ≥ 0). Restarts.</summary>
public void SetN( int n )
{
_curN = Math.Max( 0, n );
if ( PersistProgress ) SaveN( _curN );
StartSequence();
}
/// <summary>Step the song index by <paramref name="delta"/> (e.g. +1 / -1). Restarts.</summary>
public void StepN( int delta ) => SetN( _curN + delta );
/// <summary>Skip to the next song in the sequence.</summary>
public void NextSong() => StepN( 1 );
/// <summary>Step back to the previous song in the sequence.</summary>
public void PrevSong() => StepN( -1 );
/// <summary>Set vibe field <paramref name="index"/> (see <see cref="VibeCodec.Fields(int)"/>) from
/// a 0..1 fraction, store the re-encoded <see cref="Vibe"/>, and restart on a short debounce.</summary>
public void SetVibe( int index, float norm )
{
var cfg = BuildConfig();
var fields = VibeCodec.Fields( cfg.Genre );
if ( index < 0 || index >= fields.Count ) return;
fields[index].SetNorm( cfg, norm );
Vibe = VibeCodec.Encode( cfg );
_restartPending = true;
_restartPendingSince = 0;
}
/// <summary>Switch genre (rides in the vibe's first char): re-encode the effective config
/// with the new genre into <see cref="Vibe"/> so it sticks over the inspector knobs, then
/// restart. Use this rather than setting <see cref="Genre"/> directly — an existing
/// <see cref="Vibe"/> override otherwise wins and the change wouldn't take.</summary>
public void SetGenre( int genre )
{
var cfg = BuildConfig();
cfg.Genre = Math.Clamp( genre, 0, VibeCodec.GenreCount - 1 );
Vibe = VibeCodec.Encode( cfg );
StartSequence();
}
/// <summary>Randomize the vibe knobs and restart on a short debounce. By default the
/// per-instrument volumes (and genre) are left alone so a reroll re-voices without upending
/// the mix; pass <paramref name="includeVolumes"/> / <paramref name="includeGenre"/> for a
/// full shuffle.</summary>
public void RerollVibe( bool includeVolumes = false, bool includeGenre = false )
{
var cfg = BuildConfig();
var rng = System.Random.Shared;
if ( includeGenre )
cfg.Genre = rng.Next( VibeCodec.GenreCount );
foreach ( var f in VibeCodec.Fields( cfg.Genre ) )
{
if ( !includeVolumes && f.Voice != null && f.Column == 0 ) continue; // skip per-instrument volumes
f.SetNorm( cfg, rng.NextSingle() );
}
if ( cfg.BpmMin > cfg.BpmMax ) (cfg.BpmMin, cfg.BpmMax) = (cfg.BpmMax, cfg.BpmMin);
Vibe = VibeCodec.Encode( cfg );
_restartPending = true;
_restartPendingSince = 0;
}
/// <summary>Write the playing song's raw loop (no fade) to a WAV under FileSystem.Data.
/// Returns the filename written, or null on failure.</summary>
public string SaveCurrentToFile()
{
if ( _curRaw == null || _sr <= 0 ) return null;
var tag = string.IsNullOrEmpty( SeedTag ) ? "skafinity" : SeedTag.ToLowerInvariant();
var name = $"{tag}_{_curN}.wav";
try
{
FileSystem.Data.WriteAllBytes( name, MusicGen.WavFromSamples( _curRaw, 1, _sr ) );
return name;
}
catch ( Exception e )
{
Log.Warning( $"SkafinityPlayer: save failed: {e.Message}" );
return null;
}
}
// ── Optional progress persistence (FileSystem.Data, see assets/file-system.md) ──
string ProgressFile => $"skafinity_{(string.IsNullOrEmpty( SaveSlot ) ? "default" : SaveSlot)}.n";
void SaveN( int n )
{
try { FileSystem.Data.WriteAllText( ProgressFile, n.ToString() ); }
catch ( Exception e ) { Log.Warning( $"SkafinityPlayer: save progress failed: {e.Message}" ); }
}
int? LoadN()
{
try
{
if ( FileSystem.Data.FileExists( ProgressFile )
&& int.TryParse( FileSystem.Data.ReadAllText( ProgressFile ), out var v ) )
return Math.Max( 0, v );
}
catch ( Exception e ) { Log.Warning( $"SkafinityPlayer: load progress failed: {e.Message}" ); }
return null;
}
}