We saw last time that it is simple to construct a MIDI file with the pretty_midi
package. Now to make something a little more musically interesting than alternating between two notes we need to randomly generate notes to play using some basic music theory to make things sound “good.” To do this we will:
- choose a scale
- find all of the midi notes attached to that scale
- randomly draw notes from that collection of midi notes
- get a pleasing collection of simple chords to accompany the melody
- vary the rhythm of the melody
Pick a Scale
First we use a couple of nice utilities of pretty_midi
, a method which takes an integer and spits out one of the major and minor keys and another method which takes that same integer and returns how many accidentals the key has. Using the key name we can determine whether the key has flat accidentals or sharp accidentals and then by using the fact that “accidentals accumulate” (if a key has a G# then it also has a C#, for example) we can easily identify which notes are in the key.
To say more, we are using the fact that the Major and Minor keys have certain nice patterns. If we start from C and move up in perfect 5ths (C, G, D, A) then those corresponding (major) keys each add an accidental:
- C Major: CDEFGAB
- G Major: GABCDEF#
- D Major: DEF#GABC#
- A Major: ABC#DEF#G#
- …
This means we know the exact notes that are made sharp or (flat) if we know if the key is sharp (flat) and how many accidentals there are, we do not even need to know the root: we are being told the same information in a different way. Now, there is actually a more insightful way to do this (how making major scales is normally taught) by using the fact that you start at the root and add notes with the pattern WWHWWWH, but it was fun to think of a different way to get the notes.
We return the key_notes
which runs from C to B because this is how the MIDI format is laid out for each octave (… B1 C2 C#2 … A#2 B2 C3 …), but we also save off the notes of the key starting from the root for (optional) printing to the user as scale_notes
.
= [n for n in 'CDEFGAB']
note_names = [n for n in 'FCGDAEB']
sharp_accidentals = [n for n in 'BEADGCF']
flat_accidentals
def determine_key_notes(key_number):
= pretty_midi.key_number_to_key_name(key_number)
key_name = pretty_midi.key_number_to_mode_accidentals(key_number)
_, num_accidentals = key_name[0]
root = note_names.index(root)
root_index
if key_name[1] == "b":
= 'b'
accidental_mark = flat_accidentals[:num_accidentals]
accidentals else:
= '#'
accidental_mark = sharp_accidentals[:num_accidentals]
accidentals
= list(map(lambda n: n + accidental_mark if n in accidentals else n, note_names))
key_notes
= (key_notes + key_notes)[root_index:root_index + 7] scale_notes
Find All MIDI Notes Given Key Notes
For this, use the pretty_midi
utility that converts note names to MIDI note numbers and apply it to all the note names with all the octave numbers attached.
def get_all_midi_numbers(note_names):
= []
midi_numbers = list(set(note_names)) # simple de-dup
note_names for octave in range(-1, 9):
for note in note_names:
= pretty_midi.note_name_to_number(note + str(octave))
midi_number
midi_numbers.append(midi_number)return sorted(midi_numbers)
We do not need to de-duplicate or sort for our current use, but I added those steps in case I supply some note names “out of order” ([D, C]
instead of [C, D]
for instance) or provide possible note duplicates for other uses.
Randomly Draw Notes
Here you can now use the collection of midi notes and just make a random choice from it at each step. For instance if g_maj_midi
is the collection of MIDI notes for G Major then you can randomly select one with pitch = random.choice(g_maj_midi[21:36])
where we take a slice of the array to restrict the notes to just a couple of octaves.
Chord Accompaniment
We want to get just simple chords from the major scale for the piano to play them for whole notes. There are some existing Python packages that can be used:
- chords2midi let’s you generate a MIDI file by supplying a progressing an a key:
c2m I V vi IV --key C
chords2midi
uses pychord, which will generate the component notes of a chord from its name as well as name a chord from its notes.
These are both strong utilities that I will certainly use as I expand but for now I will do something much simpler. You can get a major scale chord progression by simply going to the scale and taking a note for the root and getting the third and fifth of a triad by just taking the second and fourth notes after your root. For instance, you can get a C (major) triad from the C major scale by looking at the scale CDEFGAB and using that pattern C_E_G__. Whether this is major or minor is irrelevant for our use: we just want to grab all the simple triads (not worrying about inversions or anything) and collect them together for one octave of root notes:
def get_major_progression(root_index, midi_numbers):
= []
chords for i in range(8):
= root_index + i
chord_root_index = chord_root_index + 2
chord_third_index = chord_root_index + 4
chord_fifth_index = midi_numbers[chord_root_index]
root = midi_numbers[chord_third_index]
third = midi_numbers[chord_fifth_index]
fifth = [root, third, fifth]
chord
chords.append(chord)return chords
This will generate a list of chord lists, where each chord list is the midi notes for a given triad. Note, if you do not feed the notes of a key as the MIDI numbers you will not get a major key progression, so this might be imperfectly named.
Vary the Rhythm
When we were adding notes one at a time we were keeping track of when the note began and when it ended in seconds. It would be nice to forget about when they begin and imagine writing the song and moving forward, adding notes “now.” To do this we make a writer for our instrument that keeps track of when “now” is and allows us to add notes one at a time, as a chord, or in a chunk of notes. Using this chunking allows us to vary the rhythm, we can randomly determine how long the next note(s) should be and then play a bunch of notes of that length. This is slightly more natural than varying each note independently, because shorter notes are often grouped together.
class Writer:
def __init__(self, instrument):
self.position = 0
self.instrument = instrument
def add_note(self, pitch, length, move_forward=True):
= pretty_midi.Note(velocity=100, pitch=pitch, start=self.position, end=self.position + length)
note self.instrument.notes.append(note)
if move_forward:
self.position += length
def add_note_series(self, pitches, length):
for pitch in pitches:
self.add_note(pitch, length)
def add_chord(self, pitches, length, move_forward=True):
for pitch in pitches:
self.add_note(pitch, length, move_forward=False)
if move_forward:
self.position += length
We leave move_forward
as an optional argument, because if we are writing notes that will occur at the same time (to form a chord) then we want the Writer “head” or position to stay at the same spot until we are done adding notes to that point in time. There are other simplifications that are made (no velocity changes), but this simple class gives us a lot of power so that we can more expressively generate midi music.
import pretty_midi
from music_info import determine_key_notes
import music_info
import random
# Create a PrettyMIDI object
= pretty_midi.PrettyMIDI()
ensemble
# Create an Instrument instance for a cello instrument
# Changed to a guitar for my song, and was lazy about changing variable names
# cello_program = pretty_midi.instrument_name_to_program('Cello')
= pretty_midi.instrument_name_to_program('Overdriven Guitar')
cello_program = pretty_midi.Instrument(program=cello_program)
cello
# do the same for a piano
= pretty_midi.instrument_name_to_program('Acoustic Grand Piano')
piano_program = pretty_midi.Instrument(program=piano_program)
piano
# Add the instruments to the PrettyMIDI object
ensemble.instruments.append(cello)
ensemble.instruments.append(piano)
# here is where I put the Writer class from above
= Writer(piano)
piano_writer = Writer(cello)
cello_writer
= 30
song_length_in_seconds = 120
bpm = 60 / bpm
beat_length = int(song_length_in_seconds / beat_length)
num_beats
# Decided on G major, which is index 7
= music_info.determine_key_notes(7)
g_maj = music_info.get_all_midi_numbers(g_maj)
g_maj_midi = pretty_midi.note_name_to_number("G4")
root_number = g_maj_midi.index(root_number)
root_index = music_info.get_major_progression(root_index, g_maj_midi)
major_progression
= piano_writer
accompaniment_writer = cello_writer
solo_writer
# now we see the power of the writer object
while accompaniment_writer.position < song_length_in_seconds:
= beat_length * 4 # whole note chords
length
# choose a random chord from the progression
= random.choice(major_progression)
chord
# add a lower octave of the notes for fullness
= chord + [n - 12 for n in chord]
larger_chord
# write the chord
accompaniment_writer.add_chord(larger_chord, length)
while solo_writer.position < song_length_in_seconds:
# choose to play 16th, 8th, quarter, half, or whole note(s)
= random.choice([-2, -1, 0, 1, 2])
division = beat_length * (2 ** division)
length
# if we chose 16th or 8th, play multiple of them
if division < 0:
= 2 ** (-1 * division)
num_notes = random.choices(g_maj_midi[21:36], k=num_notes)
pitches
solo_writer.add_note_series(pitches, length)else:
= random.choice(g_maj_midi[21:36])
pitch =beat_length)
solo_writer.add_note(pitch, length
def write_song():
# Write out the MIDI data
'duo.mid') ensemble.write(
Now with this I created the following simple duet. It isn’t the most thrilling piece of music, but with relatively simple rules behind it, I think it is interesting how “complex” it sounds.