Appendix B · Analysis and Set-Theory Toolkit¶
A reference for Subsequence’s analysis tools: measure a rhythm with Toussaint’s metrics, construct scales and rhythm grids from Xenakis sieves, compare melodic shapes with contour functions, and register your own scales and chord qualities so they work everywhere the library accepts a built-in name.
B.0 What this appendix is for¶
The chapters teach you to make music; this appendix gives you the small set of functions that let you reason about it numerically — and a pair of registration calls that extend the library’s musical vocabulary. None of this is on the beginner path. Reach for it when you want to:
put a number on how syncopated or how even a rhythm is, so a generator can aim for a target feel (§B.1);
build a scale, a non-octave pitch pool, or a rhythm grid from a logical formula over the integers — the experimental composer’s primitive (§B.2);
ask whether two melodies trace the same shape even when their exact pitches differ (§B.3);
teach Subsequence a scale or chord quality it doesn’t ship, after which
snap_to_scale, progressions, and voice-leading all understand it (§B.4).
Note
Almost everything here is a pure function. It takes lists of numbers and returns
lists of numbers — no Composition, no MIDI port, no clock. That’s why this
appendix’s examples are runnable {doctest} blocks you can paste straight into a
Python prompt: no instrument, no render(), no setup. The two exceptions are the
register_* calls in §B.4, which change shared library state
and so feed the live API.
Where these live. Two functions are exported at the top level
(subsequence.sieve, subsequence.residual_class) alongside the registration calls
(subsequence.register_scale, subsequence.register_chord_quality). The rest live
in subsequence.sequence_utils and are imported explicitly:
>>> from subsequence.sequence_utils import (
... syncopation, offbeatness, rhythmic_evenness, build_metric_weights,
... sieve, residual_class, Sieve, cseg, csim,
... )
>>> import subsequence
Function |
Returns |
One-line job |
|---|---|---|
|
|
How evenly onsets spread around the cycle. |
|
|
How many onsets land on coprime (off-beat) pulses. |
|
|
How far onsets pull from the metric strong points. |
|
|
The per-step “strength” table the others lean on. |
|
|
Union of residual classes over a range. |
|
|
One residual class, composable with |
|
|
The contour (rank order) of a line. |
|
|
Contour similarity of two equal-length lines. |
|
|
Add a custom scale by name. |
|
|
Add a custom chord quality by name. |
B.1 Rhythm metrics (Toussaint)¶
These three functions are lifted from Godfried Toussaint’s The Geometry of Musical
Rhythm. Each takes a rhythm as a list of onset indices — the 0-based grid steps
that carry a hit, exactly the step lists you write for hit_steps in Chapter
1 — plus the grid (how many equal pulses fill the cycle). They
turn a rhythm into a single number, which is what makes them useful as targets: a
generator can keep proposing rhythms until one scores in the range you want.
Tip
Onsets, not a binary mask. Pass the positions that sound — [0, 3, 6], not
[1, 0, 0, 1, 0, 0, 1, 0]. If you have a binary list from a generator (Chapter 4’s
generate_euclidean_sequence returns one), convert it with
sequence_to_indices first — that’s shown at the end of this section.
B.1.1 rhythmic_evenness — how regular is it?¶
rhythmic_evenness(onsets, grid, normalize=True) places the grid’s pulses as equally
spaced points on a circle and measures how spread out the onsets are around it. A
maximally even rhythm — a regular polygon, which is exactly what a Euclidean
rhythm approximates — scores 1.0; onsets bunched together score lower.
>>> round(rhythmic_evenness([0, 3, 6], 8), 3) # tresillo — almost a triangle
0.983
>>> round(rhythmic_evenness([0, 1, 2], 8), 3) # three hits crammed together
0.567
>>> rhythmic_evenness([0, 2, 4, 6], 8) # a perfect square — maximal
1.0
The default normalises against the most-even arrangement of that same number of
onsets, so the score is comparable across rhythms with different densities. Pass
normalize=False for the raw sum of chord lengths (useful only if you are
re-deriving the maths):
>>> round(rhythmic_evenness([0, 2, 4, 6], 8, normalize=False), 3)
9.657
```{list-table} rhythmic_evenness arguments
- header-rows:
1
- widths:
24 18 58
Argument
Default
Meaning
onsets—
0-based onset indices. Out-of-range and duplicate values are reduced mod
gridand de-duplicated, so you can pass raw step lists safely.
grid—
Pulses in the cycle (e.g.
8for eighth-notes in 4/4,16for sixteenths).
normalizeTrueTrue→ score in(0, 1]against the maximally-even k-gon.False→ raw sum.
```{note}
Fewer than two onsets is always `1.0` (a single point can't be uneven). The score is
**density-relative**: `[0, 4]` in a grid of 8 also scores `1.0`, because two
onsets are most-even when antipodal. Compare rhythms with the *same* onset count, or
the number flatters sparse rhythms.
B.1.2 offbeatness — how many hits fight the meter?¶
offbeatness(onsets, grid) counts the onsets that land on intrinsically off-beat
pulses — the positions coprime to the grid. Those are the pulses no regular
subdivision of the cycle ever lands on, so an onset there fights every even way of
dividing the bar. The result is a plain integer count, and it’s meter-independent:
it doesn’t consult a time signature at all.
>>> offbeatness([0, 4, 8, 12], 16) # four-on-the-floor — every hit on-beat
0
>>> offbeatness([0, 2, 4, 6, 8, 10, 12, 14], 16) # straight eighths — still on-beat
0
>>> offbeatness([0, 3, 6, 10, 13], 16) # a bossa-flavoured line
2
In that last rhythm, pulses 3 and 13 are coprime to 16 — they share no factor
with it — so they read as genuinely off the grid; the even positions, which line up
with a halving of the bar, don’t count. The downbeat (pulse 0) is never off-beat.
B.1.3 syncopation — how far from the strong beats?¶
syncopation(onsets, grid, time_signature=(4, 4), weights=None) is the meter-aware
measure. It consults a metric-weight table (the downbeat is strongest, the
half-bar next, then beats, then off-beat eighths, then everything finer) and, for each
onset, charges it the deficit between the strongest pulse and the pulse it actually
sits on. Onsets on the downbeat cost nothing; onsets on the weakest pulses cost the
most. The result is the mean deficit per onset.
>>> syncopation([0], 16) # the downbeat alone — no pull at all
0.0
>>> syncopation([0, 4, 8, 12], 16) # on the beats, but only beat 1 is strongest
0.3125
>>> round(syncopation([0, 3, 6, 9, 12], 16), 3) # tresillo over 16 — pulls harder
0.6
>>> syncopation([3, 7, 11, 15], 16) # every onset on a weak pulse — maximal
0.875
Because the table depends on the meter, the same onset list scores differently under
a different time signature — pass time_signature=(3, 4) for a waltz, for instance.
For an additive or non-isochronous meter where Subsequence’s default hierarchy
doesn’t apply, hand in your own per-pulse weights list (one value per grid step):
>>> custom = [1.0, 0.0, 0.5, 0.0, 0.5, 0.0, 0.25, 0.0] # a hand-built 8-step table
>>> round(syncopation([0, 2, 6], 8, weights=custom), 3)
0.417
B.1.4 build_metric_weights — the table underneath¶
build_metric_weights(time_signature=(4, 4), grid=16) is the function syncopation
calls for its default table, exposed so you can inspect it or build your own variant.
It returns one weight per grid step: 1.0 on the downbeat, 0.75 on the half-bar
(even meters only), 0.5 on the other beats, 0.25 on off-beat eighths, and 0.125
on anything finer.
>>> build_metric_weights((4, 4), grid=8)
[1.0, 0.25, 0.5, 0.25, 0.75, 0.25, 0.5, 0.25]
>>> build_metric_weights((3, 4), grid=12)
[1.0, 0.125, 0.25, 0.125, 0.5, 0.125, 0.25, 0.125, 0.5, 0.125, 0.25, 0.125]
Reading the 4/4-over-8 table left to right: step 0 is the downbeat (1.0), step 4 is
the half-bar (0.75), steps 2 and 6 are the other beats (0.5), and the odd steps
are the weak off-beats (0.25). It is exactly the “strength” you’d assign by ear.
B.1.5 Putting it together: scoring a generated rhythm¶
The metrics shine when you turn them on rhythms a generator produced. Chapter 4’s
generate_euclidean_sequence returns a binary mask; sequence_to_indices turns that
into the onset list these functions expect. Here we confirm that the Euclidean
rhythm E(3, 8) really is the tresillo, and read off all three metrics at once:
>>> from subsequence.sequence_utils import generate_euclidean_sequence, sequence_to_indices
>>> mask = generate_euclidean_sequence(8, 3) # 3 pulses spread over 8 steps
>>> mask
[1, 0, 0, 1, 0, 0, 1, 0]
>>> onsets = sequence_to_indices(mask)
>>> onsets
[0, 3, 6]
>>> round(rhythmic_evenness(onsets, 8), 3) # near-maximal — Euclid is even by design
0.983
>>> offbeatness(onsets, 8) # one hit (pulse 3) fights the meter
1
>>> round(syncopation(onsets, 8), 3)
0.417
Tip
That evenness ≈ 0.983 is the signature of a Euclidean rhythm: Bjorklund’s algorithm
(Chapter 4) spreads pulses as evenly as an integer grid allows, which is precisely
what rhythmic_evenness rewards. If you generate a candidate rhythm and its evenness
comes back low, it’s clustered — useful when clustered is the feel you want, and a
red flag when it isn’t.
B.2 Xenakis sieves¶
A sieve (Iannis Xenakis’s crible) is a logical formula over the integers that
denotes a subset of them. The atoms are residual classes — “every integer x
where x % modulus == residue” — combined with union, intersection, and complement.
Because the result is just a set of integers, one sieve kernel builds anything that
indexes an ordered parameter: the semitones of a scale (over 0–11), a non-octave
pitch pool (over MIDI numbers), a rhythm grid (over steps), or a bar-selection mask
(over bar numbers).
Subsequence gives you two entry points: a one-call sieve() for the common case (a
union of classes over a range), and a composable Sieve algebra via residual_class
for intersection and complement.
B.2.1 sieve() — the one-call union¶
sieve(classes, hi, lo=0) takes a list of (modulus, residue) pairs and returns the
sorted integers in the half-open range [lo, hi) that satisfy any of them — the
union. The major scale, for instance, is the union of seven single-residue classes
mod 12:
>>> sieve([(12, 0), (12, 2), (12, 4), (12, 5), (12, 7), (12, 9), (12, 11)], hi=12)
[0, 2, 4, 5, 7, 9, 11]
A single class with a small modulus is a regular pattern. Mod-2-residue-0 is the whole-tone scale; mod-3 over a 16-step grid is a steady dotted-eighth pulse:
>>> sieve([(2, 0)], hi=12) # whole-tone scale
[0, 2, 4, 6, 8, 10]
>>> sieve([(3, 0)], hi=16) # a rhythm: every third sixteenth
[0, 3, 6, 9, 12, 15]
The lo and hi bounds let the same formula address a register of MIDI notes
instead of pitch classes — a non-octave pitch pool, the kind of structure sieves
were invented to express:
>>> sieve([(5, 0), (7, 1)], lo=60, hi=84) # union of two non-octave cycles
[60, 64, 65, 70, 71, 75, 78, 80]
```{list-table} sieve() arguments
- header-rows:
1
- widths:
22 16 62
Argument
Default
Meaning
classes—
List of
(modulus, residue)pairs. Eachmodulusmust be ≥ 1; the residue is taken mod the modulus. The result is their union.
hi—
Exclusive upper bound of the range to evaluate over.
lo0Inclusive lower bound.
```{warning}
A `modulus` below 1 raises `ValueError` — there is no "every 0th integer". The range
is **half-open**: `[lo, hi)` includes `lo` and excludes `hi`, exactly like Python's
`range()`. So to cover one octave of pitch classes you pass `hi=12`, not `hi=11`.
B.2.2 residual_class and the Sieve algebra¶
sieve() only does unions. For intersection and complement you build a
Sieve object with residual_class(modulus, residue) and combine the pieces with
the operators | (union), & (intersection), and ~ (complement), then call
.evaluate(hi, lo=0) to read out the integers.
>>> rc = residual_class
>>> (rc(2, 0) | rc(3, 0)).evaluate(hi=12) # multiples of 2 OR 3
[0, 2, 3, 4, 6, 8, 9, 10]
>>> (rc(4, 0) & rc(6, 0)).evaluate(hi=24) # multiples of 4 AND 6
[0, 12]
>>> (~rc(2, 0)).evaluate(hi=12) # the complement: odd numbers
[1, 3, 5, 7, 9, 11]
Because complement is decided one integer at a time, it composes freely with the rest before you ever fix a range — this is the full Xenakis algebra:
>>> ((rc(2, 0) | rc(3, 0)) & ~rc(4, 1)).evaluate(hi=24)
[0, 2, 3, 4, 6, 8, 10, 12, 14, 15, 16, 18, 20, 22]
A Sieve also answers a single-integer membership test with in, which is handy as
a filter inside a loop without evaluating the whole range:
>>> 3 in rc(3, 0)
True
>>> 4 in rc(3, 0)
False
Note
residual_class has the short alias rc only because we assigned it
(rc = residual_class) — it isn’t a separate import. The library exports
subsequence.sieve and subsequence.residual_class at the top level too, so you can
reach them without importing from sequence_utils. Don’t try to print() a Sieve
directly: it has no readable repr (you’d get a memory address). Always .evaluate()
or test membership.
B.2.3 From a sieve to a real scale¶
A sieve over [0, 12) is a list of semitone offsets — exactly the shape
register_scale wants (§B.4). So you can design a scale as a
formula and register the result in one breath:
>>> intervals = sieve([(12, 0), (12, 2), (12, 3), (12, 7), (12, 8)], hi=12)
>>> intervals
[0, 2, 3, 7, 8]
>>> subsequence.register_scale("hira_from_sieve", intervals)
>>> subsequence.scale_notes("C", "hira_from_sieve", low=60, high=72)
[60, 62, 63, 67, 68, 72]
That registered name now drives p.snap_to_scale("C", "hira_from_sieve") and
everything else in Chapter 5, with no further wiring.
Reference
B.3 Melodic contour (cseg / csim)¶
Contour functions abstract a melody down to its shape — the up/down pattern of its pitches — discarding the exact intervals. Two phrases a perfect-fourth apart, or one in a major key and one in minor, can share an identical contour. This is the formal tool behind “the second phrase answers the first by inverting its shape.”
B.3.1 cseg — the contour of a line¶
cseg(pitches) replaces each pitch with its rank among the distinct pitches in
the line: the lowest becomes 0, the next 1, and so on. The result — Robert
Morris’s contour segment — is the same for any two lines that rise and fall in the
same places, regardless of their actual pitches:
>>> cseg([60, 67, 64]) # low, high, middle
[0, 2, 1]
>>> cseg([50, 59, 55]) # different pitches, same shape
[0, 2, 1]
Repeated pitches share a rank, so a line that holds and then drops reads cleanly:
>>> cseg([5, 5, 3]) # two equal, then lower
[1, 1, 0]
>>> cseg([]) # an empty line is an empty contour
[]
B.3.2 csim — how alike are two shapes?¶
csim(a, b) scores the similarity of two equal-length contours from 0.0
(opposite shapes) to 1.0 (identical), as the fraction of pitch pairs whose
up/down/equal relationship the two lines agree on (the Marvin–Laprade measure). Like
cseg, it ignores absolute pitch:
>>> csim([60, 67, 64], [50, 59, 55]) # same contour, different register
1.0
>>> csim([60, 62, 64], [64, 62, 60]) # a rising line vs its exact retrograde
0.0
>>> round(csim([60, 64, 62], [60, 62, 64]), 3) # share some, differ on others
0.667
Warning
csim compares equal-length lines and raises ValueError otherwise — there is no
defined similarity between a four-note and a six-note phrase here. Make the lengths
match first (truncate, or compare segment by segment). A single-element (or empty)
pair has no pitch pairs to compare and returns 1.0 by convention.
Tip
Pair these with the motif transforms in Chapter 8: generate a candidate
variation, then keep it only if csim(original_cseg, variation_cseg) falls in a
window — high enough to feel related, low enough to be a genuine variation. Contour is
how you put numbers on “same idea, said differently.”
B.4 Registering custom scales and chord qualities¶
The final two functions don’t analyse music — they extend the library’s vocabulary. Each adds a name to a shared table, and from then on that name works everywhere the corresponding built-in names do. Unlike everything above, they change global state, so they’re the live-API members of this appendix.
Important
Register once, near the top of your script, before the patterns that use the name. Both calls mutate a shared registry that the whole library reads. Re-registering the same custom name is deliberately allowed and never raises — that’s what lets live reload (Chapter 14) re-run your registration on every file save. Re-defining a built-in name, on the other hand, always raises.
B.4.1 register_scale¶
Chapter 5 introduces register_scale for the common case;
here is the full surface. The signature is register_scale(name, intervals, qualities=None), and a registered scale immediately drives p.snap_to_scale,
scale_notes, scale_pitch_classes, and a Composition(scale=...):
>>> subsequence.register_scale("hirajoshi_ref", [0, 2, 3, 7, 8])
>>> from subsequence.intervals import scale_pitch_classes
>>> scale_pitch_classes(0, "hirajoshi_ref") # pitch classes from C
[0, 2, 3, 7, 8]
>>> subsequence.scale_notes("C", "hirajoshi_ref", low=60, high=72)
[60, 62, 63, 67, 68, 72]
The optional qualities argument names a chord quality per scale degree, which is
what lets the harmony engine of Part IV build diatonic chords from your scale. It’s
unnecessary for pitch-only use (snapping, note pools):
>>> subsequence.register_scale(
... "my_penta",
... [0, 2, 4, 7, 9],
... qualities=["major", "minor", "minor", "major", "minor"],
... )
>>> scale_pitch_classes(0, "my_penta")
[0, 2, 4, 7, 9]
The interval list is validated strictly; breaking a rule raises ValueError rather
than failing quietly:
```{list-table} register_scale interval rules
- header-rows:
1
- widths:
40 60
Rule
Why
Must be a non-empty list of whole numbers
Intervals are semitone offsets — no fractions, no empty scale.
Must start with
0The first degree is the root.
Must ascend strictly (no repeats)
Each degree is a distinct, higher pitch class.
Every value in
0–11A scale spans one octave; the engine octave-wraps from there.
qualities(if given) must match the interval countOne chord quality per degree, or none at all.
namemust not be a built-in scaleBuilt-ins like
"minor","dorian","hirajoshi"are protected.
```{doctest} appB
>>> subsequence.register_scale("minor", [0, 2, 3, 5, 7, 8, 10]) # built-in name
Traceback (most recent call last):
...
ValueError: Cannot overwrite built-in scale 'minor'. Choose a different name for your custom scale.
>>> subsequence.register_scale("bad", [1, 2, 3]) # doesn't start at 0
Traceback (most recent call last):
...
ValueError: intervals must start with 0
B.4.2 register_chord_quality¶
The counterpart for harmony: register_chord_quality(name, intervals, suffix=None)
opens the chord-quality table so quartal stacks, clusters, and extended chords
become first-class symbolic chords. A registered quality works in Chord objects, in
progressions (Chapter 7), in voice-leading, and in describe()
output. Intervals are semitone offsets from the root, and may reach past the octave
(up to 24) so extensions like the 9th are expressible:
>>> subsequence.register_chord_quality("quartal", [0, 5, 10], suffix="q4")
>>> chord = subsequence.Chord(root_pc=2, quality="quartal") # D quartal
>>> chord.tones(62) # MIDI notes centred on D4
[62, 67, 72]
>>> chord.name()
'Dq4'
The optional suffix is what lets the new quality be parsed from a chord name.
Give it one and parse_chord accepts note + suffix from then on — so the quality
flows into the string-based progression syntax:
>>> subsequence.register_chord_quality("minor_9th", [0, 3, 7, 10, 14], suffix="m9")
>>> subsequence.parse_chord("Am9")
Chord(root_pc=9, quality='minor_9th')
>>> subsequence.parse_chord("Am9").tones(57) # A2 up: A C E G B
[57, 60, 64, 67, 71]
A quality registered without a suffix still works everywhere via the Chord
constructor; it just can’t be spelled in a chord-name string, and name() prints it
in an unambiguous root(quality) form rather than pretending to be a triad:
>>> subsequence.register_chord_quality("cluster", [0, 1, 2]) # no suffix
>>> subsequence.Chord(root_pc=0, quality="cluster").name()
'C(cluster)'
```{list-table} register_chord_quality rules
- header-rows:
1
- widths:
40 60
Rule
Why
intervalsstart at0, ascend strictly, lie in0–24Root first; distinct rising tones; extensions may pass the octave.
namemust not be a built-in qualityBuilt-ins like
"minor","dominant_7th"are protected.
suffix(if given) must not be a built-in suffixNo clashing with
m,7,maj7, … — those already mean something.
suffixmust not start with a note letter, accidental, or digitOtherwise
"Cq4"couldn’t be told apart from a root or extension.
```{doctest} appB
>>> subsequence.register_chord_quality("minor", [0, 3, 7]) # built-in name
Traceback (most recent call last):
...
ValueError: Cannot overwrite built-in chord quality 'minor'. Choose a different name for your custom quality.
Tip
Compose the two halves of this appendix: design a chord’s intervals with a
sieve, then hand the result to register_chord_quality. A
quartal stack is sieve([(5, 0)], hi=11) → [0, 5, 10]; a cluster is
sieve([(1, 0)], hi=3) → [0, 1, 2]. Sieves generate the integer structure; the
register_* calls give it a name the rest of Subsequence understands.
Where to go next. The rhythm metrics pair naturally with the generators in
Chapter 4 (score what you generate); sieves and custom
scales extend the pitch material of Chapter 5; contour
functions complement the motif work of Chapter 8; and custom chord
qualities feed the progressions of Chapter 7. For the exhaustive,
always-current signature list, see Appendix D and the
sequencer’s own api-cheatsheet.md.