Code/Engine/Structure.cs

Defines musical song structure types and authored section templates for genres, and provides utilities to draw a concrete song form. Contains the Part readonly struct describing per-section state and the SongForm static tables for genre variants, plus MusicGen methods to build a randomized form (DrawForm), determine optional sections, and map Section to an RNG key string.

File Access
using System;
using System.Collections.Generic;

namespace Skafinity;

/// <summary>A section of the arrangement. Appended to, never reordered — the section TYPE is an
/// RNG key (see <see cref="MusicGen.SectionKey"/>), so its name is part of what a song is.</summary>
enum Section { Intro, Chorus, Verse, Ending, PreChorus, Bridge, Solo, Breakdown }

/// <summary>
/// One section instance in a song's form.
///
/// A section is not just a span of bars: it carries the state that makes it FEEL like a
/// different part of the song. Voices read <see cref="Energy"/> (thin the comp and drop the
/// hats in a verse, pile on in a chorus), the time base reads <see cref="TempoMul"/>, the
/// pattern layer reads <see cref="Feel"/> (half/double time), and the harmony reads
/// <see cref="KeyShift"/> (the final-chorus lift).
///
/// <see cref="BarBeats"/> is the anomalous-measure hook: a section is normally a run of bars in
/// the song's meter, but it may name a beat count per bar, which is how a 2/4 link bar lands
/// inside a 4/4 song.
/// </summary>
readonly struct Part
{
	public readonly Section Type;
	public readonly int Bars;
	public readonly int VerseIndex;

	/// <summary>0 = as sparse as the section ever gets, 1 = full band. Every voice scales its
	/// density and its level off this rather than inventing its own verse/chorus rule.</summary>
	public readonly float Energy;

	/// <summary>The RHYTHM SECTION's pattern-rate multiplier: 0.5 = half time, 2 = double time.
	/// NOT a tempo change — the grid is untouched, the figures stretch or compress against it, and
	/// the song's length does not move.
	///
	/// It is the rhythm section's and not the song's: <see cref="MusicGen.RenderTune"/> plays the
	/// tune at the nominal rate whatever this says. Half and double time are a contrast BETWEEN
	/// the band and the melody — the vocal holds its ground while the kit and the comp change gear
	/// — so a multiplier applied to both at once expresses nothing at all.</summary>
	public readonly float Feel;

	/// <summary>Tempo multiplier for the section, applied to the time base's per-tick delta.</summary>
	public readonly float TempoMul;

	/// <summary>Semitones the section's key is shifted by — the final-chorus lift.</summary>
	public readonly int KeyShift;

	/// <summary>True if the section's last two bars regroup into a hemiola (a figure whose length
	/// does not divide the bar), which is the cadential accelerando into the next section.</summary>
	public readonly bool Hemiola;

	/// <summary>Beats per bar, per bar — null when every bar is the song's meter. A short bar here
	/// is the "anomalous measure": a 2/4 inside a 4/4 context.</summary>
	public readonly int[] BarBeats;

	public Part( Section t, int bars, int verse = 0, float energy = 0.7f, float feel = 1f,
		float tempoMul = 1f, int keyShift = 0, bool hemiola = false, int[] barBeats = null )
	{
		Type = t; Bars = bars; VerseIndex = verse; Energy = energy; Feel = feel;
		TempoMul = tempoMul; KeyShift = keyShift; Hemiola = hemiola;
		BarBeats = barBeats;
	}

	/// <summary>The same section with a detail changed — what the per-song form draw works with.
	/// Everything a form variant AUTHORS (its energy contour, its feel changes, where the hemiola
	/// falls) travels through unchanged; only length and the key lift are drawn.</summary>
	public Part With( int bars, int keyShift ) =>
		new( Type, bars, VerseIndex, Energy, Feel, TempoMul, keyShift, Hemiola, BarBeats );
}

/// <summary>
/// The per-genre section maps.
///
/// One hardcoded list used to serve every genre, so a metal song and a pop song had
/// byte-identical FORM — sameyness no amount of note variation reaches. Each genre now names
/// its own run of sections, its own energy contour, and the places it does something a genre
/// two rows down would never do: metal drops into a half-time breakdown, pop lifts the last
/// chorus a tone, punk cuts a bar short on the way into a chorus, country and rock take a solo.
///
/// Every section here is a multiple of 4 bars in the song's own meter. The 2/4 link bar
/// <see cref="Part.BarBeats"/> exists for is deliberately UNUSED at the moment: dropping a beat
/// out of a bar under a melody reads as the song jumping to a downbeat early, because the tune is
/// a phrase and the missing beat is taken out of the middle of it. The MECHANISM is sound and it
/// is the hook the non-4/4 work needs — what is missing is the melodic half of it (a tune that
/// knows the bar it is being sung over is short, rather than one that is simply truncated). Wire
/// a short bar back in when that lands, not before.
///
/// A <c>Displace</c> field used to sit alongside <see cref="Part.Hemiola"/> — a constant tick
/// offset that pushed the chordal voices late for a whole section. It is gone, and it should not
/// come back in that shape. Three things were wrong with it and all three are structural:
/// it shifted LATE where real syncopation anticipates; it moved the guitar and keys but not the
/// bass, so every chord arrived as a flam with its own root; and being constant across a section
/// it never re-converged, so there was no dissonance to resolve — just an offset bounded by two
/// hard cuts. A push into a phrase seam is ONE GESTURE and the engine already writes it where it
/// belongs, in a figure's cells (the ska skank's stab on the "and of 4", the Charleston, the horn
/// answer). <see cref="Part.Hemiola"/> is the metric device that survives, because a figure whose
/// length does not divide the bar genuinely drifts and comes back.
/// </summary>
static class SongForm
{
	// Energies read as a contour rather than absolutes: what matters is that a verse sits under
	// its chorus and a breakdown falls out of the bottom.
	const float Low = 0.30f, Mid = 0.55f, Lift = 0.75f, Full = 1.00f;

	// Ska-punk — the third-wave shape, and it CLIMBS. The old form opened on the chorus and rotated
	// around it because the genre was tuned laid-back; ska-punk is dynamic music built on the drop
	// between a clean skank verse and a distorted chorus (GenreProfile.LoudComp reads the energy
	// column below to make that happen), so the form has to set that drop up rather than sit level.
	// The bridge is the breakdown, and it is where this genre spends its FEEL: the band drops to
	// half time behind a vocal that does not (Part.Feel), which is the third-wave breakdown and
	// the one place the style genuinely changes gear rather than changing technique. The
	//
	// THE CHORUS TAKES NO FEEL, AND THAT IS SETTLED RATHER THAN UNEXAMINED. The verse→chorus pivot
	// is already carried by GenreProfile.LoudComp swapping the skank for driven power chords, and
	// Feel = 2 on top of it is not a second helping of the same idea — it is a different and worse
	// one, because genre 0's band is counted DOUBLE already (the skank fires once per beat; see the
	// tempo comment in GenreProfile). Doubling the rhythm section's pattern rate over that band puts
	// the loud figures on the sixteenth: the densest of them (CompFigure.SkaPunkLoud's offbeat
	// answer, six onsets to the bar) lands twelve onsets a bar, which at the band's own 172 is 8.6
	// attacks a second and at the genre's tempo ceiling of 210 is over ten — downpicked, sustained,
	// for eight bars. That is past what a guitarist does and past what the style asks for: a
	// third-wave chorus is a HEAVIER part, not a faster one.
	//
	// The escape hatch would be bringing the band down until the doubling fits, and it is closed —
	// the band is anchored on a record ("Sell Out", converted into this engine's units), so moving
	// it to make a feel work would trade a measurement for a preference. The technique change IS the
	// gear change, and the feel stays where it is unambiguous: the half-time bridge.
	public static readonly Part[] SkaPunk =
	{
		new( Section.Intro,     4, energy: Low, tempoMul: 0.98f ),
		new( Section.Verse,     8, 0, energy: Mid ),
		new( Section.PreChorus, 4, energy: Lift ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Verse,     8, 1, energy: Mid, hemiola: true ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Bridge,    8, energy: Low + 0.05f, feel: 0.5f ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Ending,    4, energy: Lift ),
	};

	// The alternate: no pre-chorus at all, so the verse drops straight into the chorus, and the
	// half-time bridge moves later. Same genre, different arc — a third-wave record does both.
	public static readonly Part[] SkaPunkB =
	{
		new( Section.Intro,   4, energy: Low, tempoMul: 0.98f ),
		new( Section.Verse,   8, 0, energy: Mid ),
		new( Section.Chorus,  8, energy: Full ),
		new( Section.Verse,   8, 1, energy: Mid, hemiola: true ),
		new( Section.Chorus,  8, energy: Full ),
		new( Section.Verse,   8, 2, energy: Mid ),
		new( Section.Bridge,  8, energy: Low + 0.05f, feel: 0.5f ),
		new( Section.Chorus,  8, energy: Full ),
		new( Section.Ending,  4, energy: Lift ),
	};

	// Rock — verse / pre-chorus / chorus, with the solo where a rock song puts it: after the
	// second chorus, before the last one. The pre-chorus is the transitional section, so it is the
	// one that regroups into a hemiola on the way into the chorus.
	public static readonly Part[] Rock =
	{
		new( Section.Intro,     4, energy: Low, tempoMul: 0.97f ),
		new( Section.Verse,     8, 0, energy: Mid ),
		new( Section.PreChorus, 4, energy: Lift ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Verse,     8, 1, energy: Mid ),
		new( Section.PreChorus, 4, energy: Lift, hemiola: true ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Solo,      8, energy: Lift ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Ending,    4, energy: Lift ),
	};

	// The alternate: no solo, and the bridge does the lifting instead — the other half of the
	// alt-rock playbook, where the third section is a drop rather than a guitar break.
	public static readonly Part[] RockB =
	{
		new( Section.Intro,     4, energy: Low, tempoMul: 0.97f ),
		new( Section.Verse,     8, 0, energy: Mid ),
		new( Section.PreChorus, 4, energy: Lift ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Verse,     8, 1, energy: Mid ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Bridge,    8, energy: Low + 0.1f, hemiola: true ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Ending,    4, energy: Lift ),
	};

	// Country — the plainest form of the six, and deliberately so: verse, chorus, verse, chorus,
	// a solo on the changes, then the last chorus a tone up (the Nashville lift).
	public static readonly Part[] Country =
	{
		new( Section.Intro,  4, energy: Low, tempoMul: 0.96f ),
		new( Section.Verse,  8, 0, energy: Mid ),
		new( Section.Chorus, 8, energy: Full ),
		new( Section.Verse,  8, 1, energy: Mid ),
		new( Section.Chorus, 8, energy: Full ),
		new( Section.Solo,   8, energy: Lift ),
		new( Section.Chorus, 8, energy: Full, keyShift: 2 ),
		new( Section.Ending, 4, energy: Lift ),
	};

	// The alternate: three verses and no solo — the storytelling shape, which is as country as the
	// solo one and is what the genre does when the words are the point.
	public static readonly Part[] CountryB =
	{
		new( Section.Intro,     4, energy: Low, tempoMul: 0.96f ),
		new( Section.Verse,     8, 0, energy: Mid ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Verse,     8, 1, energy: Mid ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Verse,     8, 2, energy: Mid, hemiola: true ),
		new( Section.PreChorus, 4, energy: Lift ),
		new( Section.Chorus,    8, energy: Full, keyShift: 2 ),
		new( Section.Ending,    4, energy: Lift ),
	};

	// Metal — the breakdown is the form's whole argument: everything drops to half time and the
	// energy falls away, then the solo climbs back out of it into the final chorus.
	public static readonly Part[] Metal =
	{
		new( Section.Intro,     4, energy: Low, tempoMul: 0.94f ),
		new( Section.Verse,     8, 0, energy: Mid ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Verse,     8, 1, energy: Mid ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Breakdown, 8, energy: Low, feel: 0.5f, tempoMul: 0.98f ),
		new( Section.Solo,      8, energy: Lift ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Ending,    4, energy: Full ),
	};

	// The alternate: the breakdown moves to the last possible moment, straight into the final
	// chorus with no solo to climb back out through. The other way a metal record is built.
	public static readonly Part[] MetalB =
	{
		new( Section.Intro,     4, energy: Low, tempoMul: 0.94f ),
		new( Section.Verse,     8, 0, energy: Mid ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Solo,      8, energy: Lift ),
		new( Section.Verse,     8, 1, energy: Mid ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Breakdown, 8, energy: Low, feel: 0.5f, tempoMul: 0.98f ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Ending,    4, energy: Full ),
	};

	// Punk — the shortest form, no pre-chorus, no solo, and a four-bar bridge that regroups on the
	// run into the last chorus. Everything is at the top of its energy already.
	public static readonly Part[] Punk =
	{
		new( Section.Intro,  4, energy: Mid, tempoMul: 1f ),
		new( Section.Verse,  8, 0, energy: Lift ),
		new( Section.Chorus, 8, energy: Full ),
		new( Section.Verse,  8, 1, energy: Lift ),
		new( Section.Chorus, 8, energy: Full ),
		// HALF TIME. A great deal of punk does this — the band drops to half the backbeat while
		// the tempo does not move, which is what a mosh part IS, and it is the genre's only gear
		// change. Without it a punk song can only run flat out for its whole length, and the
		// tempo band takes the blame for what is really a form with no dynamic in it.
		new( Section.Bridge, 4, energy: Mid, feel: 0.5f, hemiola: true ),
		new( Section.Chorus, 8, energy: Full ),
		new( Section.Ending, 4, energy: Full ),
	};

	// The alternate: no bridge at all — verse, chorus, verse, chorus, chorus, out. The shortest
	// form on the roster, which is the point of it.
	public static readonly Part[] PunkB =
	{
		new( Section.Intro,  4, energy: Mid, tempoMul: 1f ),
		new( Section.Verse,  8, 0, energy: Lift ),
		new( Section.Chorus, 8, energy: Full ),
		new( Section.Verse,  8, 1, energy: Lift, hemiola: true ),
		new( Section.Chorus, 8, energy: Full ),
		// The same gear change, taken before the last chorus rather than in a bridge. There is no
		// key lift here for it to undercut — which is exactly why it had to move out of Pop.
		new( Section.Breakdown, 4, energy: Mid, feel: 0.5f ),
		new( Section.Chorus, 8, energy: Full ),
		new( Section.Ending, 4, energy: Full ),
	};

	// Pop — the modern shape: pre-chorus into every chorus, a half-time "drop" bridge, and the
	// last chorus lifted a tone. The four-chord loop already IS the hypermeasure here
	// (GenreProfile.ChordBars = 1), so the form is what varies rather than the harmony.
	// THE MODULATION IS DIRECT — chorus straight into chorus a tone up, with nothing in between.
	// That is the device pop actually uses (the "truck driver's gear change"), and it works BECAUSE
	// it is abrupt: the lift is the whole gesture.
	//
	// A half-time breakdown used to sit in that gap, at energy 0.30, and it is the one thing in
	// this form that had to go. A modulation lifts a song relative to what came before it, so
	// dropping the floor out immediately beforehand leaves it nothing to lift FROM — the band goes
	// quiet and half-time, and then the key change arrives at a section the listener has just
	// stopped tracking. Two good devices, one after the other, cancelling.
	//
	// The drop is not lost: PopB carries it, mid-song, where a pop record puts it.
	public static readonly Part[] Pop =
	{
		new( Section.Intro,     4, energy: Low ),
		new( Section.Verse,     8, 0, energy: Mid ),
		new( Section.PreChorus, 4, energy: Lift ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Verse,     8, 1, energy: Mid ),
		new( Section.PreChorus, 4, energy: Lift ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Chorus,    8, energy: Full, keyShift: 2 ),
		new( Section.Ending,    4, energy: Lift ),
	};
	// The alternate: this is where pop's half-time DROP lives, and it lives MID-SONG — after the
	// first chorus, which is where a record puts it and where it has a chorus to fall out of and a
	// verse to build back through. The modulation at the end is direct here too, for the reason
	// spelled out on Pop.
	public static readonly Part[] PopB =
	{
		new( Section.Intro,     4, energy: Low ),
		new( Section.Verse,     8, 0, energy: Mid ),
		new( Section.PreChorus, 4, energy: Lift ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Breakdown, 4, energy: Low, feel: 0.5f ),
		new( Section.Verse,     8, 1, energy: Mid ),
		new( Section.PreChorus, 4, energy: Lift, hemiola: true ),
		new( Section.Chorus,    8, energy: Full ),
		new( Section.Chorus,    8, energy: Full, keyShift: 2 ),
		new( Section.Ending,    4, energy: Lift ),
	};
}

// Song form. Part of the MusicGen engine — see MusicGen.cs.
public sealed partial class MusicGen
{
	// Extra seconds appended after the last bar so the ending's final tonic chord (and the
	// master reverb) can ring out naturally instead of being clipped at the buffer edge. The
	// ending ritard stretches the last bars, so the tail is scaled by the ritard alongside it
	// (see ComposePlan) rather than being a constant that the slowdown outruns.
	const float RingOutTail = 2.4f;

	/// <summary>How many seconds of ring-out every song reserves past its last bar — the tail the
	/// final chord and the reverb decay into.
	///
	/// PUBLISHED because a player has to know it. A crossfade is meant to overlap this tail; a
	/// player that guesses a longer fade starts the next song BEFORE the current one's final
	/// chord has even landed, and two songs at two tempos with two downbeats play at once. The
	/// ritard stretches the last bars, so the tail is scaled to match it (see ComposePlan).</summary>
	public static float RingOutSeconds => RingOutTail * (1f + (float)RitardAmount);

	/// <summary>
	/// THIS SONG's form: one of the genre's authored variants, with its details drawn.
	///
	/// It has to be built ONCE and cached (<see cref="_form"/>), because five places used to
	/// re-derive it — the composer, and four diagnostics. That was harmless while the answer was a
	/// constant; the moment it varies per song, a diagnostic that re-derives it is a bar ruler
	/// disagreeing with the song it is measuring, and `--score` and `--grid` go quietly wrong
	/// exactly when they are most needed.
	///
	/// Randomising a form outright would be the wrong fix and is not what happens here. A form is
	/// genre identity, which is why it lives in <see cref="GenreProfile"/>, and punk with a
	/// sixteen-bar solo is not punk. Two mechanisms instead: a FAMILY of authored variants, and
	/// details drawn over whichever one was chosen.
	///
	/// Non-lead voices are seeded by section TYPE so every chorus plays identical backing; the lead
	/// folds in the verse index so it evolves; the section-end fill is seeded by absolute index so
	/// no two fills repeat.
	/// </summary>
	/// <remarks>STATIC, and takes its profile: drawing a form needs no song, and the suite's
	/// invariants over it are arithmetic. Left as an instance method they cost a composed song
	/// each, which put 35 seconds on a 25-second harness for checks that render nothing.</remarks>
	internal static List<Part> DrawForm( GenreProfile prof, Rng rng )
	{
		var variant = rng.PickWeighted( prof.Forms, prof.FormWeights );
		bool lift = rng.Chance( prof.KeyLiftChance );
		int verseBars = prof.VerseBars[rng.WeightedIndex( prof.VerseBarWeights )];
		bool twice = rng.Chance( prof.DoubleFinalChorusChance );
		bool cut = rng.Chance( prof.TruncateFinalVerseChance );

		int lastVerse = -1, lastChorus = -1;
		for ( int i = 0; i < variant.Length; i++ )
		{
			if ( variant[i].Type == Section.Verse ) lastVerse = i;
			if ( variant[i].Type == Section.Chorus ) lastChorus = i;
		}

		var parts = new List<Part>();
		for ( int i = 0; i < variant.Length; i++ )
		{
			var p = variant[i];
			// The roll is taken for EVERY section, optional or not, so how many values a form costs
			// is the form's length rather than how many optional sections it happens to carry.
			bool drop = rng.Chance( prof.OptionalDropChance ) && Optional( p.Type );
			if ( drop ) continue;

			int bars = p.Type == Section.Verse ? verseBars : p.Bars;
			// A truncated final verse still has to be a multiple of four: the hypermeasure is why
			// the ending is four bars and not two, and it does not stop applying here.
			if ( cut && i == lastVerse ) bars = Math.Max( 4, bars - 4 );
			int shift = lift ? p.KeyShift : 0;
			parts.Add( p.With( bars, shift ) );
			// The final chorus comes round twice as a REPEATED SECTION, not a longer one — every
			// chorus must still agree about its length.
			if ( twice && i == lastChorus ) parts.Add( p.With( p.Bars, shift ) );
		}
		return parts;
	}

	/// <summary>The sections a song may do without. Which one a genre has is most of what
	/// distinguishes the six forms from each other, so dropping one is a song that does without
	/// rather than a genre that does.</summary>
	static bool Optional( Section s ) =>
		s is Section.PreChorus or Section.Bridge or Section.Solo or Section.Breakdown;

	/// <summary>The section's RNG key — what makes every chorus play the same backing.</summary>
	internal static string SectionKey( Section s ) => s switch
	{
		Section.Intro => "intro",
		Section.Chorus => "chorus",
		Section.Verse => "verse",
		Section.PreChorus => "prechorus",
		Section.Bridge => "bridge",
		Section.Solo => "solo",
		Section.Breakdown => "breakdown",
		_ => "ending",
	};
}