Posttonal Basics

Pitch Class Sets and Scales

In textbooks on post-tonal theory, the definition of pitch class sets is often ambiguous. In some applications a pitch class set is considered unordered. In others (for example in normal form calculation) the order is vital. To account for both cases pitch class sets in xenharmlib are always lists of unique pitch classes.

Xenharmlib implements pitch class set arithmetic as a byproduct of scale arithmetic. Every scale object in xenharmlib has a pc_indices property that lists pitch classes in the order they appear in the scale:

from xenharmlib import EDOTuning

edo12 = EDOTuning(12)

scale = edo12.index_scale(
    [2, 4, 7, 9, 11, 13, 16, 18]
)
print(scale.pc_indices)
[2, 4, 7, 9, 11, 1, 4, 6]

However, pc_indices does not always return a collection of unique elements. For example, in the list above, pitch class 4 appears twice. This is due to the flexibility of xenharmlib’s scale definition. Unlike many traditional textbook definitions, the scale object permits multiple equivalent (albeit not identical) pitches to coexist and also allows pitches to span multiple octaves. See the following diagram for illustration:

A diagram showing a chain of pitches

One way to ensure that the elements are unique is calling the pcs_normalized method of the scale:

pcsn_scale = scale.pcs_normalized()
print(pcsn_scale.pc_indices)
[1, 2, 4, 6, 7, 9, 11]

The normalization transposes every pitch into the first base interval. This has two effects: First, it guarantees the uniqueness of the pitch classes in the list. Secondly, it guarantees that pitch classes occur in a strictly ascending order.

A diagram showing the same chain of pitches after pcs normalization

However, this kind of transformation is not always desirable, as it eliminates the mode information of a scale (for instance, the C major and A minor scales result in the same scale when pcs normalized). A better way to make the list of pitch classes unique is period normalization which transposes every pitch into the base interval spanned by the root pitch of the original scale (highlighted in red below):

A diagram showing the same chain of pitches with the relative base interval marked red

This transformation can be executed on the scale like this:

pn_scale = scale.period_normalized()
print(pn_scale.pc_indices)
[2, 4, 6, 7, 9, 11, 1]
A diagram showing the same chain of pitches after period normalization

Period normalization guarantees uniqueness, but not that pitch classes are in ascending order (as you can see from the jump from 11 to 1). They guarantee however that at most one such violation of the order occurs.

If visualized as a closed necklace the resulting pitch class set of both normalizations share the same geometric form, however the starting point (highlighted in green) is different:

A diagram showing the difference between pcs and period normal form

Transposition

Period normalized scales have a nice quality to it: They form a closed subset of scales in regard to transposition, meaning that every time you transpose a period-normalized scale your result will be another period normalized scale:

pn_scale_t3 = pn_scale.transpose(3)
print(pn_scale_t3.pc_indices)
[5, 7, 9, 10, 0, 2, 4]

From the viewpoint of the pc_indices property a transposition is an addition mod period size (mod 12 in our example). In the closed necklace visualization, it amounts to a rotation of the necklace:

A necklace diagram showing a transposition

To learn more about scale normalization methods read the section on normalization in the Advanced Scale Guide.

We now have established how to apply the post-tonal \(T_n\) operation on a pitch class set by using scale operations and can now move on to the \(I\) (inversion) operation.

Inversion

Xenharmlib implements a generalization of the \(I\) operation, called reflection(), that reflects the scale around an arbitrary axis pitch. Without any parameters it reflects around the zero element of the origin context (pitch 0 in tunings, C0 in UpDownNotation).

scale = edo12.index_scale(
    [1, 2, 4, 5, 6, 7, 9, 11]
)
refl = scale.reflection()
print(refl)
EDOPitchScale([-11, -9, -7, -6, -5, -4, -2, -1], 12-EDO)

Reflection is most easily understood visually. In the graphic below, the source and target pitches are represented by the same color. As shown, reflection is accomplished by calculating the interval from the reflection point (outlined in red) and applying the negative of that interval back to the reflection point:

A diagram showing a reflection along the 0-6 axis

From the perspective of the pc_indices property invoking reflection() without a parameter applies the \(I\) operation on the pitch class set.

scale = edo12.index_scale(
    [1, 2, 4, 5, 6, 7, 9, 11]
)
refl = scale.reflection()
print(refl.pc_indices)
[1, 3, 5, 6, 7, 8, 10, 11]

On the closed necklace graph of the pitch class set, this operation is equivalent to flipping the necklace across the horizontal line passing through 0.

A diagram showing a reflection of a necklace along the 0-6 axis

If another pitch should serve as the reflection axis it can be provided as an argument:

scale = edo12.index_scale(
    [1, 2, 4, 5, 6, 7, 9, 11]
)
refl = scale.reflection(
    edo12.pitch(6)
)
print(refl)
EDOPitchScale([1, 3, 5, 6, 7, 8, 10, 11], 12-EDO)
A diagram showing a reflection with a different axis

Normal Form

The normal form transforms all scales that are related by rotation into a single representative, more formally for two scales \(S_1\) \(S_2\), rotation function \(R\) and normal form \(N\) it holds that:

\[\exists i (S_1 = R^i(S_2)) \leftrightarrow N(S_1) = N(S_2)\]

In more concrete terms this means that all modes of a scale and all inversions of a chord have the same normal form, e.g. “C Major” and “D Dorian” have the same normal form or the “C4-E4-G4” chord has the same normal form as its inversion “E4-G4-C5”.

There are several ways to define the normal form, with the most well-known being those proposed by Alan Forte and John Rahn. Both methods seek to identify the rotation in which the scale’s intervals are most tightly clustered toward the left, though they differ slightly in their criteria, leading to different representatives in a few cases.

Xenharmlib provides both variants as part of the set class package: nf_forte() for Forte’s definition and nf_rahn() for Rahns’s definition respectively.

While in textbooks the normal form is often given in a notation like “[013468T]” (with T representing 10), xenharmlib’s normal form functions do not return a string but a scale object of the same type as the input:

from xenharmlib import EDOTuning
from xenharmlib.setc import nf_forte

edo12 = EDOTuning(12)
scale = edo12.index_scale([1, 4, 6, 7, 9, 11])
n_scale = nf_forte(scale)

print(n_scale)
EDOPitchScale([4, 6, 7, 9, 11, 13], 12-EDO)

To retrieve the normal form in its pitch class representation the scale attribute pc_indices must be used:

print(n_scale.pc_indices)
[4, 6, 7, 9, 11, 1]

Prime Form

The prime form transforms all scales that are related by rotation, transposition and inversion into a single representative.

Xenharmlib provides both the Forte and the Rahn variant of the prime form transformation: primeform_forte() and primeform_rahn() respectively.

The prime form is calculated as follows:

  1. Transform the scale to its normal order.

  2. Zero-normalize the scale

  3. Generate the inversion of the resulting scale, transform it to its normal order and zero-normalize it.

  4. Compare scales from step 2 and 3 and choose the one with intervals most tightly packed to the left (using Forte’s or Rahn’s algorithm)

An example for 12-EDO:

from xenharmlib import EDOTuning
from xenharmlib.setc import primeform_forte

edo12 = EDOTuning(12)
scale = edo12.index_scale([1, 4, 6, 7, 9, 11])
n_scale = primeform_forte(scale)

print(n_scale)
EDOPitchScale([0, 2, 3, 5, 7, 9], 12-EDO)

To retrieve the prime form in its pure pitch class representation use the scale attribute pc_indices:

print(n_scale.pc_indices)
[0, 2, 3, 5, 7, 9]