Home

2024

Worklog

LETSGO Game

Programming Music: Constant Data
🔢

Programming Music: Constant Data

Tags
UnrealResourceDesign
Owner
J
Justin Nearing
🎶
This is part of a ongoing series called Building A Music EngineBuilding A Music Engine

It documents the process of me smashing my head against the keyboard to build a game called LETSGOLETSGO

It’s gotten long enough to break into several sections:

Programming Clear Rules

When designing software, it’s critical to think in terms of data.

If you know what data you have, and what data you need, it becomes clear how your program must manipulate input data to create output data.

And by starting with constant data- the stuff you know will not change- you have a natural starting point for your software design.

In western music theory, notes are separated into octaves by 12 even steps.

This is technically out of tune:

Thankfully for us it doesn’t matter, because this form of tuning is constant.

Those constants give us 12 notes, labeled A → G, with some funny ♯ and ♭ symbols thrown in to get us 12 notes.

My first stab at designing a music theory engine is to capture these as constants:

// For now we are only concerned with 12TET traditional western music
enum Notes {
	A,
	B,
	C,
	D,
	E,
	F,
	G,
	CSHARP,
	DSHARP,
	FSHARP,
	GSHARP,
	ASHARP,
	DFLAT,
	EFLAT,
	GFLAT,
	AFLAT,
	BFLAT
};

An enum (enumeration) gives us a symbolic representation of a set of related values.

These notes are related, and the enum gives us a intuitive way to understand the data of our program:

Notes tonic = A;

The “tonic” here means that A will be our “home” key. The vast majority of music that culturally western people hear are structured around a tonic note.

💡
Quick note, by “culturally western” I am focusing specifically on music, created in the traditional European music system, using 12 tone equal temperament. If you grew up listening to music made in this system, you are culturally western for the purposes of this exercise.

Given these notes, we can start to map out different scales:

struct Scale {
	std::string name;
	Notes notes[7]; // Use the Notes enum type

	Scale(const std::string& scaleName, const Notes(&scaleNotes)[7]) : name(scaleName)
	{
		for (int i = 0; i < 7; i++) {
			notes[i] = scaleNotes[i];
		}
	}
};

Scale cMajor("C Major", { C, D, E, F, G, A, B });
Scale cMinor("C Minor", { C, D, EFLAT, F, G, AFLAT, BFLAT });
Scale dMajor("D Major", { D, E, FSHARP, G, A, B, CSHARP });
Scale dMinor("D Minor", { D, E, F, G, A, BFLAT, C });
Scale eMajor("E Major", { E, FSHARP, GSHARP, A, B, CSHARP, DSHARP });
Scale eMinor("E Minor", { E, FSHARP, G, A, B, C, D });
Scale fMajor("F Major", { F, G, A, BFLAT, C, D, E });
Scale fMinor("F Minor", { F, G, AFLAT, BFLAT, C, DFLAT, EFLAT });
Scale gMajor("G Major", { G, A, B, C, D, E, FSHARP });
Scale gMinor("G Minor", { G, A, BFLAT, C, D, EFLAT, F });
Scale aMajor("A Major", { A, B, CSHARP, D, E, FSHARP, GSHARP });
Scale aMinor("A Minor", { A, B, C, D, E, F, G });
Scale bMajor("B Major", { B, CSHARP, DSHARP, E, FSHARP, GSHARP, ASHARP });
Scale bMinor("B Minor", { B, CSHARP, D, E, FSHARP, G, A });

Complexity of Scales

This implementation isn’t bad as a first random stab at organizing the data.

But we’re missing all those ♯ and ♭ (called accidentals)- such as bFlatMinor (B♭, C, D♭, E♭, F, G♭, A♭)

Manually mapping each one gets problematic. I'd have to double this list for those sharps and flats, and hope I didn’t copy anything down incorrectly.

Things get especially problematic since Major and Minor are only two types of “modes”. There are 5 other modes- and that’s just in the Greek(?) Church(?) Western Triadic(?) tradition:

  1. Ionian (Major)
  2. Dorian
  3. Phrygian
  4. Lydian
  5. Mixolydian
  6. Aeolian (Minor)
  7. Locrian

If I wanted to support these modes in the future, I have to map each one out- so that giant list multiplied seven times. In terms of organizing my data, this doesn’t feel like a great approach.

The good news is, each note in a scale is being mathematically derived.

Dead simple math too.

It turns out computers are really fast at doing simple math.

My next attempt is to codify the rules by which the notes can be derived:

enum Steps {
	WHOLE = 2,
	HALF = 1
};

const struct Mode {
	std::string name;
	Steps steps[7];

	Mode(const std::string& modeName, const Steps(&modeSteps)[7]) : name(modeName)
	{
		for (int i = 0; i < 7; i++) {
			steps[i] = modeSteps[i];
		}
	}
};

// Given what we've defined above, we can define: 
Mode ionian(	"Ionian",		{ WHOLE, WHOLE, HALF, WHOLE, WHOLE, WHOLE, HALF });

A “Mode” is a pattern for generating a scale. It consists of seven “steps” (sorry Brits, we’re not calling them tones here). Whether you go a whole step (C → D) or half step (D → D#) is what defines the mode.

The Ionian pattern of whole/half steps is the constant rule for this scale. Whole steps means you jump 2 notes, half means you jump 1 note.

Consider the following array:

//No B# - it's the same as C so we just use C. 
// It gets weird if you think about it too long
const Notes musical_notes[] = { A, ASHARP, B, C, CSHARP, D, DSHARP, E, F, FSHARP, G, GSHARP, };

Tonic is A

Ionian has the pattern WHOLE, WHOLE, Half, etc.

  • Start with A
  • Move up 2 notes (WHOLE) = B
  • Move up 2 notes (WHOLE) = CSHARP
  • Move up 1 note (HALF) = D
  • Etc.

And this gives us the A Major scale (A, B, C♯, D, E, F♯, G♯). Doesn’t matter which key you start with, the step-pattern will always give you the correct notes for the scale.

This approach is better than our initial one, because we have a better ability to derive the notes of the scale.

I realized after the fact that one problem with the above implementation is that it assumes a 7 note scale. This excludes 5 note pentatonic scales, or the whole 12 note chromatic scale.

This exposes one of the dangers of working with constant data: expressing something that should be dynamic as a constant can have long term consequences.

💡
Now theres going to be some gotchas here. For simplicity I’ve only really dealt here with the natural notes (A, not A♯ or A♭). Western music theory has complex rules for whether you notate a note as sharp or flat.

In essence notes must be in alphabetical order at all times, but the first letter of the alphabet is the tonic… It starts generating weird edge cases with odd accidentals like 𝄫 and 𝄪

I’ll have to learn the particulars of this and act accordingly, but it’s no where near the critical path for making a music game.

The Critical Path to Something Playable

The point is, using this system we could imagine the implementation of an improvement to our game so far:

  1. At the beginning of play, a tonic and a mode are randomly selected
  2. Generate the scale for that tonic
  3. Each time a Music Platform is spawned, the note for that platform is the next whole/half step for that mode.

So the first music platform might randomly choose a A as the tonic.

The next spawned platform would show B

Then C#, D, E, F#, G#, back to A.

This only works if the notes provided are in the correct order. In the Notes enum we initially created, we listed A-G, then all supported sharps and flats.

But from a sound perspective we only really have the 12 notes.

I expect this is a somewhat thorny issue. On Abletons piano roll, it only sharpens.

C, C#, D, D#, E, F, F#, G, G#, A, A#, B - All the black keys are sharp
C, C#, D, D#, E, F, F#, G, G#, A, A#, B - All the black keys are sharp

The good news is that this gives us a canonical ordered list:

[ C, C#, D, D#, E, F, F#, G, G#, A, A#, B ]

A + 2 = B, B + 2 wraps around to C#, and so on.

We can traverse the array using our Modes step pattern. A → B → C# → etc.

The note we display might be incorrect, but in theory the sound will be correct and that should be considered our critical path.

There is probably a way to calculate the correct notation (D♭ and C are the same note, but notated differently based on alchemic principles of music notation, and the context of the tonic).

But that calculation is in the weeds. I just want to hear the correct sound, bruh.

💡
Note from the future: The enum notes I eventually followed follows the circle of fifths:
// For now we are only concerned with 12TET traditional western music
// Named by circle of fifths chart. Not A#, Bb. Notation is bonkers.
// This is an ordered list. That might have consequences down the road.
UENUM(BlueprintType, Category = LetsGoBlueprintCategory)
enum ELetsGoMusicNotes : uint8 {
	C		UMETA(DisplayName = "C"),
	CSharp	UMETA(DisplayName = "C♯"),
	D		UMETA(DisplayName = "D"),
	EFlat	UMETA(DisplayName = "E♭"),
	E		UMETA(DisplayName = "E"),
	F		UMETA(DisplayName = "F"),
	FSharp	UMETA(DisplayName = "F♯"),
	G		UMETA(DisplayName = "G"),
	AFlat	UMETA(DisplayName = "A♭"),
	A		UMETA(DisplayName = "A"),
	BFlat	UMETA(DisplayName = "B♭"),
	B		UMETA(DisplayName = "B"),
};

I might change it to Gb instead of F#, that one seems more 50/50 than other notes?

So loosely we want something that does:

int main() {
    Notes tonic = A;
    Notes currentNote = A;
    Mode  mode = dorian; 
		
		for (int i = 0; i < 9; i++) {
			// print currentNote
			// currentNote = getNextNoteInMode(tonic, currentNote, dorian) 
		}
}

Good ol Chat Jippity gives us the following:

// find currentNote index in musical_notes[]
// set stepIncrement - from tonic and mode, determine if the next note is a WHOLE (stepIncrement == 2) or a HALF (stepIncrement == 1)
// return musical_notes[current_note + stepIncrement]
Notes getNextNoteInMode(const Notes& tonic, const Notes& currentNote, const Mode& mode) {
	// Find the index of the currentNote in the musical_notes array
	int currentNoteIndex = -1;
	for (int i = 0; i < sizeof(musical_notes) / sizeof(musical_notes[0]); i++) {
		if (musical_notes[i] == currentNote) {
			currentNoteIndex = i;
			break;
		}
	}

	if (currentNoteIndex == -1) {
		return tonic;
	}

	// Determine the stepIncrement based on the mode
	Steps stepIncrement = mode.steps[currentNoteIndex % 7];

	// Calculate the index of the next note
	int nextNoteIndex = (currentNoteIndex + stepIncrement) % 12;

	// Return the next note from the musical_notes array
	return musical_notes[nextNoteIndex];
}

This seems like a reasonable first pass at jumping around a scale array.

But this is untested, bringing us neatly to the next sticking point.

Ain’t No main() Here

Now we must figure out how to make this an Unreal function… not to mention make our enums recognized in Unreal…

In blueprints, I want to select a random mode and tonic and have the Spawner spit out the next note in that modes scale.

Which means figuring out how to surface these things in Unreal Blueprints.

It turns this is a #wholething.

In the next section, we’ll start exploring the vagaries of C++ development in Unreal.

Surely previous programming experience will translate neatly into to learning C++.

Naturally Unreal won’t have its own deep, poorly documented flavor of C++.

image

💥Unreal C++