Code/Engine/Voices/Comp.cs

Part of the MusicGen engine, this file implements the chordal/comping voices for a music section. It renders the main comp voice and a secondary keys voice, computes density trims for figure loudness balancing, determines comp hit lengths, and routes to several comping styles (riff, strum, pad, etc.).

Native Interop
using System;
using System.Collections.Generic;

namespace Skafinity;

// The chordal layer's dispatch: the genre's comp figure, played the genre's way.
//
// Part of the MusicGen engine — see MusicGen.cs.

public sealed partial class MusicGen
{
	/// <summary>The main chordal voice for a bar. The FIGURE comes from the genre's table (a
	/// pattern with its own length, so a two-bar riff is a two-bar riff) and the STYLE says what
	/// the voice does with each hit. Three genres sharing one comping rhythm was the loudest
	/// duplication left in the engine — this is the seam that fixes it.</summary>
	/// <param name="loud">The section is loud enough that the genre changes technique — see
	/// <see cref="GenreProfile.LoudComp"/>. One voice, one chord, a different instrument gesture;
	/// the caller has already picked the matching figure.</param>
	void RenderCompVoice( int barTick, int to, int chord, Pattern fig, Rng rng, Rng exprRng,
		bool loud = false )
	{
		_compTrim = DensityTrim( fig, loud ? _prof.LoudCompFigures : _prof.CompFigures );
		var hits = fig.Slice( barTick, to, _sectionTick, _feel );
		// Remember what the riff played: where the bass doubles it (metal, and punk's unison
		// option) it reads these onsets rather than a table of its own.
		_riffOnsets.AddRange( hits );
		Trace?.Add( TraceVoice.Comp, hits );

		switch ( loud ? _prof.LoudComp : _prof.Comp )
		{
			case CompStyle.Riff: RenderRiffBar( hits, chord, rng, exprRng ); break;
			case CompStyle.BoomChick: RenderStrumBar( hits, chord, rng, exprRng ); break;
			case CompStyle.Downstroke: RenderDownstrokeBar( hits, chord, rng, exprRng ); break;
			case CompStyle.Gallop: RenderGallopBar( hits, chord, rng, exprRng ); break;
			case CompStyle.Pad: RenderPadBar( hits, chord, rng, exprRng ); break;
			default: RenderSkankBar( hits, chord, rng, exprRng ); break;
		}
	}

	/// <summary>
	/// How far the drawn comp figure is trimmed for its own DENSITY, relative to the genre's table.
	///
	/// A section draws its figure from the genre's table and the figures are not equally dense, so
	/// how loud the backing sits was decided by a draw: rock's rhythm guitar measured 4 dB apart
	/// between the seed the suite balances on and the genre's own average. Nothing was watching it —
	/// <c>--levels</c> averages the spread away and the suite's balance check is a single seed —
	/// and 4 dB is a larger move than any of the balances it sits under, handed out at random.
	///
	/// The trim is EXACTLY the arithmetic part of that and no more. N onsets of an unrelated-phase
	/// voice sum incoherently, so the level of a figure grows as √N: the trim is therefore
	/// √(table mean ÷ this figure), which cancels that growth and nothing else. What survives is
	/// everything MUSICAL about the difference — the cells' own velocities, the genre's accent
	/// weights, the section's energy, and the plain fact that a busier bar has more attacks in it.
	/// A full compensation (exponent 1) would flatten the busy figure into the sparse one, which is
	/// the mistake the other repair — levelling the figures against each other — makes by design.
	///
	/// AND IT IS MEAN-PRESERVING, which is the difference between narrowing a spread and moving a
	/// balance. √(mean ÷ d) is convex in d, so averaged over a genre's table it comes out above 1
	/// and every genre's comp would drift a little louder — a mix change smuggled in with a spread
	/// fix, and the `*Balance` values are measured numbers that would then be wrong. Dividing by the
	/// table's own average trim leaves the genre exactly where `--levels` measured it and moves only
	/// the seed-to-seed variation, which is the whole and only claim.
	///
	/// Clamped, because a table with one outlier figure should not push the whole genre's comp
	/// around, and because a trim is a correction rather than a mix control.
	/// </summary>
	/// <summary>How often a chordal voice plays its genre's flourish over a two-bar window instead
	/// of the figure it is on. One number for every genre: what makes a gesture read as one is that
	/// it is occasional, and how occasional is not something a genre has a strong opinion about —
	/// where genres differ is in what the gesture IS, which is the pattern itself.</summary>
	const float OrnamentChance = 0.22f;

	internal static float DensityTrim( Pattern fig, Pattern[] table )
	{
		if ( table == null || table.Length < 2 ) return 1f;
		float d = fig.Count / (float)fig.LengthTicks;
		if ( d <= 0f ) return 1f;
		float mean = 0f;
		foreach ( var p in table ) mean += p.Count / (float)p.LengthTicks;
		mean /= table.Length;
		float norm = 0f;
		foreach ( var p in table )
		{
			float pd = p.Count / (float)p.LengthTicks;
			norm += pd > 0f ? MathF.Sqrt( mean / pd ) : 1f;
		}
		norm /= table.Length;
		if ( norm <= 0f ) return 1f;
		return Math.Clamp( MathF.Sqrt( mean / d ) / norm, 0.75f, 1.3f );
	}

	/// <summary>How long a comp hit rings.
	///
	/// NOT simply "until the next onset". A figure with uneven gaps then produces a note with an
	/// uneven length every single bar — the "short, longggg" shape that made the backing read as
	/// one repeated cell however varied the figure was. A chord rings for up to two beats and a
	/// stab is a stab; past that the voice is silent and the next hit lands into space, which is
	/// what a played part actually sounds like.</summary>
	static int CompLen( int spanTicks, bool ring )
		=> Math.Max( 1, Math.Min( spanTicks, ring ? Timing.TicksPerBeat * 2 : Timing.TicksPerEighth ) );

	/// <summary>The second chordal voice — the keys/piano/synth layer, where the genre has one.
	/// It never doubles the main voice: rock's organ answers the riff's gaps, country's piano
	/// hits the backbeat the guitar leaves alone, pop's arp moves over a pad that does not.
	/// </summary>
	void RenderKeysVoice( int barTick, int to, int chord, Rng rng, Rng exprRng, bool ornament )
	{
		var hits = (ornament ? _prof.KeysOrnament : _keysFig).Slice( barTick, to, _sectionTick, _feel );
		Trace?.Add( TraceVoice.Keys, hits );
		switch ( _prof.Keys )
		{
			case KeysStyle.HonkyTonk: RenderHonkyTonkBar( hits, chord, rng, exprRng ); break;
			case KeysStyle.Arp: RenderArpBar( hits, chord, rng, exprRng ); break;
			default: RenderKeysStabBar( hits, chord, rng, exprRng ); break;
		}
	}
}