I'll be the first to admit that music theory is pretty complex. Eventhough it's hard to develop an intuition regarding fundamental concepts without access to proper tools, we can create tools to help us visualize and understand these concepts in a way traditional methods might miss. Let's dive right into it.

Getting acquainted with the MIDI

The Musical Instrument Digital Interface (MIDI) protocol is a standard to transmit and store music. It encodes musical notes, timings, pitch information, and velocity in a .mid file, which is then decoded by a MIDI player.

A musical note is represented by a number between 0 and 127, where note number 60 is called 'Middle C' (a convention that isn't of any use to us). An increase in the note number represents a semitone increase. So, instead of dealing with abstract note names like C and C#, MIDI uses numbers from 0 to 127.

Instead of looking up what each note sounds like, let's whip up a program to generate a track with notes 0 to 127.

Environment

First, let's create a workspace and install the midiutil package.

$ mkdir music_simulation
$ cd music_simulation
$ python3 -m pip install midiutil

You'll also need to install a MIDI player to play .mid files. I'm using timidity on Linux, though any decent MIDI player should suffice for the purpose of this project.

Visualizing notes

from midiutil import MIDIFile

degrees = [i for i in range(128)] # initialize a list of 0..127

def generate_midifile():
    midifile = MIDIFile(1)
    time, track = 0, 0
    midifile.addTempo(track, time, 60)
    for pitch in degrees: # going from 0..127
        midifile.addNote(track, 0, pitch, time, 1, 100) # writes pitch at specified time value
        time += 0.5

    with open("scale.mid", 'wb') as file:
        midifile.writeFile(file)

if __name__ == "__main__":
    generate_midifile()

This creates a note for every value between 0 and 127 inclusive and saves them in scale.mid. Run scale.mid using your midi-player and notice the semi-tonal increase every 0.5 seconds.

Understanding scales

A scale is like a palette of musical colors that work well together. Just as artists don't use every possible color in a single painting, musicians don't typically use all possible notes in a single piece of music (feel free to experiment with it!)

A half-step is a single semitone increase (+1 in MIDI) and a whole-step is a 2 semitone increase (+2 in MIDI).

We'll focus on the C Major, the most popular of the major scales in Western music, for now:

  1. \(root\text{ }note = C_{60}\)
  2. \(C_{60} + W_{2} → D_{62}\)
  3. \(D_{62} + W_{2} → E_{64}\)
  4. \(E_{64} + H_{1} → F_{65}\)
  5. \(F_{65} + W_{2} → G_{67}\)
  6. \(G_{67} + W_{2} → A_{69}\)
  7. \(A_{69} + W_{2} → B_{71}\)
  8. \(B_{71} + H_{1} → C_{72}\)

The specific pattern of whole steps (W) and half steps (H) - W W H W W W H - is what makes a major scale sound 'major'. It's a cyclical pattern that takes us back to the root note (C). Here's how we encode this pattern:

WHOLE = 2 # 2 semitones
HALF = 1 # 1 semitone
MAJOR_SCALE = [2, 2, 1, 2, 2, 2, 1] # W-W-H-W-W-W-H

def create_scale(root, intervals):
    scale = [root] # first element is the root
    curr_note = root
    for interval in intervals:
        curr_note += interval
        scale.append(curr_note) # appends the new note to the scale
    return scale

c_major_scale = create_scale(60, MAJOR_SCALE) # returns a key scale

The key-specific scale is generated by creating offsets of the root note, which was 'C' in this case.

Creating Melodies

Now that we understand scales, let's implement a simple, random melody generator using a given scale.

from random import choice

def generate_melody(root: int, interval: List[int], duration: int):
    scale = create_scale(root, interval)
    melody = [] # initializing an empty list of notes
    possible_durations = [2, 1] # double and single semitone increase
    duration_count = 0

    while duration_count < duration:
        note = choice(scale) # randomly selects note from scale
        duration_choice = choice(possible_durations) # randomly selects duration from [2, 1]
        if duration_count + duration_choice <= duration:
            melody.append((note, duration))
            duration_count += duration_choice
            return melody

def save_melody(melody: List[Tuple[int, int]]) -> None:
    midifile = MIDIFile(1)
    time = 0 # vary this
    for note, duration in melody:
        midifile.addNote(0, 0, note, time, duration, 100) # vary velocity
        time += duration

    # file handling
    with open("midi_files/test1.mid", 'wb') as file:
        midifile.writeFile(file)

    print("Melody saved at location 'midi_files/test1.mid'")

To create your first algorithmic melody, add the following piece of code to your program:

C = 60
MAJOR_SCALE = [2, 2, 1, 2, 2, 2, 1]
if __name__ == "__main__":
    save_melody(generate_melody(C, MAJOR_SCALE))

Try modifying scale values or trying out different root notes! You're here to learn, not to follow along blindly.

Keywords

The naivety of this approach

There's a glaring lack of musical context when randomly selecting notes from a scale, as it fails to take into consideration the relationship between notes, which can result in melodies that feel disjointed and lack a coherrent musical narrative. Though, it does try to build a harmonious narrative by using notes from the same scale. Furthermore, the generated melody can sound unnatural because it fails to account for common rhythmic patterns, instead choosing to randomly select notes from the scale. Try experimenting with weighted distribution-based sampling for a more natural sound. Finally, the approach is limited to generating monophonic (single-voice) melodies, whereas much of the richness in music comes from the graceful interplay of multiple melodic lines and harmonies.

Moving forward

To overcome the deficits of the naive approach, we'll implement a melody generator using Markov chains. Without going too deep into it, we'll analyze a corpus of renowned compositions to build a Markov transition matrix, which stores the probabilities of transitioning from one note to another. We'll probabilistically generate new pieces and apply some additonal rules to follow conventional music patterns (e.g. avoiding unnatural pauses, maintaining a sense of tonality, etc.).