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
The toolkit at a glance

Function

Returns

One-line job

rhythmic_evenness(onsets, grid)

float in (0, 1]

How evenly onsets spread around the cycle.

offbeatness(onsets, grid)

int

How many onsets land on coprime (off-beat) pulses.

syncopation(onsets, grid)

float

How far onsets pull from the metric strong points.

build_metric_weights(ts, grid)

List[float]

The per-step “strength” table the others lean on.

sieve(classes, hi, lo)

List[int]

Union of residual classes over a range.

residual_class(mod, res)

Sieve

One residual class, composable with & | ~.

cseg(pitches)

List[int]

The contour (rank order) of a line.

csim(a, b)

float in [0, 1]

Contour similarity of two equal-length lines.

register_scale(name, intervals)

None

Add a custom scale by name.

register_chord_quality(name, intervals)

None

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 grid and de-duplicated, so you can pass raw step lists safely.

    • grid

    • Pulses in the cycle (e.g. 8 for eighth-notes in 4/4, 16 for sixteenths).

    • normalize

    • True

    • True → 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. Each modulus must be ≥ 1; the residue is taken mod the modulus. The result is their union.

    • hi

    • Exclusive upper bound of the range to evaluate over.

    • lo

    • 0

    • Inclusive 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.

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.”

Reference

cseg(), csim()

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 0

    • The first degree is the root.

    • Must ascend strictly (no repeats)

    • Each degree is a distinct, higher pitch class.

    • Every value in 011

    • A scale spans one octave; the engine octave-wraps from there.

    • qualities (if given) must match the interval count

    • One chord quality per degree, or none at all.

    • name must not be a built-in scale

    • Built-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

    • intervals start at 0, ascend strictly, lie in 024

    • Root first; distinct rising tones; extensions may pass the octave.

    • name must not be a built-in quality

    • Built-ins like "minor", "dominant_7th" are protected.

    • suffix (if given) must not be a built-in suffix

    • No clashing with m, 7, maj7, … — those already mean something.

    • suffix must not start with a note letter, accidental, or digit

    • Otherwise "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.