Engine/VibeCodec.cs

Codec for the game's "vibe" knob grid and related UI/genre metadata. It defines the global voice/cell table, per-genre row layouts, advanced house mix fields, functions to encode/decode a fixed-length hex "vibe" string, roll random vibes/genres/volumes, and helpers for UI field lists and volume persistence.

Obfuscated Code
using System;
using System.Collections.Generic;
using System.Text;

namespace Skafinity;

/// <summary>
/// The knob grid, and its compact hex encoding — the "vibe" half of a seed
/// (<c>tag:n[:genre][:vibe]</c>; the string as a whole is <see cref="SeedCodec"/>'s).
///
/// WIRE FORMAT — one GLOBAL grid, genre-independent and fixed width:
///   <c>[voice 0 cols 1..4][voice 1 cols 1..4]…</c>, one hex digit per cell,
///   <see cref="VoiceCount"/> × <see cref="WireColumns"/> = <see cref="VibeLength"/> chars.
///
/// An instrument sits at the SAME index in every genre, whether or not that genre plays it, and
/// every cell is a fixed (Config field, range) pair — <see cref="Cells"/> — that no genre may
/// redefine. That is what makes a vibe portable: pin one, let the genre roll, and each song reads
/// the same 36 numbers through whatever voices it happens to use. A genre chooses which cells it
/// EXPOSES as sliders and what to call them (<see cref="GenreDef"/>), and nothing else.
///
/// The wire carries a NORMALISED level (0..15 over the cell's range), not a raw value, so a
/// genre's character comes from its voice code and <see cref="GenreProfile"/> — the places that
/// already hold it — rather than from a per-genre range on the knob.
///
/// A vibe is EXACTLY <see cref="VibeLength"/> hex chars. Short, long or non-hex is not a vibe;
/// there is no pad-with-defaults degrade, because a half-read grid is a song nobody chose. Growing
/// the grid (a voice, a 5th column) changes that length and invalidates every shared vibe: it is a
/// format break, not an append. Volume never travels — it is column 0, a local mix preference.
///
/// Lossy by design (16 levels/cell) but stable: Encode(Apply(s)) == s for any valid s.
/// </summary>
public static class VibeCodec
{
	internal const string Hex = "0123456789abcdef";
	public const int Levels = 16;       // one hex digit per knob
	public const int Columns = 5;       // 0 volume, then four travelling columns
	/// <summary>First column that travels. Column 0 is VOLUME — a local mix preference
	/// (see <see cref="ReadVolumes"/>), so the whole column is skipped rather than encoded.</summary>
	public const int WireFirstColumn = 1;
	public const int WireColumns = Columns - WireFirstColumn;

	public sealed class Field
	{
		public string Name;
		public float Min, Max;
		public bool Int;
		/// <summary>Discrete option labels (value = Min + index); null for a continuous knob.</summary>
		public string[] Choices;
		public Func<MusicGen.Config, float> Get;
		public Action<MusicGen.Config, float> Set;
		/// <summary>Instrument row this knob belongs to.</summary>
		public string Voice;
		/// <summary>Matrix column: 0 volume, 1..4 the travelling columns.</summary>
		public int Column;

		/// <summary>Current value as a 0..1 fraction of the range.</summary>
		public float GetNorm( MusicGen.Config c ) =>
			Math.Clamp( (Get( c ) - Min) / (Max - Min), 0f, 1f );

		/// <summary>Set from a 0..1 fraction (rounded for integer/discrete knobs).</summary>
		public void SetNorm( MusicGen.Config c, float norm )
		{
			float v = Min + Math.Clamp( norm, 0f, 1f ) * (Max - Min);
			if ( Int || Choices != null ) v = (float)Math.Round( v );
			Set( c, v );
		}

		/// <summary>Human-readable current value for the row header.</summary>
		public string Display( MusicGen.Config c )
		{
			float v = Get( c );
			if ( Choices != null )
			{
				int idx = (int)Math.Clamp( Math.Round( v - Min ), 0, Choices.Length - 1 );
				return Choices[idx];
			}
			if ( Int ) return ((int)Math.Round( v )).ToString();
			// A knob whose whole range fits in 0..2 is a proportion, not a count — rounding it to
			// a whole number shows the same "1" across most of its travel. Read those as percents
			// (a 0..1.5 volume, a 0.7..1.45 tempo scale); anything wider is a real quantity (Hz,
			// cents, a drive amount) and stays a number.
			if ( Max <= 2f ) return $"{(int)Math.Round( v * 100 )}%";
			return ((int)Math.Round( v )).ToString();
		}
	}

	static Field F( string name, float min, float max, bool isInt,
		Func<MusicGen.Config, float> get, Action<MusicGen.Config, float> set,
		string voice, int column, string[] choices = null )
		=> new() { Name = name, Min = min, Max = max, Int = isInt, Get = get, Set = set,
			Voice = voice, Column = column, Choices = choices };

	// ── The global voice table ────────────────────────────────────────────────────────────
	// Identity is the VOICE, not the label a genre puts on it: ska's "LEAD" and pop's "LEAD" are
	// two different voices (MELODY and LEAD GTR), and pop's "SYNTH" is rock's KEYS. Index order is
	// the wire order and is fixed; a genre's display order is its own (see GenreDef.Rows).
	public const int VoiceMelody = 4;

	sealed class VoiceDef
	{
		public string Name;
		public Field Volume;        // column 0 — never on the wire
		public Field[] Cells;       // columns 1..4, null where the grid has no knob at all
	}

	static VoiceDef V( string name, Func<MusicGen.Config, float> volGet, Action<MusicGen.Config, float> volSet,
		Field c1, Field c2, Field c3, Field c4 )
		=> new() { Name = name, Volume = F( "VOLUME", 0f, 1.5f, false, volGet, volSet, name, 0 ),
			Cells = new[] { c1, c2, c3, c4 } };

	// The cell table. A cell's Config field and RANGE are global — the same 16 levels mean the same
	// thing in every genre, which is what a portable vibe requires. Where a genre wants a different
	// floor or a different amount of an effect, that already lives in its voice code (Guitar.cs and
	// Lead.cs offset the drive per genre) or in GenreProfile; it must not come back here as a
	// per-genre range, or a pinned vibe stops meaning one thing.
	static readonly VoiceDef[] Voices =
	{
		V( "DRUMS", c => c.DrumVol, ( c, v ) => c.DrumVol = v,
			F( "TONE", 0f, 1f, false, c => c.DrumTone, ( c, v ) => c.DrumTone = v, "DRUMS", 1 ),
			F( "BUSY", 0f, 1f, false, c => c.DrumBusy, ( c, v ) => c.DrumBusy = v, "DRUMS", 2 ),
			F( "DRIVE", 0f, 1f, false, c => c.DrumDrive, ( c, v ) => c.DrumDrive = v, "DRUMS", 3 ),
			null ),
		V( "BASS", c => c.BassVol, ( c, v ) => c.BassVol = v,
			F( "TONE", 80f, 1200f, false, c => c.BassCutoff, ( c, v ) => c.BassCutoff = v, "BASS", 1 ),
			F( "DRIVE", 1f, 4f, false, c => c.BassDrive, ( c, v ) => c.BassDrive = v, "BASS", 2 ),
			F( "OCTAVE POP", 0f, 1f, false, c => c.OctavePopChance, ( c, v ) => c.OctavePopChance = v, "BASS", 3 ),
			F( "TRIPLETS", 0f, 0.1f, false, c => c.BassTriplets, ( c, v ) => c.BassTriplets = v, "BASS", 4 ) ),
		V( "SKANK", c => c.SkankVol, ( c, v ) => c.SkankVol = v,
			F( "TONE", 500f, 8000f, false, c => c.SkankCutoff, ( c, v ) => c.SkankCutoff = v, "SKANK", 1 ),
			F( "BITE", 0f, 2000f, false, c => c.SkankHighpass, ( c, v ) => c.SkankHighpass = v, "SKANK", 2 ),
			F( "CHOP", 0.15f, 1f, false, c => c.SkankChop, ( c, v ) => c.SkankChop = v, "SKANK", 3 ),
			null ),
		V( "ORGAN", c => c.OrganVol, ( c, v ) => c.OrganVol = v,
			F( "TONE", 500f, 8000f, false, c => c.OrganCutoff, ( c, v ) => c.OrganCutoff = v, "ORGAN", 1 ),
			F( "BUBBLE", 0f, 1f, false, c => c.OrganBubbleChance, ( c, v ) => c.OrganBubbleChance = v, "ORGAN", 2 ),
			F( "VIBRATO", 0f, 12f, false, c => c.OrganVibrato, ( c, v ) => c.OrganVibrato = v, "ORGAN", 3 ),
			null ),
		V( "MELODY", c => c.MelodyVol, ( c, v ) => c.MelodyVol = v,
			F( "TONE", 500f, 8000f, false, c => c.LeadCutoff, ( c, v ) => c.LeadCutoff = v, "MELODY", 1 ),
			F( "JUMPINESS", 0f, 1f, false, c => c.MelodyLeapChance, ( c, v ) => c.MelodyLeapChance = v, "MELODY", 2 ),
			F( "TRIPLETS", 0f, 0.1f, false, c => c.TripletChance, ( c, v ) => c.TripletChance = v, "MELODY", 3 ),
			null ),
		V( "HORNS", c => c.HornVol, ( c, v ) => c.HornVol = v,
			F( "TONE", 500f, 8000f, false, c => c.HornCutoff, ( c, v ) => c.HornCutoff = v, "HORNS", 1 ),
			F( "SECTION", 0f, 1f, false, c => c.HornSectionChance, ( c, v ) => c.HornSectionChance = v, "HORNS", 2 ),
			F( "DENSITY", 0f, 1f, false, c => c.HornDensity, ( c, v ) => c.HornDensity = v, "HORNS", 3 ),
			null ),
		V( "KEYS", c => c.KeysVol, ( c, v ) => c.KeysVol = v,
			F( "TONE", 500f, 8000f, false, c => c.KeysCutoff, ( c, v ) => c.KeysCutoff = v, "KEYS", 1 ),
			F( "DISTORTION", 1f, 5f, false, c => c.KeysDrive, ( c, v ) => c.KeysDrive = v, "KEYS", 2 ),
			F( "CHUG", 0f, 1f, false, c => c.KeysChug, ( c, v ) => c.KeysChug = v, "KEYS", 3 ),
			null ),
		V( "RHYTHM GTR", c => c.RhythmGtrVol, ( c, v ) => c.RhythmGtrVol = v,
			F( "TONE", 500f, 8000f, false, c => c.RhythmGtrCutoff, ( c, v ) => c.RhythmGtrCutoff = v, "RHYTHM GTR", 1 ),
			F( "DISTORTION", 1f, 6f, false, c => c.RhythmGtrDrive, ( c, v ) => c.RhythmGtrDrive = v, "RHYTHM GTR", 2 ),
			F( "CHUG", 0f, 1f, false, c => c.RhythmGtrChug, ( c, v ) => c.RhythmGtrChug = v, "RHYTHM GTR", 3 ),
			null ),
		V( "LEAD GTR", c => c.LeadGtrVol, ( c, v ) => c.LeadGtrVol = v,
			F( "TONE", 500f, 8000f, false, c => c.LeadGtrCutoff, ( c, v ) => c.LeadGtrCutoff = v, "LEAD GTR", 1 ),
			F( "DISTORTION", 1f, 6f, false, c => c.LeadGtrDrive, ( c, v ) => c.LeadGtrDrive = v, "LEAD GTR", 2 ),
			F( "BENDINESS", 0f, 1f, false, c => c.LeadGtrBend, ( c, v ) => c.LeadGtrBend = v, "LEAD GTR", 3 ),
			null ),
	};

	public static int VoiceCount => Voices.Length;
	/// <summary>Exact length of a vibe string, derived from the grid so nothing restates it.</summary>
	public static readonly int VibeLength = Voices.Length * WireColumns;

	/// <summary>The wire position of voice <paramref name="v"/>'s column <paramref name="col"/>.</summary>
	static int Pos( int v, int col ) => v * WireColumns + (col - WireFirstColumn);

	/// <summary>Is there a knob at wire position <paramref name="pos"/>? The grid is rectangular, so
	/// some cells are holes — a voice with three columns still reserves its fourth. A hole encodes
	/// as '0' and decodes to nothing, and a roll must leave it at '0' too: anything else would
	/// re-encode to '0' and make a rolled vibe fail to round-trip.</summary>
	public static bool HasCell( int pos )
	{
		if ( pos < 0 || pos >= VibeLength ) return false;
		return Voices[pos / WireColumns].Cells[pos % WireColumns] != null;
	}

	// ── Advanced / tuning-only knobs ──
	// Config fields that shape the BASELINE MIX (peak balances, kit presence) rather than a
	// song's shareable identity. They are NOT in the vibe wire (Encode/Apply never touch them)
	// and NOT in Fields() (so they don't appear as per-genre sliders). Membership in THIS list
	// is exactly the "config value, not a vibe slider" marker. Surfaced to the host (web:
	// config.json) by NAME — names match the MusicGen.Config field 1:1 — so the house mix can
	// be retuned at runtime without a rebuild. Ranges are generous tuning bounds, not the seed
	// grid. Genre-independent; not positional, so nothing here can shift a wire cell.
	public static readonly Field[] AdvancedFields =
	{
		F( "KitPresence", 0f, 4f, false, c => c.KitPresence, ( c, v ) => c.KitPresence = v, null, 0 ),
		// The stereo image, and the house's scale over each song's drawn reverb. Both were vibe
		// sliders; both are environment rather than music. 1 = as designed.
		F( "PanAmount", 0f, 1f, false, c => c.PanAmount, ( c, v ) => c.PanAmount = v, null, 0 ),
		F( "MasterReverb", 0f, 2f, false, c => c.MasterReverb, ( c, v ) => c.MasterReverb = v, null, 0 ),
		// How far each genre's own mix profile (GenreProfile.Mix) is taken. 1 = as designed,
		// 0 = every genre through one neutral mix. The SHAPE of a genre's mix is character and
		// lives in the profile; what the house retunes at runtime is how far to push it.
		F( "GenreMix", 0f, 2f, false, c => c.GenreMix, ( c, v ) => c.GenreMix = v, null, 0 ),
		F( "KickBalance", 0f, 2f, false, c => c.KickBalance, ( c, v ) => c.KickBalance = v, null, 0 ),
		F( "SnareBalance", 0f, 2f, false, c => c.SnareBalance, ( c, v ) => c.SnareBalance = v, null, 0 ),
		F( "TomBalance", 0f, 2f, false, c => c.TomBalance, ( c, v ) => c.TomBalance = v, null, 0 ),
		F( "HatBalance", 0f, 2f, false, c => c.HatBalance, ( c, v ) => c.HatBalance = v, null, 0 ),
		F( "RideBalance", 0f, 2f, false, c => c.RideBalance, ( c, v ) => c.RideBalance = v, null, 0 ),
		F( "CrashBalance", 0f, 2f, false, c => c.CrashBalance, ( c, v ) => c.CrashBalance = v, null, 0 ),
		F( "BassBalance", 0f, 2f, false, c => c.BassBalance, ( c, v ) => c.BassBalance = v, null, 0 ),
		F( "SkankBalance", 0f, 2f, false, c => c.SkankBalance, ( c, v ) => c.SkankBalance = v, null, 0 ),
		F( "OrganBalance", 0f, 2f, false, c => c.OrganBalance, ( c, v ) => c.OrganBalance = v, null, 0 ),
		F( "MelodyBalance", 0f, 2f, false, c => c.MelodyBalance, ( c, v ) => c.MelodyBalance = v, null, 0 ),
		F( "HornBalance", 0f, 2f, false, c => c.HornBalance, ( c, v ) => c.HornBalance = v, null, 0 ),
		F( "KeysBalance", 0f, 2f, false, c => c.KeysBalance, ( c, v ) => c.KeysBalance = v, null, 0 ),
		F( "RhythmGtrBalance", 0f, 2f, false, c => c.RhythmGtrBalance, ( c, v ) => c.RhythmGtrBalance = v, null, 0 ),
		F( "LeadGtrBalance", 0f, 2f, false, c => c.LeadGtrBalance, ( c, v ) => c.LeadGtrBalance = v, null, 0 ),
		// Stereo double-tracking / width (see MusicGen.Config "width" block).
		F( "DoubleTrack", 0f, 1f, false, c => c.DoubleTrack, ( c, v ) => c.DoubleTrack = v, null, 0 ),
		F( "WidthBacking", 0f, 1f, false, c => c.WidthBacking, ( c, v ) => c.WidthBacking = v, null, 0 ),
		F( "WidthLead", 0f, 1f, false, c => c.WidthLead, ( c, v ) => c.WidthLead = v, null, 0 ),
		// Bounded at 20 cents, not 50: half a quarter-tone between two takes is not a double, it is
		// a tuning error, and this is a house-config field with no way for a listener to undo it.
		F( "WidthDetune", 0f, 20f, false, c => c.WidthDetune, ( c, v ) => c.WidthDetune = v, null, 0 ),
		F( "WidthDelayMs", 0f, 40f, false, c => c.WidthDelayMs, ( c, v ) => c.WidthDelayMs = v, null, 0 ),
		F( "WidthJitterMs", 0f, 30f, false, c => c.WidthJitterMs, ( c, v ) => c.WidthJitterMs = v, null, 0 ),
		F( "WidthAmpVar", 0f, 1f, false, c => c.WidthAmpVar, ( c, v ) => c.WidthAmpVar = v, null, 0 ),
		F( "WidthCutoffVar", 0f, 1f, false, c => c.WidthCutoffVar, ( c, v ) => c.WidthCutoffVar = v, null, 0 ),
	};

	/// <summary>Overlay a <c>name → raw value</c> map (the shared config file's "advanced" block)
	/// onto <paramref name="c"/>. Keys match <see cref="AdvancedFields"/> names (= Config field
	/// names) 1:1; unknown keys are ignored and values are clamped to each field's range. Both
	/// hosts use this: s&box reads the file and calls this; the web mirrors it in JS over the
	/// same field list. Call it where the baseline mix is assembled (after defaults/vibe).</summary>
	public static void ApplyAdvanced( IReadOnlyDictionary<string, float> values, MusicGen.Config c )
	{
		if ( c == null || values == null ) return;
		foreach ( var f in AdvancedFields )
			if ( values.TryGetValue( f.Name, out var v ) )
				f.Set( c, Math.Clamp( v, f.Min, f.Max ) );
	}

	// ── Genres: which cells they expose, in what order, under what name ───────────────────
	// A genre NEVER redefines what a cell does — it picks the rows a listener gets sliders for and
	// the words on them. Everything a genre sounds like is in its voice code and GenreProfile.
	sealed class RowDef
	{
		public int Voice;                 // index into Voices
		public string Label;              // what this genre calls it (null = the voice's own name)
		public int[] Columns;             // which travelling columns it exposes
		public string[] Labels;           // per-column name override (null entry = the cell's own)
	}

	sealed class GenreDef
	{
		public string Name;
		public RowDef[] Rows;             // display order
	}

	// All four cells of a voice, under the voice's own names. The common case.
	static RowDef R( int voice, string label = null )
		=> new() { Voice = voice, Label = label, Columns = null, Labels = null };

	// A subset of a voice's cells, optionally renamed: R( V, "SYNTH", (1, null), (3, "PLUCK") ).
	static RowDef R( int voice, string label, params (int col, string name)[] cells )
	{
		var cols = new int[cells.Length];
		var names = new string[cells.Length];
		for ( int i = 0; i < cells.Length; i++ ) { cols[i] = cells[i].col; names[i] = cells[i].name; }
		return new RowDef { Voice = voice, Label = label, Columns = cols, Labels = names };
	}

	const int Drums = 0, Bass = 1, Skank = 2, Organ = 3, Melody = 4, Horns = 5,
		Keys = 6, RhythmGtr = 7, LeadGtr = 8;

	static readonly GenreDef[] GenreDefs =
	{
		// Ska-Punk. The chorus guitar is the RHYTHM GTR voice: third-wave ska drops the skank for
		// driven power chords once the section is loud (GenreProfile.LoudComp).
		new() { Name = "Ska-Punk", Rows = new[]
		{
			R( Bass ), R( Skank ), R( Organ ), R( Melody, "LEAD" ), R( Horns ), R( Drums ),
			R( RhythmGtr, "CHORUS GTR" ),
		} },
		new() { Name = "Rock", Rows = new[]
		{
			R( Drums ), R( Bass ), R( Keys ), R( LeadGtr ), R( RhythmGtr ),
		} },
		// Country — clean strummed open chords, honky-tonk piano, twangy telecaster lead. The
		// cleaner floor under each DISTORTION knob is in Guitar.cs / Lead.cs / Keys.cs.
		new() { Name = "Country", Rows = new[]
		{
			R( Drums ), R( Bass ), R( RhythmGtr ), R( Keys ), R( LeadGtr ),
		} },
		new() { Name = "Metal", Rows = new[]
		{
			R( Drums ), R( Bass ), R( RhythmGtr ), R( LeadGtr ),
		} },
		// Punk — "lean punk" / power-pop: rock's voices without the keys.
		new() { Name = "Punk", Rows = new[]
		{
			R( Drums ), R( Bass ), R( RhythmGtr ), R( LeadGtr ),
		} },
		// Pop — modern synth/dance-pop. The KEYS voice run clean and bright (PLUCK tightens the
		// ringing pad toward stabs) and the LEAD GTR voice run clean as a plucky synth lead. Both
		// hide their DISTORTION cell: pop's clean floor is Keys.cs / Lead.cs, and a slider that
		// the voice code overrules is a lie. The cell still TRAVELS — every vibe is full width.
		new() { Name = "Pop", Rows = new[]
		{
			R( Drums ), R( Bass ),
			R( Keys, "SYNTH", (1, null), (3, "PLUCK") ),
			R( LeadGtr, "LEAD", (1, null), (3, "GLIDE") ),
		} },
	};

	public static int GenreCount => GenreDefs.Length;
	public static IReadOnlyList<string> Genres
	{
		get { var a = new string[GenreDefs.Length]; for ( int i = 0; i < a.Length; i++ ) a[i] = GenreDefs[i].Name; return a; }
	}

	static GenreDef Def( int genre ) => GenreDefs[Math.Clamp( genre, 0, GenreDefs.Length - 1 )];

	/// <summary>A genre's row as the UI shows it: the voice's field, relabelled where the genre
	/// says so. The returned Field is a copy — the global cell is never mutated.</summary>
	static Field Labelled( Field cell, string voiceLabel, string nameOverride )
	{
		if ( cell == null ) return null;
		if ( voiceLabel == null && nameOverride == null ) return cell;
		return new Field
		{
			Name = nameOverride ?? cell.Name, Min = cell.Min, Max = cell.Max, Int = cell.Int,
			Choices = cell.Choices, Get = cell.Get, Set = cell.Set,
			Voice = voiceLabel ?? cell.Voice, Column = cell.Column,
		};
	}

	/// <summary>Flat list of the sliders <paramref name="genre"/> shows, in display order: each
	/// row's volume then its exposed cells. Each field carries its <see cref="Field.Voice"/> /
	/// <see cref="Field.Column"/>, so the UI lays the matrix out without a second table.</summary>
	/// <remarks>The returned fields are STABLE: the same genre hands back the same objects every
	/// call, because a UI identifies a knob by reference to find its wire index. <see cref="Labelled"/>
	/// mints a fresh <see cref="Field"/> for any row its genre renames, so a list rebuilt per call
	/// makes those knobs unfindable — they draw and drag and set nothing, and only on the genres that
	/// rename a row. Cached per genre for that reason first and for the allocations second.</remarks>
	public static IReadOnlyList<Field> Fields( int genre )
	{
		int g = Math.Clamp( genre, 0, GenreDefs.Length - 1 );
		var cache = _fields ??= new IReadOnlyList<Field>[GenreDefs.Length];
		if ( cache[g] != null ) return cache[g];

		var list = new List<Field>();
		foreach ( var row in GenreDefs[g].Rows )
		{
			var v = Voices[row.Voice];
			list.Add( Labelled( v.Volume, row.Label, null ) );
			if ( row.Columns == null )
			{
				foreach ( var cell in v.Cells )
					if ( cell != null ) list.Add( Labelled( cell, row.Label, null ) );
			}
			else
			{
				for ( int i = 0; i < row.Columns.Length; i++ )
				{
					var cell = v.Cells[row.Columns[i] - WireFirstColumn];
					if ( cell != null ) list.Add( Labelled( cell, row.Label, row.Labels[i] ) );
				}
			}
		}
		cache[g] = list;
		return list;
	}

	// One list per genre, built on first ask. Lazy rather than a static initialiser so it cannot
	// depend on field-initialisation order with GenreDefs.
	static IReadOnlyList<Field>[] _fields;

	/// <summary>True if <paramref name="f"/> is a per-instrument VOLUME knob — column 0 of an
	/// instrument row, kept out of the shareable seed and persisted per-voice instead.</summary>
	public static bool IsVolume( Field f ) => f != null && f.Voice != null && f.Column == 0;

	/// <summary>Read the per-instrument volumes of <paramref name="genre"/> off
	/// <paramref name="c"/> as a <c>voice → 0..1 level</c> map. The key is the label this genre
	/// uses, which is what the UI shows; a voice a genre renames (pop's "SYNTH") therefore keeps
	/// its own level there. Merge this into a single store across genres.</summary>
	public static Dictionary<string, float> ReadVolumes( int genre, MusicGen.Config c )
	{
		var d = new Dictionary<string, float>();
		if ( c == null ) return d;
		foreach ( var f in Fields( genre ) )
			if ( IsVolume( f ) ) d[f.Voice] = f.GetNorm( c );
		return d;
	}

	/// <summary>Overlay a <c>voice → 0..1 level</c> map (from <see cref="ReadVolumes"/> / storage)
	/// onto <paramref name="c"/> for <paramref name="genre"/>. Voices absent from the map keep
	/// their current/default level. Call this after <see cref="Apply"/> so a song's saved mix
	/// rides on top of the seed's voicing.</summary>
	public static void ApplyVolumes( int genre, IReadOnlyDictionary<string, float> vols, MusicGen.Config c )
	{
		if ( c == null || vols == null ) return;
		foreach ( var f in Fields( genre ) )
			if ( IsVolume( f ) && vols.TryGetValue( f.Voice, out var n ) )
				f.SetNorm( c, n );
	}

	// ── The wire ──────────────────────────────────────────────────────────────────────────

	/// <summary>Encode the whole global grid off <paramref name="c"/>. Genre-independent: the
	/// result depends on the knobs and on nothing else, so re-encoding after a genre change hands
	/// back the same string.</summary>
	public static string Encode( MusicGen.Config c )
	{
		if ( c == null ) return "";
		var sb = new StringBuilder( VibeLength );
		foreach ( var v in Voices )
			foreach ( var cell in v.Cells )
				sb.Append( cell != null ? Quant( cell, c ) : Hex[0] );
		return sb.ToString();
	}

	static char Quant( Field f, MusicGen.Config c )
	{
		int q = (int)Math.Round( f.GetNorm( c ) * (Levels - 1) );
		return Hex[Math.Clamp( q, 0, Levels - 1 )];
	}

	/// <summary>True if <paramref name="s"/> is a vibe: exactly <see cref="VibeLength"/> hex
	/// chars. There is no near-miss — see the class remarks on why short does not degrade.</summary>
	public static bool IsVibe( string s )
	{
		if ( s == null || s.Length != VibeLength ) return false;
		foreach ( var ch in s )
			if ( Hex.IndexOf( char.ToLowerInvariant( ch ) ) < 0 ) return false;
		return true;
	}

	/// <summary>Apply a vibe string onto <paramref name="c"/> in place. Returns false (touching
	/// nothing) if it is not a vibe — callers that need to TELL the listener use
	/// <see cref="SeedCodec.TryParse"/>, which is where the message lives.</summary>
	public static bool Apply( string vibe, MusicGen.Config c )
	{
		if ( c == null || !IsVibe( vibe ) ) return false;
		vibe = vibe.ToLowerInvariant();
		for ( int v = 0; v < Voices.Length; v++ )
			foreach ( var cell in Voices[v].Cells )
			{
				if ( cell == null ) continue;
				int q = Hex.IndexOf( vibe[Pos( v, cell.Column )] );
				cell.SetNorm( c, q / (float)(Levels - 1) );
			}
		return true;
	}

	// ── Rolling ───────────────────────────────────────────────────────────────────────────

	/// <summary>
	/// Roll a whole vibe STRING — every cell of the global grid, genre-independent.
	///
	/// This is the one definition of what "reroll" means, shared by every player, so the two
	/// drivers cannot answer the question differently. It produces a string rather than editing a
	/// Config on purpose: a rolled vibe is full width like any other, so it can be pinned into a
	/// seed and heard identically under a genre that was rolled separately.
	///
	/// Randomness is the CALLER's: <paramref name="rnd"/> returns values in [0,1). A driver that
	/// wants a throwaway roll passes a session RNG; one that wants a reproducible roll passes a
	/// seeded stream (see <see cref="SeedCodec.RollVibeFor"/>). The engine stays free of any
	/// ambient RNG.
	/// </summary>
	public static string RollVibe( Func<float> rnd )
	{
		if ( rnd == null ) return new string( Hex[0], VibeLength );
		var sb = new StringBuilder( VibeLength );
		for ( int i = 0; i < VibeLength; i++ )
		{
			if ( !HasCell( i ) ) { sb.Append( Hex[0] ); continue; }   // a hole stays a hole
			// Guard the top of the range: a generator returning exactly 1.0 must not index off the
			// end of the alphabet.
			int q = (int)(rnd() * Levels);
			sb.Append( Hex[Math.Clamp( q, 0, Levels - 1 )] );
		}
		return sb.ToString();
	}

	/// <summary>Roll a genre index from the same kind of caller-owned stream.</summary>
	public static int RollGenre( Func<float> rnd )
		=> rnd == null ? 0 : Math.Clamp( (int)(rnd() * GenreCount), 0, GenreCount - 1 );

	/// <summary>Roll the per-instrument volumes of <paramref name="genre"/> in place — the one
	/// thing a vibe string cannot carry, for a driver that wants the mix rolled too.</summary>
	public static void RollVolumes( int genre, MusicGen.Config c, Func<float> rnd )
	{
		if ( c == null || rnd == null ) return;
		foreach ( var f in Fields( genre ) )
			if ( IsVolume( f ) ) f.SetNorm( c, rnd() );
	}
}