Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ the single source of truth for both the game and this web toy**. The web build c
| `test/engine/` | Engine-only test harness (`make test-engine`) — compiles `Engine/**` alone into the same assembly as the tests, so it runs on a plain dev host where s&box cannot. The safety net for engine work. |
| `sbox-library/Skafinity/skafinity.config.json` | The single shared **house-mix config** (peak balances / kit presence). Canonical here; the s&box plugin reads it at runtime and `make` copies it to `web/config.json`. Edit it to retune the baseline mix without a rebuild. |
| `sbox-library/Skafinity/Code/SkafinityPlayer.cs` | The s&box playback driver (`SoundStream`, infinite `tag:n`, look-ahead, crossfade). Web equivalent is `web/app.js`; the s&box-only bits are not used on the web. |
| `sbox-library/Skafinity/Code/UI/SkafinityMusicPanel.razor` (`.scss`) | Optional drop-in Razor `PanelComponent` — finds a `SkafinityPlayer` and exposes its knobs as in-game UI (seed/prev-next, genre, per-instrument vibe mixer, mute/volume, reroll, save). s&box-only; not in the web build. |
| `sbox-library/Skafinity/Code/UI/SkafinityMusicPanel.razor` (`.scss`) | Optional drop-in Razor `PanelComponent` — finds a `SkafinityPlayer` and exposes its knobs as in-game UI (seed + this-song/this-station copy, prev-next, genre strip with a Random entry, mute/volume, new station, save, and the per-instrument vibe mixer behind a TINKER button). s&box-only; not in the web build. |
| `sbox-library/Skafinity/Code/UI/SkafinityTheme.cs` | The panel's palette, derived at RUNTIME from one `Accent` colour so a consuming game can retint a *vendored* copy without editing it. Unset = neutral gray/black. |
| `reference/*.cs` | The original Rotaliate-client copies, kept for context. **Read-only.** The `sbox-library` copies are what actually compile. |

Expand Down
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ refactor proving it moved nothing).

| Rank | Item | Notes |
|---|---|---|
| 98 | Get s&box back to parity after the new seed format | The engine, `SkafinityPlayer.cs` and the Razor panel all compile and drive `tag:n[:genre][:vibe]` already — what has not followed is the panel's UI: the knob matrix is still open rather than behind a tinker button, the genre strip has no "Random" entry (the player has `RollGenre()` waiting for one), there is one copy button rather than this-song/this-station, and the reroll still says it rerolls the genre when it now rerolls the station. `RandomEverySong` is also still one switch over both genre and vibe, where the web splits them per seed part. Cannot be built or tested on the dev host, so it is a push-and-listen row. **And the save is broken**: `SaveCurrentToFile` passes `channels: 1` to `WavFromSamples` while `_curRaw` is the interleaved stereo buffer `SoundStream( _sr, MusicGen.Channels )` is fed, so every in-game save is stereo PCM under a mono header — half speed, channels interleaved. Output is always stereo (`MusicGen.Channels = 2`); pass it. |
| 55 | s&box has no shuffle, in the sense the web now means it | `shuffle` on the web is "every next song is a whole new station rather than the next song of this one" — `SeedCodec.RollTagFor` is in the engine for exactly that and only the web calls it. `SkafinityPlayer` walks song indices, so this is the positions-vs-indices rework the web did in `aeb9067`: the ledgers, the PCM cache, Prev/Next and the queue view all key on a timeline POSITION whose station is derived from the root. Push-and-listen, like everything s&box. |
| 12 | Revisit the always-full-width vibe | A normalised vibe carries every instrument slot in every genre, so a genre using four voices still pays for the nine — 36 hex chars, whatever is playing. Whether that is worth compressing depends on how ugly the pasted result turns out to be in practice, which is not knowable until a few have been shared. Any scheme has to keep "incomplete is an error" and genre-independence, which is what rules out per-genre trimming. |
| 45 | Some songs open with a cymbal wash nobody put there | Reported by ear as "a wash of cymbals that have nothing to do with what's being played from the beginning of tracks", and it survives every explanation tried so far. What is MEASURED: the render's first sample is near silence and reaches full peak in ~1 ms, so it is a real attack and not a truncated ring — the PCM does not begin mid-decay. Band-limited above 2.5 kHz the first two seconds are QUIETER than mid-song on all eight seeds checked, so there is no extra cymbal energy overall. But on 2 of those 8 the high band *decays* from sample 0 with tau ~330 ms and the first real hit is not until 360 ms, which is what is being heard. Two candidates were tested and BOTH are ruled out: the host fade-up (fixed separately, and the shape is identical with the old envelope removed) and CrashOnOne on the song's first bar (suppressed — the opening 340 ms is byte-identical with and without it, so that change is a correctness fix and not this). 330 ms matches KitNuance.OpenHatDurMin/Max (0.26-0.42 s) almost exactly, but both ska grooves put their Open cell on the "and of 4" rather than beat 1, so that does not explain it either. **The next step is an instrument, not more inference**: --render currently has no way to mute or solo the kit, so "is this the drums at all" cannot be answered in one measurement and every attempt so far has been a chain of guesses. Add that first. |
| 40 | The cymbals are not tuned, hats included | The ride carries three stacked edits (`StrokeLevelRide` 0.45, `RingTau` knee 3500, `BowStrike` bump 4200) and the hats two (`KitNuance.OpenHatDurMin/Max` 0.26–0.42, `HatBalance` 0.288). Every one of them landed off a single listening note on a single seed, and none has been swept across the six genres or against a section that is dense rather than sparse. **The crash has deliberately not been touched through any of it**, so it is now the least-examined voice in the kit and quite possibly the loud one. Levers by reach: the three ride constants above, the open-hat band, then `HatBalance`/`RideBalance`/`CrashBalance` in `skafinity.config.json` (no rebuild). Judge the voice with `--cymbal` and the mix with the band + duty-cycle read (CLAUDE.md) — whole-mix RMS is what sent three rounds of this after the wrong voice. |
Expand Down
20 changes: 12 additions & 8 deletions sbox-library/Skafinity/Code/Engine/Wav.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ static class Wav
/// <summary>Clamp a −1..1 mix sample to signed 16-bit.</summary>
public static short ToS16( float v ) => (short)(Math.Clamp( v, -1f, 1f ) * 32767f);

/// <summary>Wrap already-rendered 16-bit samples in a WAV. Mono or interleaved stereo per
/// <paramref name="channels"/>.</summary>
public static byte[] FromSamples( short[] samples, int channels, int sampleRate )
/// <summary>Wrap already-rendered 16-bit interleaved-stereo samples in a WAV.</summary>
/// <remarks>The channel count is <see cref="MusicGen.Channels"/> and is deliberately NOT a
/// parameter. Everything the engine renders and every buffer a host carries is interleaved
/// stereo, so a caller's channel count could only ever agree with that or be wrong — and wrong
/// is silent: stereo PCM under a mono header plays at half speed with the channels interleaved
/// into each other, which is a plausible-sounding artefact rather than an obvious failure.</remarks>
public static byte[] FromSamples( short[] samples, int sampleRate )
{
const int channels = MusicGen.Channels;
int dataSize = samples.Length * 2;
int blockAlign = channels * 2;
var bytes = new List<byte>( 44 + dataSize );
Expand Down Expand Up @@ -46,10 +51,9 @@ short[] ToShorts( float gain )
return s;
}

/// <summary>Wrap already-rendered 16-bit samples in a WAV (for export). Mono or
/// interleaved stereo per <paramref name="channels"/>.</summary>
public static byte[] WavFromSamples( short[] samples, int channels, int sampleRate )
=> Wav.FromSamples( samples, channels, sampleRate );
/// <summary>Wrap already-rendered 16-bit interleaved-stereo samples in a WAV (for export).</summary>
public static byte[] WavFromSamples( short[] samples, int sampleRate )
=> Wav.FromSamples( samples, sampleRate );

byte[] EncodeWav( float gain ) => Wav.FromSamples( ToShorts( gain ), Channels, _sr );
byte[] EncodeWav( float gain ) => Wav.FromSamples( ToShorts( gain ), _sr );
}
39 changes: 36 additions & 3 deletions sbox-library/Skafinity/Code/SkafinityCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,47 @@ public static void SetGenre( int genre )
Log.Info( $"[Skafinity] genre {genre} = {VibeCodec.Genres[genre]} — {p.CurrentSeed}" );
}

/// <summary>Reroll the vibe: a new genre and every knob, keeping your per-instrument volumes.</summary>
/// <summary>Throw every knob somewhere new and pin it there, keeping your per-instrument
/// volumes and the genre. The way back out is <c>skafinity_random vibe</c>.</summary>
[ConCmd( "skafinity_reroll" )]
public static void Reroll()
{
var p = Player();
if ( p == null ) return;

p.RerollVibe();
Log.Info( $"[Skafinity] rerolled — {p.CurrentSeed}" );
Log.Info( $"[Skafinity] rerolled the vibe — {p.CurrentSeed}" );
}

/// <summary>A fresh random station at song 0. Anything pinned stays pinned.</summary>
[ConCmd( "skafinity_station" )]
public static void Station()
{
var p = Player();
if ( p == null ) return;

p.RerollStation();
Log.Info( $"[Skafinity] new station — {p.StationSeed}" );
}

/// <summary>Hand a pinned seed part back to the station so every song rolls its own again:
/// <c>skafinity_random genre</c>, <c>vibe</c>, or <c>both</c>.</summary>
[ConCmd( "skafinity_random" )]
public static void Random( string part = "both" )
{
var p = Player();
if ( p == null ) return;

switch ( (part ?? "").Trim().ToLowerInvariant() )
{
case "genre": p.RollGenre(); break;
case "vibe": p.RollVibe(); break;
case "both" or "": p.RollGenre(); p.RollVibe(); break;
default:
Log.Warning( $"[Skafinity] '{part}' is not a seed part — genre, vibe or both." );
return;
}
Log.Info( $"[Skafinity] rolling — {p.StationSeed}" );
}

/// <summary>Write the playing song to a .wav under the s&amp;box data folder.</summary>
Expand Down Expand Up @@ -255,7 +287,8 @@ public static void Status()
Log.Info( $" transport {( p.Enabled ? "on" : "MUTED" )}, vol {p.Volume:0.00}, "
+ $"{( p.IsPlaying ? "playing" : "not playing" )}"
+ $"{( p.IsBuffering ? ", BUFFERING" : p.IsGenerating ? ", generating ahead" : "" )}" );
Log.Info( $" shuffle {( p.RandomEverySong ? "on — every song freezes a fresh vibe + genre" : "off" )}" );
Log.Info( $" station {p.StationSeed} (genre {( p.GenrePinned ? "pinned" : "rolling" )}, "
+ $"vibe {( p.VibePinned ? "pinned" : "rolling" )})" );
Log.Info( $" output {p.SampleRate} Hz, {p.RenderThreads} render thread(s)" );
// Zero here is the interesting case: the baseline mix is then the engine's compiled
// defaults, not the file the web toy reads, and nothing else would ever say so.
Expand Down
Loading