Game/MonolithMusic.cs
namespace Monolith;

/// <summary>
/// The score: a chiptune sequencer, synthesised in C# at startup. There is no audio file
/// anywhere in this project.
///
/// **Why generate it.** s&amp;box ships 63 addressable sound events and not one of them is music.
/// The only music files on this machine live under `download/assets`, which is other creators'
/// packages cached by the client: not ours to use, and they would not resolve on anyone else's
/// machine. Beyond that, the Play Fund makes a package ineligible if it contains copyrighted
/// material, and music is the easiest way to fail that test. A score computed from arithmetic
/// has no provenance to argue about. See GOALS section 8.
///
/// **What changed from the first attempt.** The first version was three sine drones with slow
/// swells, and the verdict was fair: that is ambience, not music. Hotline Miami, Super Hexagon
/// and the chiptune driver tracks they come from are not textures, they are SEQUENCES. They have
/// a tempo you can nod to, a bassline that repeats, an arpeggio doing sixteenths, drums, and a
/// melody. None of that can emerge from summed sine waves, no matter how they are modulated. So
/// this is a step sequencer instead: notes on a grid, square and pulse waves, and a drum kit.
///
/// **The arrangement.** 168 BPM, sixteenth-note grid, eight bars, over a four chord loop in A
/// minor: Am, F, C, G. Three layers play permanently and only their VOLUMES move, so the track
/// builds with the run rather than cutting between tracks:
///
/// - **BED** is kick, a sixteenth-note bass, and the arpeggio. A complete track on its own.
/// - **WORK** adds backbeat, hats and the arp doubled an octave up. Rises as the stage empties.
/// - **THREAT** adds the vibrato lead, and rises with what is hunting you.
///
/// **Where the reference actually pointed.** The named tracks are Chipzel's, from Super Hexagon.
/// The thing to take from them is not the palette, it is that **the arpeggio is the tune**: it
/// runs continuously in sixteenths and the chords move underneath it. An earlier pass had the arp
/// in a layer that faded up while mining, so the track had no melody at all for the first half of
/// a stage. It is in the bed now, and the tempo went up to match.
///
/// **Why the loop is seamless.** 128 steps at 168 BPM is exactly 252000 samples at 22050 Hz, with
/// nothing left over. Notes render with WRAPPING indices, so anything still ringing at the end
/// continues into the top of the loop instead of being cut. There is no crossfade and no click.
/// </summary>
public sealed class MonolithMusic : Component
{
	// ---------------------------------------------------------------- the grid

	/// <summary>
	/// 22.05 kHz. Half of CD rate, chosen because square waves alias and a lower ceiling plus the
	/// filter below turns that aliasing into something that reads as a cheap synth rather than as
	/// harshness. It also halves generation time and memory.
	/// </summary>
	private const int SampleRate = 22050;

	/// <summary>
	/// 168, and the value is constrained rather than chosen freely. The loop must be a whole
	/// number of samples: `LoopSamples` works out to 42336000 / BPM, so only tempos that divide
	/// that cleanly keep the seam silent. 150, 160, 168, 175 and 180 all do. 176 does not.
	/// </summary>
	private const int Bpm = 168;

	/// <summary>Sixteenth notes. The arpeggio needs this resolution; nothing needs more.</summary>
	private const int StepsPerBeat = 4;

	/// <summary>Eight bars of 4/4. Long enough to hold a chord progression and a melody.</summary>
	private const int Steps = 128;

	private const int StepsPerBar = StepsPerBeat * 4;

	private const float StepSeconds = 60f / Bpm / StepsPerBeat;

	/// <summary>12.8 seconds, and deliberately a whole number of samples.</summary>
	private const int LoopSamples = (int)(SampleRate * Steps * StepSeconds);

	// ---------------------------------------------------------------- the music
	//
	// A minor: Am, F, C, G, two bars each. MIDI note numbers throughout, so the maths is the
	// standard one and the notes are readable to anyone who has seen a piano roll.

	/// <summary>Root of each chord, one per two bars.</summary>
	private static readonly int[] ChordRoots = { 33, 29, 36, 31 };   // A1, F1, C2, G1

	/// <summary>Chord tones above each root, as semitone offsets. Minor, major, major, major.</summary>
	private static readonly int[][] ChordTones =
	{
		new[] { 0, 3, 7, 12 },   // Am
		new[] { 0, 4, 7, 12 },   // F
		new[] { 0, 4, 7, 12 },   // C
		new[] { 0, 4, 7, 12 },   // G
	};

	/// <summary>
	/// The lead, as (step, midi, length in steps). Written out rather than generated: a melody is
	/// the one part of this that an algorithm makes worse.
	/// </summary>
	private static readonly int[][] Lead =
	{
		// Am
		new[] { 0, 76, 3 }, new[] { 4, 74, 3 }, new[] { 8, 72, 6 }, new[] { 16, 74, 3 },
		new[] { 20, 76, 3 }, new[] { 24, 69, 8 },
		// F
		new[] { 32, 77, 3 }, new[] { 36, 76, 3 }, new[] { 40, 72, 6 }, new[] { 48, 69, 3 },
		new[] { 52, 72, 3 }, new[] { 56, 76, 8 },
		// C
		new[] { 64, 79, 3 }, new[] { 68, 76, 3 }, new[] { 72, 74, 6 }, new[] { 80, 72, 3 },
		new[] { 84, 74, 3 }, new[] { 88, 76, 8 },
		// G
		new[] { 96, 74, 3 }, new[] { 100, 76, 3 }, new[] { 104, 79, 6 }, new[] { 112, 78, 3 },
		new[] { 116, 76, 3 }, new[] { 120, 74, 8 },
	};

	// ---------------------------------------------------------------- live state

	private SoundHandle bed, work, threat;

	private bool started;

	private float bedLevel, workLevel, threatLevel;

	protected override void OnStart()
	{
		Generate();
	}

	private void Generate()
	{
		if ( started )
			return;

		started = true;

		bed = StartLayer( "monolith_bed", RenderBed, Tuning.MusicBedVolume );
		work = StartLayer( "monolith_work", RenderWork, 0f );
		threat = StartLayer( "monolith_threat", RenderThreat, 0f );

		if ( bed == null && work == null && threat == null )
			Log.Warning( "[music] no layer started. The score is silent this session." );
	}

	/// <summary>
	/// Renders one layer, wraps it in a looping sound, and starts it playing.
	/// </summary>
	/// <remarks>
	/// The <see cref="SoundEvent"/> is built in code because the thing being played does not
	/// exist on disk. This is the only call in the file that is not arithmetic, so it is kept
	/// alone: if the engine changes how a runtime sound is played, this is all that changes.
	/// </remarks>
	private static SoundHandle StartLayer( string name, Action<float[]> compose, float volume )
	{
		try
		{
			var buffer = new float[LoopSamples];

			compose( buffer );
			LowPass( buffer, 7400f );
			Normalise( buffer, 0.85f );

			var file = SoundFile.FromPcm( name, Pack( buffer ), new SoundFile.PcmOptions
			{
				Rate = SampleRate,
				Bits = 16,
				Channels = 1,
				Loop = true,
			} );

			// UI, so it plays flat in both ears instead of being placed in the world. Music that
			// pans as you turn your head is a sound effect, not a score.
			var evt = new SoundEvent
			{
				Sounds = new List<SoundFile> { file },
				UI = true,
				Volume = 1f,
				DistanceAttenuation = false,
				OcclusionEnabled = false,
				ReverbEnabled = false,
			};

			var handle = Sound.Play( evt );
			handle.Volume = volume;

			return handle;
		}
		catch ( Exception e )
		{
			Log.Warning( $"[music] layer '{name}' failed: {e.Message}" );
			return null;
		}
	}

	// ---------------------------------------------------------------- the layers

	/// <summary>
	/// BED. Kick, a driving sixteenth bass, and THE ARPEGGIO.
	///
	/// The arpeggio lives here rather than in a layer that fades up, and that is the single
	/// biggest correction from the previous version. In Chipzel's Super Hexagon writing the arp
	/// is not decoration over a groove, it IS the tune: it runs continuously in sixteenths from
	/// the first bar to the last and the chords change underneath it. Putting it in a layer that
	/// only rose while mining meant the track had no melodic content at all until you were
	/// halfway through a stage, which is most of why it read as noise.
	///
	/// So the bed is a complete track on its own. The other two layers add weight to it, they do
	/// not complete it.
	/// </summary>
	private static void RenderBed( float[] buffer )
	{
		for ( int bar = 0; bar < Steps / StepsPerBar; bar++ )
		{
			int barStart = bar * StepsPerBar;
			int chord = ChordAt( barStart );
			int root = ChordRoots[chord];
			var tones = ChordTones[chord];

			// Four on the floor. The thing you nod to.
			for ( int beat = 0; beat < 4; beat++ )
				Kick( buffer, barStart + beat * StepsPerBeat, 0.95f );

			for ( int step = 0; step < StepsPerBar; step++ )
			{
				// BASS. Straight sixteenths, staccato, with every fourth note an octave up. At
				// 168 BPM that is about eleven notes a second, which is what makes the track feel
				// like it is running rather than walking. The fast decay is what keeps it from
				// turning into a drone: each note has to clear out before the next arrives.
				int bassNote = (step % 4 == 3) ? root + 12 : root;
				Note( buffer, barStart + step, 0.9f, bassNote, 0.4f, Waveform.Triangle, 16f );

				// THE ARP. Six notes against a sixteen step bar, so the pattern lands in a
				// different place in each bar and the ear never quite catches the loop.
				int[] shape = { 0, 1, 2, 3, 2, 1 };
				int arpNote = root + 36 + tones[shape[step % shape.Length]];

				// A 25% pulse: thinner and more nasal than a square, which is the sound that cuts
				// through without adding weight.
				Note( buffer, barStart + step, 1f, arpNote, 0.22f, Waveform.Pulse25, 13f );
			}
		}
	}

	/// <summary>
	/// WORK. Backbeat, hats, and the arpeggio doubled an octave up.
	///
	/// This layer is pure lift: it adds nothing new melodically, it makes what is already playing
	/// bigger and brighter. That is deliberate, because it fades in and out with how much of the
	/// stage is left, and a layer that carried its own melody would make the track sound like it
	/// was losing a part rather than relaxing.
	/// </summary>
	private static void RenderWork( float[] buffer )
	{
		for ( int bar = 0; bar < Steps / StepsPerBar; bar++ )
		{
			int barStart = bar * StepsPerBar;
			int chord = ChordAt( barStart );
			var tones = ChordTones[chord];

			// Snare on two and four.
			Snare( buffer, barStart + StepsPerBeat, 0.7f );
			Snare( buffer, barStart + StepsPerBeat * 3, 0.7f );

			// Closed hats on every off-beat sixteenth. Relentless, which is the point.
			for ( int step = 1; step < StepsPerBar; step += 2 )
				Hat( buffer, barStart + step, 0.14f, 0.03f );

			int[] shape = { 0, 1, 2, 3, 2, 1 };

			for ( int step = 0; step < StepsPerBar; step++ )
			{
				// The same arp an octave above the bed, quieter and thinner. Two octaves of the
				// same line is a chiptune staple and costs one more voice.
				int note = ChordRoots[chord] + 48 + tones[shape[step % shape.Length]];
				Note( buffer, barStart + step, 0.8f, note, 0.12f, Waveform.Pulse25, 18f );
			}
		}
	}

	/// <summary>
	/// THREAT. The lead melody, with vibrato, rising with what is hunting you.
	///
	/// Two voices: a square lead and a copy detuned slightly under it. That detune is why these
	/// leads sound wide and slightly unstable rather than thin, because two close pitches beat
	/// against each other and the ear hears one large sound instead of two small ones. The
	/// vibrato on top is what stops a held square note reading as a test tone.
	/// </summary>
	private static void RenderThreat( float[] buffer )
	{
		foreach ( var note in Lead )
		{
			int step = note[0];
			int midi = note[1];
			float length = note[2];

			Note( buffer, step, length, midi, 0.26f, Waveform.Square, 2.6f, vibrato: 0.010f );
			Note( buffer, step, length, midi, 0.19f, Waveform.Square, 2.6f,
				detune: 0.07f, vibrato: 0.008f );
		}

		// An open hat on the last eighth of every bar: a push into the next one.
		for ( int bar = 0; bar < Steps / StepsPerBar; bar++ )
			Hat( buffer, bar * StepsPerBar + 14, 0.3f, 0.11f );
	}

	/// <summary>Which of the four chords is playing at a given step. Two bars each.</summary>
	private static int ChordAt( int step )
		=> (step / (StepsPerBar * 2)) % ChordRoots.Length;

	// ---------------------------------------------------------------- the synth

	private enum Waveform
	{
		Square,
		Pulse25,
		Triangle,
		Saw,
	}

	/// <summary>Equal temperament, A4 = 440 Hz. MIDI note 69 is A4.</summary>
	private static float Frequency( int midi ) => 440f * MathF.Pow( 2f, (midi - 69) / 12f );

	private static float Shape( Waveform wave, float phase )
	{
		// `phase` is 0 to 1 across one cycle.
		switch ( wave )
		{
			case Waveform.Square: return phase < 0.5f ? 1f : -1f;
			case Waveform.Pulse25: return phase < 0.25f ? 1f : -1f;
			case Waveform.Saw: return 1f - 2f * phase;

			default:
				return phase < 0.5f ? (4f * phase - 1f) : (3f - 4f * phase);
		}
	}

	/// <summary>
	/// Renders one note into the buffer.
	/// </summary>
	/// <remarks>
	/// Writes with a WRAPPING index. A note near the end of the loop keeps ringing into the
	/// beginning, which is what makes the seam inaudible: the alternative is either cutting the
	/// tail (a click) or leaving a gap (a stutter), and both are audible every 12.8 seconds
	/// forever.
	/// </remarks>
	private static void Note( float[] buffer, int step, float lengthSteps, int midi,
		float gain, Waveform wave, float decay, float detune = 0f, float vibrato = 0f )
	{
		float frequency = Frequency( midi ) * (1f + detune * 0.01f);

		int start = (int)(step * StepSeconds * SampleRate);
		int length = (int)(lengthSteps * StepSeconds * SampleRate);

		float phase = 0f;
		float advance = frequency / SampleRate;

		for ( int i = 0; i < length; i++ )
		{
			// Exponential decay, with a short attack so nothing starts on a click.
			float t = (float)i / SampleRate;
			float envelope = MathF.Exp( -decay * t );

			const int attack = 48;
			if ( i < attack )
				envelope *= (float)i / attack;

			// VIBRATO. A held chiptune lead without it sounds like a test tone, because a square
			// wave has no natural movement of its own to fall back on. Delayed by an eighth of a
			// second so the note lands on pitch first and only then starts to sing, which is how
			// a player would actually phrase it.
			if ( vibrato > 0f )
			{
				float depth = vibrato * MathF.Min( 1f, MathF.Max( 0f, (t - 0.12f) * 6f ) );
				advance = frequency * (1f + depth * MathF.Sin( t * 6.5f * MathF.Tau )) / SampleRate;
			}

			buffer[(start + i) % LoopSamples] += Shape( wave, phase ) * envelope * gain;

			phase += advance;
			if ( phase >= 1f )
				phase -= 1f;
		}
	}

	// ---------------------------------------------------------------- the drums
	//
	// No samples, so each drum is a shape that behaves like the thing it is named after: a kick
	// is a pitch falling fast, a snare is noise plus a body tone, a hat is a very short noise.

	private static void Kick( float[] buffer, int step, float gain )
	{
		int start = (int)(step * StepSeconds * SampleRate);
		int length = (int)(0.28f * SampleRate);

		float phase = 0f;

		for ( int i = 0; i < length; i++ )
		{
			float t = (float)i / SampleRate;

			// The pitch drop is the kick. 130 Hz down to 45 Hz in about 40 milliseconds.
			float frequency = 45f + 85f * MathF.Exp( -28f * t );
			float envelope = MathF.Exp( -14f * t );

			phase += frequency / SampleRate;
			if ( phase >= 1f )
				phase -= 1f;

			buffer[(start + i) % LoopSamples] += MathF.Sin( phase * MathF.Tau ) * envelope * gain;
		}
	}

	private static void Snare( float[] buffer, int step, float gain )
	{
		int start = (int)(step * StepSeconds * SampleRate);
		int length = (int)(0.16f * SampleRate);

		uint seed = 0x5EED_1234;
		float phase = 0f;

		for ( int i = 0; i < length; i++ )
		{
			float t = (float)i / SampleRate;
			float envelope = MathF.Exp( -26f * t );

			// Noise for the rattle, plus a 190 Hz tone for the body. Noise alone reads as a
			// burst of static rather than as a drum being hit.
			float noise = NextNoise( ref seed );

			phase += 190f / SampleRate;
			if ( phase >= 1f )
				phase -= 1f;

			float body = MathF.Sin( phase * MathF.Tau ) * 0.5f;

			buffer[(start + i) % LoopSamples] += (noise * 0.8f + body) * envelope * gain;
		}
	}

	private static void Hat( float[] buffer, int step, float gain, float seconds )
	{
		int start = (int)(step * StepSeconds * SampleRate);
		int length = (int)(seconds * SampleRate);

		uint seed = 0x1A7_C0DE;
		float previous = 0f;

		// Decay scaled to the requested length, so a closed hat snaps and an open one rings.
		float decay = 4f / MathF.Max( 0.01f, seconds );

		for ( int i = 0; i < length; i++ )
		{
			float t = (float)i / SampleRate;
			float envelope = MathF.Exp( -decay * t );

			float noise = NextNoise( ref seed );

			// Crude high pass: the difference between consecutive samples. A hat is the bright
			// half of noise, and without this it sits on top of the kick instead of above it.
			float bright = noise - previous;
			previous = noise;

			buffer[(start + i) % LoopSamples] += bright * envelope * gain;
		}
	}

	/// <summary>
	/// White noise from a deterministic generator, so the track is identical every run.
	/// </summary>
	/// <remarks>
	/// Deliberately NOT <c>Game.Random</c>. That is seeded per tick and shared, so drawing from
	/// it would make the drums different on every launch and, worse, would consume from a stream
	/// that gameplay also uses.
	/// </remarks>
	private static float NextNoise( ref uint state )
	{
		// xorshift32
		state ^= state << 13;
		state ^= state >> 17;
		state ^= state << 5;

		return (state / (float)uint.MaxValue) * 2f - 1f;
	}

	// ---------------------------------------------------------------- post

	/// <summary>
	/// One pole low pass.
	///
	/// Square waves at 22 kHz alias badly, and the aliased partials are what make naive chiptune
	/// synthesis sound like grit rather than like a synth. Rolling the top off turns that into
	/// the filtered sound these tracks actually have, and costs one multiply per sample.
	/// </summary>
	private static void LowPass( float[] buffer, float cutoff )
	{
		float rc = 1f / (MathF.Tau * cutoff);
		float dt = 1f / SampleRate;
		float alpha = dt / (rc + dt);

		// Primed from the END of the buffer, because the buffer loops: starting from silence
		// would put a filter sweep at the top of every repetition.
		float value = buffer[^1];

		for ( int i = 0; i < buffer.Length; i++ )
		{
			value += alpha * (buffer[i] - value);
			buffer[i] = value;
		}
	}

	/// <summary>Scales the loudest peak to a target, so layers are balanced by design.</summary>
	private static void Normalise( float[] buffer, float peak )
	{
		float loudest = 0f;

		foreach ( float sample in buffer )
			loudest = MathF.Max( loudest, MathF.Abs( sample ) );

		if ( loudest <= 0.0001f )
			return;

		float scale = peak / loudest;

		for ( int i = 0; i < buffer.Length; i++ )
			buffer[i] *= scale;
	}

	/// <summary>Packs to 16 bit signed little endian mono.</summary>
	private static byte[] Pack( float[] buffer )
	{
		var bytes = new byte[buffer.Length * 2];

		for ( int i = 0; i < buffer.Length; i++ )
		{
			short sample = (short)(Math.Clamp( buffer[i], -1f, 1f ) * short.MaxValue);

			bytes[i * 2] = (byte)(sample & 0xFF);
			bytes[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
		}

		return bytes;
	}

	// ---------------------------------------------------------------- mixing

	/// <summary>
	/// Rides the three volumes.
	///
	/// NOT gated on the pause the way the rest of the game is. Music continuing under a menu is
	/// the point of having it, and the duck below is what tells you the world stopped.
	/// </summary>
	protected override void OnUpdate()
	{
		float wantBed = Tuning.MusicBedVolume;
		float wantWork = 0f;
		float wantThreat = 0f;

		var manager = MonolithManager.Instance;

		if ( manager != null )
		{
			// The arpeggio swells as the stage empties, so the last cubes of a stage feel like an
			// ending rather than like the middle.
			float cleared = manager.World?.Progress ?? 0f;
			wantWork = Tuning.MusicWorkVolume * (0.45f + 0.55f * cleared);

			// The lead follows what is hunting you, so the score answers to the same thing the
			// player is answering to.
			wantThreat = Tuning.MusicThreatVolume
				* Math.Clamp( HazardPressure() / Tuning.MusicThreatFullAt, 0f, 1f );
		}

		// Ducked hard while paused. A menu over a full mix reads as the game still running, which
		// is exactly the impression the pause exists to dispel.
		if ( GameTime.Paused )
		{
			wantWork *= 0.1f;
			wantThreat *= 0f;
			wantBed *= 0.4f;
		}

		float master = AudioSettings.MusicVolume;

		wantBed *= master;
		wantWork *= master;
		wantThreat *= master;

		// Real delta, not game delta: these must keep moving while the world is frozen or the
		// duck would never arrive.
		float rate = Time.Delta * Tuning.MusicFadeRate;

		bedLevel = MathX.Lerp( bedLevel, wantBed, rate );
		workLevel = MathX.Lerp( workLevel, wantWork, rate );
		threatLevel = MathX.Lerp( threatLevel, wantThreat, rate );

		Apply( bed, bedLevel );
		Apply( work, workLevel );
		Apply( threat, threatLevel );
	}

	/// <summary>How much is currently hunting the player, as a weighted count of live hazards.</summary>
	private static float HazardPressure()
	{
		float pressure = 0f;

		try
		{
			pressure += Spotter.All.Count * 1.5f;
			pressure += Sentinel.All.Count;
			pressure += Crawler.All.Count * 1.5f;
			pressure += Interceptor.All.Count * 0.5f;
			pressure += Leech.All.Count * 0.75f;
			pressure += Anchor.All.Count * 0.5f;
		}
		catch ( Exception )
		{
		}

		return pressure;
	}

	private static void Apply( SoundHandle handle, float volume )
	{
		try
		{
			if ( handle != null )
				handle.Volume = volume;
		}
		catch ( Exception )
		{
		}
	}
}