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&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 )
{
}
}
}