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 |
|---|---|
|
The home note of the piece, as a note name ( |
|
The scale/mode that defines which degrees are in key. Defaults to major
( |
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.0–1.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.
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 |
|
Other church modes |
|
Minor variants |
|
Pentatonic |
|
Non-western |
|
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
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.count— instead ofhigh, ask for an exact number of notes ascending fromlow, 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
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 at0, ascends strictly, and stays within0–11.
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 0–11 (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
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 |
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.