Chapter 5 · Relative Pitch and Scales

In Chapter 4 you let generators fill the rhythm for you; now we do the same for pitch — working in keys and scale degrees instead of raw MIDI numbers, and snapping generated lines onto a scale so they always sound in key.

Up to now every pitch has been an exact number: notes.C2 is 36, and 36 is always the same semitone. That’s fine when you know precisely which note you want. But musicians rarely think “play MIDI 43” — they think “play the fifth, in the key we’re in.” This chapter teaches Subsequence to think that way too: you describe a key and a scale, generate or hand-write a line, and let the library keep every note diatonic — most of the time, with the occasional chromatic passing tone when you ask for one. That shift, from absolute numbers to pitches-relative-to-a-key, is the groundwork everything in Part IV (harmony) is built on.

5.1 Keys and scale degrees

A key is a home note — "C", "F#", "Bb" — and a scale (or mode) is the set of steps you’re allowed to walk away from it. Together they pick seven (or five, for a pentatonic) pitch classes out of the twelve, and those are the notes that sound “in tune” with each other. A scale degree then names a note by its position in that set rather than its absolute pitch: the 1st degree is the root, the 5th is the fifth, and so on. “The fifth in C” is G; “the fifth in E♭” is B♭ — same degree, different note. That indirection is the whole point.

You can hand Subsequence a key right at the top, when you create the Composition:

import subsequence
import subsequence.constants.midi_notes as notes

composition = subsequence.Composition(bpm=120, key="C", scale="minor")

The two new arguments are written in plain musical language:

Argument

What it does

key="C"

The home note of the piece, as a note name ("C", "F#", "Bb"). No octave — a key is a pitch class, not one specific C.

scale="minor"

The scale/mode that defines which degrees are in key. Defaults to major ("ionian") when you leave it out.

Setting key and scale on the Composition records the home the piece lives in. On its own it changes nothing you hear — your p.note(...) lines still place exactly the pitches you write. What the key buys you is a reference the pitch tools in this chapter can read, so that “snap this to the scale” or “give me the notes of the scale” know which scale you mean without you repeating it every time.

Note

Sharps and flats both work for a key. key="F#" and key="Gb" name the same home note; use whichever spelling reads right for the music. (This is unlike the note constants from §3.5, which only spell sharps — notes.FS4. A key is written as you’d write it on a lead sheet.)

Tip

Picking a scale is a quick way to set a mood before you write a single note: "minor" (natural minor) is dark, "dorian" is minor-but-hopeful, "mixolydian" is bluesy-major, "major_pentatonic" can’t sound wrong. We list the full set of names in §5.2.

5.2 Snapping to a scale: snap_to_scale (pitch, not timing)

The single most useful pitch verb is p.snap_to_scale. It takes every note already placed in the pattern and nudges each one to the nearest pitch that belongs to a scale. Generate a line however you like — a random walk, a melody mapped from sensor data, or just a hand-written line with a wrong note in it — then snap it, and it lands in key.

Its shape is:

p.snap_to_scale(key, mode, strength=1.0)
  • key — the root note name ("C", "F#", "Bb").

  • mode — the scale name (default "ionian", i.e. major).

  • strength — the probability, 0.01.0, that each note is snapped.

Here is a line written deliberately “out” — a chromatic climb that ignores the key entirely — snapped into C minor so every note becomes diatonic:

import subsequence

composition = subsequence.Composition(bpm=120)

@composition.pattern(channel=1, beats=4)
def melody(p):
    chromatic = [60, 61, 63, 66, 68, 70]     # a climb with several off-key notes
    for i, pitch in enumerate(chromatic):
        p.note(pitch, beat=i * 0.5, duration=0.5)
    p.snap_to_scale("C", "minor")            # every note is now in C minor

composition.render(bars=2, filename="snapped.mid")

Like the articulation verbs in §3.4, snap_to_scale acts on notes already placed, so you call it after the p.note(...) lines. It edits pitches in place and leaves everything else — beats, durations, velocities — untouched. A note that is already in the scale is left exactly where it is.

Important

snap_to_scale changes PITCH, never timing. It’s the pitch counterpart of the timing verb swing from Chapter 4: swing moves when a note sounds; snap_to_scale moves which note sounds. They are completely separate axes. If you want a line that’s both in key and grooving, you snap its pitches and swing its timing — two independent calls. (Older drafts of Subsequence overloaded the word “quantize” for pitch; that is retired. In this guide pitch is always snap_to_scale, and “quantize” only ever means timing — snapping note onsets onto the grid, a job you’ll meet much later.)

How a note chooses where to land

When a note is off the scale, snap_to_scale moves it to the nearest scale tone. If it sits exactly halfway between two scale tones — a tritone gap — the upward note wins. That has one consequence worth seeing, because it occasionally surprises people: in C major, C♯ is one semitone above C and one below D, a tie, so it snaps up to D, not down to C.

Snapping into C major (pitch classes C D E F G A B)

Off-scale note

Snaps to

Why

C♯ (61)

D (62)

Tie between C and D → upward wins

D♯ (63)

E (64)

Tie between D and E → upward wins

F♯ (66)

G (67)

Tie between F and G → upward wins

Most notes diatonic, a few chromatic: strength

At the default strength=1.0, every note snaps and the line is purely diatonic. Lower it and only some notes snap — the rest keep their original, possibly chromatic, pitch. This is how you get a line that’s mostly in key but still has the occasional spicy passing tone, instead of a sterile, scrubbed-clean melody.

strength=0.6 means each note independently has a 60% chance of being snapped. The choice uses the pattern’s seeded random number generator, so it’s repeatable — pass a seed= to pin it down exactly:

import subsequence

composition = subsequence.Composition(bpm=120)

@composition.pattern(channel=1, beats=4)
def coloured_line(p):
    run = [60, 61, 62, 63, 64, 65, 66, 67]       # a full chromatic run
    for i, pitch in enumerate(run):
        p.note(pitch, beat=i * 0.5, duration=0.5)
    p.snap_to_scale("C", "ionian", strength=0.6, seed=42)  # mostly C major…

composition.render(bars=2, filename="coloured.mid")

With that exact seed the run 60, 61, 62, 63, 64, 65, 66, 67 comes out as 60, 62, 62, 64, 64, 65, 66, 67 — the off-key 61 and 63 were pulled into the scale, but 66 (F♯) escaped the snap and stays as a chromatic passing tone. Change the seed and a different selection survives; raise the strength toward 1.0 and fewer escape.

Tip

Think of strength as a “how strict is the bouncer” dial. 1.0 lets nobody off-key in; 0.8 is a sensible setting for a melody that should read as in-key but still feel human and surprising; 0.0 is the door wide open (nothing changes). Reach for the lower values after generating a line, to colour it.

The mode names you can use

The mode argument accepts any of these built-in names. The seven-note modes have chord qualities defined (so they’ll work with the harmony engine in Part IV too); the pentatonic and non-western scales are pitch-only, which is all snap_to_scale needs:

Family

Names

Major / minor

"ionian" (= "major"), "aeolian" (= "minor")

Other church modes

"dorian", "phrygian", "lydian", "mixolydian", "locrian"

Minor variants

"harmonic_minor", "melodic_minor"

Pentatonic

"major_pentatonic", "minor_pentatonic"

Non-western

"hirajoshi", "in_sen", "iwato", "yo", "egyptian"

Note

"major" is just an alias for "ionian", and "minor" for "aeolian" (natural minor) — write whichever reads more naturally. If you pass a name Subsequence doesn’t know, it raises a ValueError that lists the valid names, so a typo fails loudly rather than silently doing the wrong thing.

Reference

snap_to_scale()

5.3 Scale note lists: scale_notes

snap_to_scale corrects pitches you’ve already placed. Sometimes you want the opposite: a ready-made list of the in-scale notes in a register, to choose from as you build a line — a bass that only ever picks scale tones, an arpeggio drawn from the scale, a random melody that can’t go wrong. That list comes from subsequence.scale_notes.

It’s a top-level function (not a method on p), so you call it with subsequence.scale_notes(...). Its shape:

subsequence.scale_notes(key, mode, low, high, count)
  • key — the root, as a note name.

  • mode — the scale name (same set as §5.2); defaults to "ionian".

  • low / high — the MIDI range to draw from, inclusive.

  • countinstead of high, ask for an exact number of notes ascending from low, climbing through octaves as needed.

Asked for a range, it returns every scale tone between low and high:

import subsequence
import subsequence.constants.midi_notes as notes

scale = subsequence.scale_notes("C", "ionian", low=notes.C4, high=notes.C5)
print(scale)
[60, 62, 64, 65, 67, 69, 71, 72]

That’s C major from middle C up to the C an octave above — the white keys, as MIDI numbers. Because the result is just a plain list of numbers, you use it like any other step/pitch list from earlier chapters. Here a bass walks up the first few scale tones, picking them by index into the list rather than by absolute pitch:

import subsequence
import subsequence.constants.midi_notes as notes

composition = subsequence.Composition(bpm=120)

# The notes of E natural minor across the bass register, named once.
E_MINOR = subsequence.scale_notes("E", "minor", low=notes.E2, high=notes.E3)

@composition.pattern(channel=1, beats=4)
def scale_bass(p):
    # E_MINOR[0] is the root, [2] the third, [4] the fifth — degrees by index.
    p.note(E_MINOR[0], beat=0, duration=1.0)   # root
    p.note(E_MINOR[2], beat=1, duration=1.0)   # third up
    p.note(E_MINOR[4], beat=2, duration=1.0)   # fifth up
    p.note(E_MINOR[0], beat=3, duration=1.0)   # back home

composition.render(bars=4, filename="scale-bass.mid")

The count form is handy when you want a fixed-size palette regardless of register — say “give me eight notes of A minor pentatonic starting at A3”, climbing into the next octave automatically:

import subsequence
import subsequence.constants.midi_notes as notes

pool = subsequence.scale_notes("A", "minor_pentatonic", low=notes.A3, count=8)
print(pool)
[57, 60, 62, 64, 67, 69, 72, 74]

Warning

low must be a note that’s actually in the scale, or scale_notes silently starts from the next in-scale pitch above it. Asking for scale_notes("E", "minor", low=60, ...) starts on C (60 is in E minor, but it’s not the root), so your “first note” won’t be the root you expected. The safe habit: pass a low whose note name matches the key, e.g. low=notes.E2 for key="E". Then scale[0] is always the root.

Tip

A scale-note list pairs beautifully with the generators from Chapter 4 and with random. Build the pool once with scale_notes, then let a generator pick indices into it — every choice is guaranteed in key, because the pool only ever contained scale tones. You get generative variety with zero wrong notes.

Reference

scale_notes()

5.4 Custom scales: register_scale

The built-in list in §5.2 covers most music, but you may want a scale it doesn’t ship — a raga, a synthetic mode, a folk scale. register_scale adds one by name, after which it works everywhere a built-in name does: in snap_to_scale, in scale_notes, and as a Composition scale=.

subsequence.register_scale(name, intervals)
  • name — the name you’ll use later (e.g. "raga_bhairav"). It must not clash with a built-in name — you can’t redefine "minor".

  • intervals — the scale’s steps as semitone offsets from the root, a list that starts at 0, ascends strictly, and stays within 011.

You register a scale once, near the top of your script, then use it like any other. Here is Raga Bhairav — a seven-note scale with a flat 2nd and flat 6th — registered and immediately snapped onto a generated line:

import subsequence

# Register once, up front. Semitone offsets from the root: 0 1 4 5 7 8 11.
subsequence.register_scale("raga_bhairav", [0, 1, 4, 5, 7, 8, 11])

composition = subsequence.Composition(bpm=120)

@composition.pattern(channel=1, beats=4)
def raga_line(p):
    climb = [60, 61, 62, 63, 64, 65, 66, 67]   # a chromatic run again…
    for i, pitch in enumerate(climb):
        p.note(pitch, beat=i * 0.5, duration=0.5)
    p.snap_to_scale("C", "raga_bhairav")        # …pulled into the custom raga

composition.render(bars=2, filename="raga.mid")

And the same name works in scale_notes, so you can build a pool from your custom scale exactly as in §5.3:

import subsequence
import subsequence.constants.midi_notes as notes

pool = subsequence.scale_notes("C", "raga_bhairav", low=notes.C3, count=7)
print(pool)
[48, 49, 52, 53, 55, 56, 59]

Important

The interval list has rules, and breaking them raises a ValueError rather than failing quietly. It must start with 0 (the root), every step must be strictly larger than the last, and all values must be 011 (one octave). So [0, 2, 4, 7, 9] is a valid major-pentatonic-ish scale; [0, 0, 4] (repeat), [2, 4, 7] (no root), and [0, 5, 13] (out of range) all raise. Whole numbers only — semitones, not fractions.

Note

A custom scale you register is pitch-only by default, which is everything snap_to_scale and scale_notes need. (register_scale also takes an optional qualities= argument that maps a chord to each degree — that only matters for the harmony engine in Part IV, so we leave it off here.)

Reference

register_scale()

5.5 A pitch is a specification resolved late

Step back from the verbs for a moment, because this section is the idea the whole chapter has been circling — and it’s the bridge into harmony.

When you write p.note(60, beat=0), it’s tempting to think you’ve committed to a sound: middle C, full stop. But look at what snap_to_scale actually did. The number 60 you wrote was not the final sound — it was a specification, a request that only got its real musical meaning when a key and scale were applied, later, at the moment the bar was built. The same written line came out as different notes depending on the key you snapped it into.

Here is that made concrete. The identical raw line [60, 62, 64, 66, 67] is snapped into two different keys; watch the third note land in two different places:

import subsequence

# The SAME written line, snapped into two different keys, sounds different.
for key in ("C", "Eb"):
    composition = subsequence.Composition(bpm=120)
    sounded = {}

    @composition.pattern(channel=1, beats=4)
    def line(p, _key=key, _out=sounded):
        raw = [60, 62, 64, 66, 67]
        for i, pitch in enumerate(raw):
            p.note(pitch, beat=i * 0.5, duration=0.5)
        p.snap_to_scale(_key, "ionian")
        # Peek at the pitches the bar actually ended up with, just to print them.
        _out["pitches"] = [
            n.pitch
            for step in sorted(p._pattern.steps)
            for n in p._pattern.steps[step].notes
        ]

    composition.render(bars=1, filename="resolved.mid")
    print(key, "->", sounded["pitches"])
C -> [60, 62, 64, 67, 67]
Eb -> [60, 62, 65, 67, 67]

The written specification was byte-for-byte identical both times. Under C major the off-scale 66 resolved to 67; under E♭ major the in-between 64 resolved to 65. Same spec, different sound — because the pitch was resolved against the key late, when the bar was built, not when you typed the number.

(That little loop over p._pattern.steps is just so we can print the resolved pitches here; you’d never reach inside p like that in real music — you’d simply let the notes play. It’s a peek behind the curtain to make the point visible.)

This is worth naming carefully, because it’s two different notions of “the same” that musicians juggle without thinking:

Kind of “same”

What it means

Structural equality

Two things are written the same way — the same specification, the same degree, the same instruction. The two raw lines above are structurally identical.

Sonic equality

Two things sound the same — the same actual pitches come out. The two lines above are not sonically equal, because the key resolved them differently.

A raw MIDI number like 36 is the rare case where structural and sonic equality coincide: 36 is always the same C, resolved or not. The moment you describe pitch relative to a key — which is what snap_to_scale and a Composition(key=...) let you do — the two come apart, and that gap is a feature. It’s what lets you write one line and transpose a whole piece by changing one key=, or write a bass that follows wherever the music goes.

Note

You’ve only seen the gentle version of this. Here the “context” a pitch is resolved against is just the key and scale of the piece. The full power arrives in Part IV, where the context becomes the current chord: a stored degree can mean the chord’s root in one bar and its third in the next, as the harmony moves underneath it. The principle is identical — a pitch is a specification resolved late — but the thing it’s resolved against gets richer. Keep this idea in your pocket; harmony is where it pays off.


You can now work in keys and scales instead of bare numbers: set a key on the Composition, snap any line diatonic (with strength for chromatic colour), build note pools with scale_notes, and add your own scales with register_scale — all resting on the idea that a pitch is a specification resolved late. Next we turn on the chord engine, so that “context” becomes a living harmony your patterns can follow bar by bar.