Game development and more

Generate cantus firmus with Python

Introduction

Python can be used for different types of things, including writing computer generated music. Since picking totally random notes and playing them back doesn't sound good, we need some formalized rules.

Cantus firmus (cantus firmi in plural) is the term for the melodies existing in Medieval European music tradition that were used by composers as a base for writing polyphonic compositions. But the cantus firmi themselves were written based on a strict set of rules. This gives us the ability to generate such melodies programmatically.

I've created an algorythm that generates a cantus firmus and plays back the result using python-rtmidi library. Source code is available on Github.

Theory

As a source of theoretical material I used this page. We'll navigate straight to the section that lists the rules.

Rules:

  1. length of about 8–16 notes
  2. arhythmic (all whole notes; no long or short notes)
  3. begin and end on do
  4. approach final tonic by step (usually re–do, sometimes ti–do)
  5. all note-to-note progressions are melodic consonances
  6. range (interval between lowest and highest notes) of no more than a tenth, usually less than an octave
  7. a single climax (high point) that appears only once in the melody
  8. clear logical connection and smooth shape from beginning to climax to ending
  9. mostly stepwise motion, but with some leaps (mostly small leaps)
  10. no repetition of “motives” or “licks”
  11. any large leaps (fourth or larger) are followed by step in opposite direction
  12. no more than two leaps in a row; no consecutive leaps in the same direction (Fux’s F-major cantus is an exception, where the back-to-back descending leaps outline a consonant triad.)
  13. the leading tone progresses to the tonic
  14. in minor, the leading tone only appears in the penultimate bar; the raised submediant is only used when progressing to that leading tone

WARNING: I don't have formal musical education, so my interpretation of the rules might be incorrect.

Writing notes in code

First, we need to define data format to store the notes.

Cantus firmus is built on a diatonic scale which always consist of seven steps, so we won't be using any chromatic notes. The key and the octave in which it is played doesn't matter (during the generation phase). We won't need to store note durations either, because the notes are always evenly spaced in time. Also, the velocity of the notes is not important.

The information we need in order to store the note sequence is:

  • the place of the note on the scale (scale degree);
  • the position of the note in the sequence of the notes.

We'll store the place on the scale as an integer relative to the tonic. For example, in the key of C major number 0 will correspond to the note "C", number 2 - to the note "E".

Number 7 in C major will mean "note C one octave up" and number -1 will mean "note B one octave down".

The sequence of notes can be stored as Python lists:

# In C major this would represent C, D, E, F, G, B.
[0, 1, 2, 3, 4, 5, 6]

The algorithm

Since modern computers are very fast, I didn't think about speed optimizations at all. It is a simple bruteforce algorithm (for the most part):

  1. Generate a random sequence of numbers (scale degrees).
  2. Check if it matches the rules.
  3. If it doesn't, go back to step one.
  4. Repeat until the sequence does match the rules.

Step one isn't completely random, though. Let's take a look on the rules again and see what can be done before we generate a sequence.

Pre-generation rules

1. length of about 8–16 notes

Just run a loop 8 times to generate 8-note sequence, 9 times to generate 9-note sequence, etc.

notes = []
for i in range(0, 8):
    note = make_note()
    notes.append(note)

2. arhythmic (all whole notes; no long or short notes)

That's already handled since we don't store note durations or pauses.

3. begin and end on do

Since we want our melody to be played in any key, not just do, we'll always start from the tonic. That means that the initial sequence will look like this:

notes = [0, ]
for i in range(1, 8):
    note = make_note()
    notes.append(note)

After the sequence is generated, we'll check if it landed on 0.

5. all note-to-note progressions are melodic consonances

Melodic consonanses are the following intervals: octave, perfect 4th and 5th, major and minor thirds and major and minor seconds. Since we don't care if it's major or minor we can store those intervals as integer numbers. In cantus firmus, we won't need intervals greater than that.

def make_note(previous_note):
    intervals = (1, 2, 3)
    interval = random.choice(intervals)
    direction = random.choice((1, -1))
    note = previous_note + interval * direction

11. any large leaps (fourth or larger) are followed by step in opposite direction

Let's store the interval and the direction every time we generate a note, and check them on the next iteration. Though it can be checked after the sequence is generated, it's very easy to do inside the cycle.

def make_note(previous_note, previous_interval, previous_direction):
    intervals = (1, 2, 3)
    interval = random.choice(intervals)

    if abs(previous_interval) >= 3:
        direction = previous_direction * -1
    else:
        direction = random.choice((1, -1))

    note = previous_note + interval * direction

    previous_interval = interval
    previous_direction = direction

12. no more than two leaps in a row; no consecutive leaps in the same direction

To check if there were two leaps in a row, we need to store two previous intervals.

abs(previous_interval) >= 3 and abs(pre_previous_interval) >= 3:
    intervals = (1, 2)
else:
    intervals = (1, 2, 3)
interval = random.choice(intervals)

Post-generation rules

We have enough rules to generate a sequence, though it isn't a correct cantus firmus yet. Let's run some checks, and if at least one doesn't pass, we'll generate a new sequence.

Most of the rules are pretty obvious:

3. begin and end on do

We just check the last number in the sequence, and if it's not 0 we return False.

def check_rule_3(notes):
    return notes[-1] == 0

Other rules need more attention, namely rules 7, 8 and 10.

7. a single climax (high point) that appears only once in the melody

Climax is the highest note in the sequence, that means it is the largest number. We make sure it's encountered only once:

def check_rule_7(notes):
    return notes.count(max(notes)) == 1

Here's the problem: it may happen that the highest note would be 0, and the rest of the sequence would go below that. Such melody won't sound right. I haven't found any rule how high the climax should be in cantus firmus, so let's assume it should be on the 4th degree and higher:

def check_rule_7(notes):
    climax = max(notes)
    return notes.count(climax) == 1 and climax > 3

Also we want to check that the climax is at least in the middle of the sequence:

def check_rule_7(notes):
    climax = max(notes)
    return notes.count(climax) == 1 and climax > 3 and notes.index(climax) > len(notes) // 2

8. clear logical connection and smooth shape from beginning to climax to ending

"Clear logical connection" and "smooth shape" are pretty vague phrases. I decided to define it as "having at least 3 consecutive steps in one direction (up or down)":

def check_rule_8(notes, directions):
    num_steps = 3
    up_slope = [1] * num_steps
    down_slope = [-1] * num_steps

    up_slopes_count = 0
    down_slopes_count = 0

    for i in range(0, len(notes) - num_steps):
        if directions[i:i + num_steps] == up_slope:
            up_slopes_count += 1
        elif directions[i:i + num_steps] == down_slope:
            down_slopes_count += 1

    return up_slopes_count > 0 or down_slopes_count > 0

I tried to increase number of consecutive steps, and this is where I've encountered the downsides of the naive bruteforce algorithm:

  1. Sometimes it would take too long to generate (millions of iterations).
  2. Sometimes such sequence would be mathematically impossible. Because rules are not dependant on each other, the program can't detect it and just freezes.

10. no repetition of “motives” or “licks”

Motive is a sequence of 2 notes. So if we have at least one repeated motive, the check is failed.

def check_rule_10(notes):
    motive_length = 2

    # Get all possible motives
    motives = []
    for i in range(0, len(notes) - motive_length + 1):
        motive = notes[i:i + motive_length]
        motives.append(motive)

    # Count occurences of each motive
    for entry in motives:
        if motives.count(entry) > 1:
            return False

    return True

13. the leading tone progresses to the tonic

This rule is similar to the rules 3 and 4, we just check if degree -1 comes only before degree 0, and degree 6 - only before degree 7. Leading tone appears in natural major and we don't use any altered scales in this exercise. So this check should be run only if the melody is in major.

14. in minor, the leading tone only appears in the penultimate bar; the raised submediant is only used when progressing to that leading tone

Since I didn't use altered scales, the leading tone won't appear in minor at all. This rule can be skipped.

Playing it back

To play the melody through the system MIDI device I used python-rtmidi library. It's more low-level than mido, for example, but good enough for our purposes. I've included a Device class in common/device.py file. It makes it easier to select an instrument and play notes through the MIDI device.

First, we need to convert scale degrees to tones (which are numbers from 0 to 11). For this we'll define a major and a minor scale:

MAJOR_SCALE = (0, 2, 4, 5, 7, 9, 11)
MINOR_SCALE = (0, 2, 3, 5, 7, 8, 10)

Each number shows how many tones above the tonic we should go to get to the right note, with degree being the index. The middle C in general MIDI standard is 60. If we want to play the sequence from the middle C, we add it to the note value:

base_note = 60 # Middle C
for degree in notes:
    final_note = base_note + scale[degree]

Don't forget that degree can be higher than 6. In this case we need to wrap it around and add another octave (12 tones):

if degree > 6:
    final_note = base_note + scale[degree - 7] + 12

A similar case if the degree is smaller than 0:

if degree < 0:
    final_note = base_note + scale[degree + 7] - 12

The range of cantus firmus shouldn't exceed 1 octave (12 tones) and we are not checking if the note is more than one octave above or below.

Then we just send the note to the MIDI device.

channel = 0
for note in notes:
    device.note_on(channel, note)
    # Hold the note for 1 second
    time.sleep(1)
    device.note_off(channel, note)

Source code

I've refactored the code from the article, placed it into a class and added some utility functions. The source can be found on Github in 01_cantus_firmus.py file. Run it as python 01_cantus_firmus.py. You can also provide the length of the sequence, the base note and the scale (major or minor). E.g. the following command:

python 01_cantus_firmus.py -l 15 -b 48 --minor

will generate a melody in A minor.


Share Back to main