Scales

A scale in xenharmlib is a set of unique frequency representations, sorted in ascending order, from lowest frequency to highest frequency. The scale primitive allows equivalent frequency representations to coexist inside its structure, but not identical ones. On creation xenharmlib will sort the frequency representations and remove duplicate items:

from xenharmlib import WesternNotation
western = WesternNotation()

C4 = western.note('C', 4)
E4 = western.note('E', 4)
G4 = western.note('G', 4)
C5 = western.note('C', 5)

# E4 will only appear once in the scale, however
# C4 and C5 are allowed to coexist
scale = western.scale([E4, G4, E4, C4, C5])
print(scale)
WesternNoteScale([C4, E4, G4, C5])

Scales in xenharmlib are not restricted to the span of the equivalency interval (e.g. the octave), meaning the scale primitive can also be used to analyze polychords and related structures:

C4 = western.note('C', 4)
E4 = western.note('E', 4)
G4 = western.note('G', 4)
B4 = western.note('B', 4)
D5 = western.note('D', 5)

polychord = western.scale([C4, E4, G4, B4, D5])
print(polychord)
WesternNoteScale([C4, E4, G4, B4, D5])

Scales can be built from any iterable object that includes pitches or notes of the same origin context. This includes, e.g., Python’s lists, tuples, sets, generators, and xenharmlib’s sequence primitive.

C4 = western.note('C', 4)
E4 = western.note('E', 4)
G4 = western.note('G', 4)

# from list, set, tuple and generator
scale_a = western.scale([C4, E4, G4])
scale_b = western.scale({C4, E4, G4})
scale_c = western.scale((C4, E4, G4))
scale_d = western.scale(western.note(pcs, 4) for pcs in 'CEG')

# from xenharmlib's sequence primitive
seq = western.seq([C4, G4, E4, E4, C4])
scale_e = western.scale(seq)

print(scale_a == scale_b == scale_c == scale_d == scale_e)
True

For post-tonal theory, the scale primitive can be used as a pitch set or pitch class set. Since Xenharmlib implements a more general framework of music theory, double-digit pitch class indices are not abbreviated with ‘T’ for ten or ‘E’ for eleven, but returned in their numerical form.

Xenharmlib’s support for multi-generator tunings and notations also means that pitch or pitch class indices are not necessarily represented by integers, but can also be lattice points:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

p41 = edo31.pitch(41)
p49 = edo31.pitch(49)
p62 = edo31.pitch(62)

scale = edo31.scale([p41, p49, p62])
print(scale.pitch_indices)
print(scale.pc_indices)
[41, 49, 62]
[10, 18, 0]

Deriving Scales

The builder method most agnostic to the origin context is the scale() method, which takes a list of frequency representations originating from the same context and creates a scale primitive:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

p0 = edo31.pitch(0)
p10 = edo31.pitch(10)
p18 = edo31.pitch(18)

scale = edo31.scale([p0, p10, p18])
print(scale)
EDOPitchScale([0, 10, 18], 31-EDO)

Element-wise Construction

Scales can be constructed element-wise. In line with xenharmlib’s immutable object design, this is not done with an append method like in standard Python, but with a method that returns a scale with an additional element:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

C0 = edo31.pitch(0)
D0 = edo31.pitch(5)
E0 = edo31.pitch(10)

# start with empty scale
scale = edo31.scale()

for e in [C0, D0, E0]:
    scale = scale.with_element(e)
    print(scale)
EDOPitchScale([0], 31-EDO)
EDOPitchScale([0, 5], 31-EDO)
EDOPitchScale([0, 5, 10], 31-EDO)

Index-based Construction

Scales can also conveniently be constructed by providing a list of indices, which - depending on origin context - can be integers or lattice points. This type of construction also works on the notation layer by employing the notation’s enharmonic strategy.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale = edo31.index_scale([0, 10, 18])
print(scale)
EDOPitchScale([0, 10, 18], 31-EDO)

Tunings with lattice point indices provide the convenience builder method vec_scale() that expects a tuple instead of a lattice point object, making the construction less verbose:

from xenharmlib import PrimeLimitTuning
limit5 = PrimeLimitTuning(5)

scale = limit5.vec_scale(
    [(1, 0, 0), (-1, 0, 1), (0, 1, 0)]
)
print(scale)
PrimeLimitPitchScale([2, 5/2, 3], 5-Limit)

PC Shortform Construction

A common shortform to define scales is to simply define a succession of pitch classes or pitch class symbols with the silent assumption that each pitch class or symbol points to the next highest pitch of that class in relation to its predecessor pitch.

If, for example, we spell the C major scale as \(C, D, E, F, G, A, B\) we assume an equivalency interval section for the first element (e.g. we assume \(C\) to stand for \(C0\)) and then move upwards to find the next D, resulting in \(D0\), from there moving upwards to find the next E, finding \(E0\), and so forth)

The same structure applies if we have a pitch class set from a textbook given in the form [8, 11, 0, 3, 4]: We assume a base interval for the first pitch and then successively move upwards until we find a pitch with the respective class and continue to do so, until we have a scale whose .pc_indices property is exactly like our input values:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale = edo31.pc_scale([18, 0, 10])
print(scale)
EDOPitchScale([18, 31, 41], 31-EDO)

Construction Based on Closest Frequency

In origin contexts based on integer indices, you can build a scale from a list of frequencies with the closest_scale() method that finds the closest representation for each frequency and constructs a scale from it.

The frequency approximation feature can be especially useful for spectralist composition techniques, where the composer takes a sound and extracts the most prominent overtone frequencies to emulate them with chords of another instrument.

from xenharmlib import EDOTuning
from xenharmlib import Frequency

edo31 = EDOTuning(31)

# overtone frequencies measured e.g. by
# a spectrogram plugin or librosa
frequencies = [
    Frequency(f) for f in [230, 410, 460, 500, 690]
]

scale = edo31.closest_scale(frequencies)
print(scale)
EDOPitchScale([118, 144, 149, 153, 167], 31-EDO)

Relation to Other Primitives

Scales can be transformed into three other harmonic primitives: Sequences, Interval Sequences and Interval Fans:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale = edo31.index_scale([2, 4, 5])

print(scale.to_seq())
print(scale.to_interval_seq())
print(scale.to_interval_fan())
EDOPitchSeq([2, 4, 5], 31-EDO)
EDOPitchIntervalSeq([2, 1], 31-EDO)
EDOPitchIntervalFan([0, 2, 3], 31-EDO)

It is also possible to create a scale from any of the other primitives. While sequences can just be given to the scale constructor, interval sequences and interval fans need a pitch or note as a reference point:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

seq = edo31.index_seq([3, 5, 10, 18, 15, 10, 0])
print(edo31.scale(seq))

iseq = edo31.diff_interval_seq([5, 8, 5, 5, 5, 8])
print(edo31.pitch(5).scale(iseq))

ifan = edo31.diff_interval_fan([0, 5, 10, 11, 15])
print(edo31.pitch(5).scale(ifan))
EDOPitchScale([0, 3, 5, 10, 15, 18], 31-EDO)
EDOPitchScale([5, 10, 18, 23, 28, 33, 41], 31-EDO)
EDOPitchScale([5, 10, 15, 16, 20], 31-EDO)

Identity and Equivalency

Two scales are considered identical if each element in one scale has a matching element in the other scale with exactly the same frequency. This relation works across origin contexts, for example, 12-EDO and 24-EDO scales, or Western Note Scales can be identical:

from xenharmlib import EDOTuning
from xenharmlib import UpDownNotation
from xenharmlib import WesternNotation

edo24 = EDOTuning(24)
n_edo24 = UpDownNotation(edo24)
western = WesternNotation()

Cm_1 = edo24.index_scale([0, 8, 14])
Cm_2 = n_edo24.pc_scale('CEG')
Cm_3 = western.pc_scale('CEG')

print(Cm_1 == Cm_2 == Cm_3)
True

Keep in mind that even if two scales might have the same symbolic string representation in a notation, they are not necessarily equal. A G4 in 31-EDO has, for example, a different frequency than a G4 in 12-EDO:

from xenharmlib import EDOTuning
from xenharmlib import UpDownNotation

edo31 = EDOTuning(31)
n_edo31 = UpDownNotation(edo31)

edo12 = EDOTuning(12)
n_edo12 = UpDownNotation(edo12)

Cm_1 = n_edo31.pc_scale('CEG', 4)
Cm_2 = n_edo12.pc_scale('CEG', 4)

print(Cm_1)
print(Cm_2)

print(Cm_1 == Cm_2)
UpDownNoteScale([C4, E4, G4], 31-EDO)
UpDownNoteScale([C4, E4, G4], 12-EDO)
False

As long as two origin contexts have the same equivalency interval scales can also be compared on the criterion of equivalency instead of equality. Scales provide two definitions of equivalency: Set equivalency, which implies that each scale element has an equivalent element in the other scale (and vice versa), and the (stronger) sequential equivalency, which also demands that equivalent elements are at the same position in the scale. To get an intuition for the difference, consider the following example:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([0, 10, 49])
scale_b = edo31.index_scale([18, 31, 41])
scale_c = edo31.index_scale([31, 41, 49])

print(scale_a.is_set_equivalent(scale_b))
print(scale_a.is_seq_equivalent(scale_b))
print(scale_a.is_seq_equivalent(scale_c))
True
False
True

Transposition

Scales can be transposed with the transpose() method. This is typically done by providing an interval object of the same origin context:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale = edo31.index_scale([3, 10, 19])
interval = edo31.diff_interval(8)
print(scale.transpose(interval))
EDOPitchScale([11, 18, 27], 31-EDO)

The transpose() method also accepts a pitch difference. A pitch difference is an integer or lattice point that defines the numeric distance between two frequency representations. In the case of notations with enharmonic ambiguity, xenharmlib makes a guess which note should be picked.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale = edo31.index_scale([3, 10, 19])
print(scale.transpose(8))
EDOPitchScale([11, 18, 27], 31-EDO)

Rotation

Scale objects can be rotated upwards and downwards. On upward rotation, the first element is transposed upwards by the equivalency interval until it is bigger than the last element. On downward rotation the last element is transposed downwards by the equivalency interval until is smaller than the first element.

Rotation is also sometimes called the mode of a scale (for example the “Dorian Mode” is the first upwards rotation of the C major scale). In the context of chords, rotation is called inversion.

Rotation is done using the rotation method of scales, which takes an integer parameter: A positive integer \(k\) will rotate the scale upwards \(k\) times, a negative integer \(-k\) will rotate the scale downwards \(k\) times.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale = edo31.index_scale([3, 10, 18, 24, 29])
print(scale.rotation(3))
EDOPitchScale([24, 29, 34, 41, 49], 31-EDO)

Sequential Properties

Scales implement the typical properties of Python’s lists und tuples. Containment can be tested with the in operator and item and slice retrieval can be achieved with the [] operator. Also, of course, iteration is supported:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale = edo31.index_scale([3, 10, 18, 24, 29])

print(edo31.pitch(10) in scale)
print(scale[2])
print(scale[1:4])

for pitch in scale:
    print('-->', pitch)
True
EDOPitch(18, 31-EDO)
EDOPitchScale([10, 18, 24], 31-EDO)
--> EDOPitch(3, 31-EDO)
--> EDOPitch(10, 31-EDO)
--> EDOPitch(18, 31-EDO)
--> EDOPitch(24, 31-EDO)
--> EDOPitch(29, 31-EDO)

Index Masks and Partial Scales

When examining a scale, you are often interested in focusing only on fragments of it. In many cases, the []-operator is sufficient to extract the desired partial scale from the original, e.g. if you want the major septimal chord scale from a major scale:

from xenharmlib import EDOTuning

edo31 = EDOTuning(31)

# stack 7 fifths. since 0 is C, start with -18 (F)
c_maj_scale = edo31.index_scale(
    k * 18 for k in range(-1, 6)
).pcs_normalized()

c7chord = c_maj_scale[::2]
print(c7chord)
EDOPitchScale([0, 10, 18, 28], 31-EDO)

In other cases, the []-operator will not take you far. What if instead of the major septimal chord scale you want to generate the sus4 chord scale? The appropriate indices (0, 3, 4) don’t have a uniform distance, so they can not be represented by a single slice expression. For reason of this limitation, xenharmlib implements a generalization of a slice called an index mask and the scale method partial() that expects an index mask as its parameter:

from xenharmlib import EDOTuning

edo31 = EDOTuning(31)

# stack 7 fifths. since 0 is C, start with -18 (F)
c_maj_scale = edo31.index_scale(
    k * 18 for k in range(-1, 6)
).pcs_normalized()

c_sus4_chord = c_maj_scale.partial((0, 3, 4))
print(c_sus4_chord)
EDOPitchScale([0, 13, 18], 31-EDO)

An index mask can be defined in multiple ways. In the above snippet, it is simply expressed as a tuple of consecutive integers. For big connected blocks of integers, this can be tedious, so xenharmlib offers a short variant with Python’s ellipsis symbol (...). An ellipsis indicates that all indices between two values should be part of the mask as well:

from xenharmlib import EDOTuning

edo31 = EDOTuning(31)

# stack 7 fifths. since 0 is C, start with -18 (F)
c_maj_scale = edo31.index_scale(
    k * 18 for k in range(-1, 6)
).pcs_normalized()

partial = c_maj_scale.partial((1, ..., 4, 6))
print(partial)
EDOPitchScale([5, 10, 13, 18, 28], 31-EDO)

Similar to Python’s slices, the first value and the last value of mask definitions can be omitted entailing the familiar behavior: If a mask begins with an ellipsis, the first value is implicitly 0. If a mask ends with an ellipsis, the last value is the last index of the scale the mask is applied to:

from xenharmlib import EDOTuning

edo31 = EDOTuning(31)

# stack 7 fifths. since 0 is C, start with -18 (F)
c_maj_scale = edo31.index_scale(
    k * 18 for k in range(-1, 6)
).pcs_normalized()

partial1 = c_maj_scale.partial((..., 3, 6))
partial2 = c_maj_scale.partial((3, ...))

print(partial1)
print(partial2)
EDOPitchScale([0, 5, 10, 13, 28], 31-EDO)
EDOPitchScale([13, 18, 23, 28], 31-EDO)

Please note that, in contrast to slices, the index after an ellipsis is included in the mask:

from xenharmlib import EDOTuning

edo31 = EDOTuning(31)

# stack 7 fifths. since 0 is C, start with -18 (F)
c_maj_scale = edo31.index_scale(
    k * 18 for k in range(-1, 6)
).pcs_normalized()

partial_scale = c_maj_scale.partial((1, ..., 3))
scale_slice = c_maj_scale[1:3]

print(partial_scale)
print(scale_slice)
EDOPitchScale([5, 10, 13], 31-EDO)
EDOPitchScale([5, 10], 31-EDO)

For convenience reasons, one-element mask expressions can also be given without being wrapped by the tuple constructor:

from xenharmlib import EDOTuning

edo31 = EDOTuning(31)

# stack 7 fifths. since 0 is C, start with -18 (F)
c_maj_scale = edo31.index_scale(
    k * 18 for k in range(-1, 6)
).pcs_normalized()

partial_scale = c_maj_scale.partial((1, ..., 3))
scale_slice = c_maj_scale[1:3]

partial = c_maj_scale.partial(1)
print(partial)
EDOPitchScale([5], 31-EDO)

If you want to receive the complement of a mask, you can resort to the partial_not() method:

from xenharmlib import EDOTuning

edo31 = EDOTuning(31)

# stack 7 fifths. since 0 is C, start with -18 (F)
c_maj_scale = edo31.index_scale(
    k * 18 for k in range(-1, 6)
).pcs_normalized()

scale = c_maj_scale.partial_not((0, 1, 2, 6, ...))
print(scale)
EDOPitchScale([13, 18, 23], 31-EDO)

If you want to receive both, use the partition() method:

from xenharmlib import EDOTuning

edo31 = EDOTuning(31)

# stack 7 fifths. since 0 is C, start with -18 (F)
c_maj_scale = edo31.index_scale(
    k * 18 for k in range(-1, 6)
).pcs_normalized()

c_sus4, leftover = c_maj_scale.partition((0, 3, 4))

print(c_sus4)
print(leftover)
EDOPitchScale([0, 13, 18], 31-EDO)
EDOPitchScale([5, 10, 23, 28], 31-EDO)

Normalizations

Scales can be normalized in various ways. From the Quickstart you already know the pcs_normalized() method, which transposes all elements of the scale to the first base interval. However, there are other methods of normalization that can be useful depending on what you want to do.

One downside of pitch class set normalization is that the scale does not necessarily retain its original root note. Often you want to reduce a scale to one base interval, but keep the root note. For this, xenharmlib offers the period_normalized() method:

from xenharmlib import EDOTuning

edo31 = EDOTuning(31)
scale = edo31.index_scale([2, 8, 10, 19, 24, 29, 31, 35])
print(scale)

pn_scale = scale.period_normalized()
print(pn_scale)
EDOPitchScale([2, 8, 10, 19, 24, 29, 31, 35], 31-EDO)
EDOPitchScale([2, 4, 8, 10, 19, 24, 29, 31], 31-EDO)

Sometimes you want to compare the structure of different scales without considering the root note; for example, you want to know if a scale is a major scale by comparing it with the definition of the C major scale. Here, the method zero_normalized() can help that will transpose the scale so the root note is the zero element (C0 in UpDownNotation, the pitch with index 0 on the pitch layer):

from xenharmlib import EDOTuning
from xenharmlib import UpDownNotation

edo12 = EDOTuning(12)
n_edo12 = UpDownNotation(edo12)
c_maj_scale = n_edo12.natural_scale()

scale = n_edo12.pc_scale(
    ['A', 'B', 'C#', 'D', 'E', 'F#', 'G#']
)

zn_scale = scale.zero_normalized()
print(zn_scale)
print(zn_scale.is_notated_same(c_maj_scale))
UpDownNoteScale([C0, D0, E0, F0, G0, A0, B0], 12-EDO)
True

Note that zero normalization does not guarantee that the elements of the resulting scale are contained in one base interval:

polychord_Db_G = n_edo12.pc_scale(
    ['G', 'B', 'D', 'F', 'Ab', 'C#']
)

zn_scale = polychord_Db_G.zero_normalized()
print(zn_scale)
UpDownNoteScale([C0, E0, G0, Bb0, Db1, F#1], 12-EDO)

Often in textbooks, scales are described in a form where the root note is C0 and the scale elements all reside in the first base interval. This form is equivalent to the combined application of zero normalization and period normalization:

norm = polychord_Db_G.period_normalized().zero_normalized()
print(norm)
UpDownNoteScale([C0, Db0, E0, F#0, G0, Bb0], 12-EDO)

For this type of normalization, there is a convenient shortcut available, the method zp_normalized() (“zero-period normalized”):

norm = polychord_Db_G.zp_normalized()
print(norm)
UpDownNoteScale([C0, Db0, E0, F#0, G0, Bb0], 12-EDO)

Another common normal form is depicting the scale as a series between two equivalent notes (for example depicting the notes of F major between F4 and F5). This form can be calculated using the method plusone_normalized():

edo12 = EDOTuning(12)
n_edo12 = UpDownNotation(edo12)

scale = n_edo12.pc_scale(
    ['F', 'G#', 'C', 'F#', 'G']
)
print(scale)

pn_scale = scale.plusone_normalized()
print(pn_scale)
UpDownNoteScale([F0, G#0, C1, F#1, G1], 12-EDO)
UpDownNoteScale([F0, F#0, G0, G#0, C1, F1], 12-EDO)

Reflection

Reflection is a generalization of the post-tonal inversion operation on pitch class sets. A reflection is defined as calculating the interval from each scale element to a designated axis note/pitch and applying the resulting interval on the axis to get the target note/pitch.

Without an argument, the reflection() method assumes the axis to be the zero element of the origin context.

c_hypodorian = n_edo12.pc_scale(
    ['B', 'C', 'D', 'E', 'F', 'G', 'A']
)
refl = c_hypodorian.reflection()
print(refl)
UpDownNoteScale([Eb-2, F-2, G-2, Ab-2, Bb-2, C-1, Db-1], 12-EDO)

Observe how reflection across the zero element is equivalent to applying the pitch class set inversion operation:

print(c_hypodorian.pc_indices)
print(refl.pc_indices)
[11, 0, 2, 4, 5, 7, 9]
[3, 5, 7, 8, 10, 0, 1]

A scale can also be reflected using a different axis:

c_hypodorian = n_edo12.pc_scale(
    ['B', 'C', 'D', 'E', 'F', 'G', 'A']
)
axis = n_edo12.note('B', 0)
refl = c_hypodorian.reflection(axis)
print(refl)
UpDownNoteScale([C#0, D#0, E#0, F#0, G#0, A#0, B0], 12-EDO)

Retuning by Closest Correspondence

In the beginning we talked about the possibility to construct scales from a list of frequencies. The retune_closest() provides an extra level of convenience: With it you can take a scale from one origin context and transform it into a scale from a different origin context:

from xenharmlib import EDOTuning
from xenharmlib import WesternNotation
from xenharmlib import UpDownNotation

edo31 = EDOTuning(31)
n_edo31 = UpDownNotation(edo31)
western = WesternNotation()

scale = n_edo31.pc_scale(['C', 'D#', 'E', 'G'], 4)
print(scale)

retuned = scale.retune_closest(western)
print(retuned)
UpDownNoteScale([C4, D#4, E4, G4], 31-EDO)
WesternNoteScale([C4, D#4, E4, G4])

Please note that retuning by closest frequency is not necessarily a one-to-one correspondence. If we try to reverse the retuning result, we will not receive the scale we started with:

retuned_back = retuned.retune_closest(n_edo31)
print(retuned_back)
print(scale == retuned_back)
UpDownNoteScale([C4, Eb4, E4, G4], 31-EDO)
False

This is because in the Western equal-tempered system “D#” and “Eb” represent the same frequency; however, in a 31-EDO system they are distinct:

print(western.note('D#', 4) == western.note('Eb', 4))
print(n_edo31.note('D#', 4) == n_edo31.note('Eb', 4))
True
False

Retuning from 31-EDO to a Western system is like lowering the resolution in an image. Once you have done it, the more “fine-grained” data is lost. Since distinct notes or pitches in one system can “collapse” into one in a system with less granularity, retuning scales can also have the effect that the resulting scale is shorter than the original one:

scale = n_edo31.pc_scale(['C', 'D#', 'Eb', 'E', 'G'], 4)
print(scale)

retuned = scale.retune_closest(western)
print(retuned)
UpDownNoteScale([C4, D#4, Eb4, E4, G4], 31-EDO)
WesternNoteScale([C4, D#4, E4, G4])

Set operations

Scales support most of the typical set operations that you are familiar with from the built-in Python sets (with slightly different names):

Please note that set operations without additional parameters test for equality and not equivalency, so if one scale includes C0 and the second scale includes C1, they are not considered the same element with regard to intersection, difference or symmetric difference.

Union (\(A \cup B\))

The union() operation (also possible to use with the infix operator |) returns a new scale with all elements from both scales.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([3, 10, 18, 24, 29])
scale_b = edo31.index_scale([4, 10, 18, 24])

print(scale_a.union(scale_b))
print(scale_a | scale_b)
EDOPitchScale([3, 4, 10, 18, 24, 29], 31-EDO)
EDOPitchScale([3, 4, 10, 18, 24, 29], 31-EDO)

Intersection (\(A \cap B\))

The intersection() operation (also possible to use with the infix operator &) returns a new scale containing only elements that exist in both scales.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([3, 10, 18, 24, 29])
scale_b = edo31.index_scale([4, 10, 18, 24])

print(scale_a.intersection(scale_b))
print(scale_a & scale_b)
EDOPitchScale([10, 18, 24], 31-EDO)
EDOPitchScale([10, 18, 24], 31-EDO)

It is possible to modify the intersection operation so that equivalent pitches (e.g., C4 and C5) are considered equal. This approach is especially useful if you want to find common pitch classes that bridge one scale to the other.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([0, 4, 18, 24])
scale_b = edo31.index_scale([20, 24, 31, 35])

print(scale_a.intersection(scale_b, ignore_bi_index=True))
EDOPitchScale([0, 4, 24, 31, 35], 31-EDO)

Difference (\(A \setminus B\))

The difference() operation (also possible to use with the infix operator -) returns a new scale contatining only elements that exist in the first scale, but not in the second one.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([3, 10, 18, 24, 29])
scale_b = edo31.index_scale([4, 10, 18, 24])

print(scale_a.difference(scale_b))
print(scale_a - scale_b)
EDOPitchScale([3, 29], 31-EDO)
EDOPitchScale([3, 29], 31-EDO)

It is possible to modify the difference operation so that equivalent pitches (e.g. C4 and C5) are considered equal.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([0, 4, 18, 24])
scale_b = edo31.index_scale([20, 24, 31, 35])

print(scale_a.difference(scale_b, ignore_bi_index=True))
EDOPitchScale([18], 31-EDO)

Symmetric Difference (\(A \triangle B\))

The symmetric_difference() operation (also possible to use with the infix operator ^) returns a new scale containing only elements that exist in the first scale, but not in the second one combined with elements that exist in the second scale but not in the first one.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([3, 10, 18, 24, 29])
scale_b = edo31.index_scale([4, 10, 18, 24])

print(scale_a.symmetric_difference(scale_b))
print(scale_a ^ scale_b)
EDOPitchScale([3, 4, 29], 31-EDO)
EDOPitchScale([3, 4, 29], 31-EDO)

It is possible to modify the symmetric difference operation so that equivalent pitches (e.g. C4 and C5) are considered equal.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([0, 4, 18, 24])
scale_b = edo31.index_scale([20, 24, 31, 35])

print(scale_a.symmetric_difference(scale_b, ignore_bi_index=True))
EDOPitchScale([18, 20], 31-EDO)

Subset Relations (\(A \subset B\), \(A \subseteq B\))

The is_subset() operation (also possible to use with the infix operator <= for “subset or equal” and < for “proper subset”) returns True if all the elements in one scale exist in the other.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([3, 10, 18, 24, 29])
scale_b = edo31.index_scale([3, 10, 18, 24])

print(scale_b.is_subset(scale_a))
print(scale_a.is_subset(scale_a, proper=True))

print(scale_b <= scale_a)
print(scale_a < scale_a)
True
False
True
False

It is possible to modify the subset operation so that equivalent pitches (e.g., C4 and C5) are considered equal.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([0, 4, 18])
scale_b = edo31.index_scale([31, 35, 49])

print(scale_b.is_subset(scale_a, ignore_bi_index=True))
print(scale_b.is_subset(scale_a, proper=True, ignore_bi_index=True))
True
False

Disjoint Sets (\(A \cap B = \emptyset\))

Two scales can be tested if they do not contain any common elements (are disjoint) by the is_disjoint() operation.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([3, 10, 18, 24, 29])
scale_b = edo31.index_scale([4, 10, 18, 24])
scale_c = edo31.index_scale([4, 11, 19, 25])

print(scale_a.is_disjoint(scale_b))
print(scale_a.is_disjoint(scale_c))
False
True

It is possible to modify the disjoint test so that equivalent pitches (e.g. C4 and C5) are considered equal.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

scale_a = edo31.index_scale([0, 4, 18])
scale_b = edo31.index_scale([31, 35, 49])

print(scale_a.is_disjoint(scale_b, ignore_bi_index=True))
False

Enharmonic ambiguity and set operations

When we speak of enharmonic equivalence, we mean that two notes refer to the same pitch, but are notated differently; that is: we differentiate between the sound of a note and its function. The question if Eb and D# in 12-EDO are the same can only be answered if we further specify: Same in regard to what?

In regard to notation Eb and D# are distinct; however, in regard to sound, they are the same. Spoken in mathematical terms: Notation systems in which there is enharmonic equivalence define two equivalency relations on the space of notes. We already came across both of them in the chapter about notes: The == operator considers notes as equal if their frequency is the same, while the method is_notated_same checks for notation equality.

The existence of two different equivalency relations on the space of notes poses a problem for us when we want to define scales and operations on them: Note scales are defined as ‘an ordered set of unique notes’, meaning that no two notes in a scale are the same. Now should this refer to notation or sound? After all, in cases where we are interested in the functional relationship of scales, we would want to treat D# and Eb as distinct, while in other cases we want them to be considered equal.

Xenharmlib defines the uniqueness restriction of the scale as “unique in pitch”, meaning that no two notes e_flat and d_sharp with e_flat == d_sharp can exist in the scale at the same time. However, it provides special variants for some set operations that honor notational differences (More on that later).

The reason for choosing the == equivalency as the base for the uniqueness property is to provide consistency with the underlying pitch scale object:

e_flat = n_edo12.note('Eb', 0)
d_sharp = n_edo12.note('D#', 0)

scale_a = n_edo12.scale([e_flat])
scale_b = n_edo12.scale([d_sharp])

note_union = scale_a.union(scale_b)
pitch_union = scale_a.pitch_scale.union(
    scale_b.pitch_scale
)

# among others, these would break if we treat
# Eb and D# as distinct in note scales
assert note_union.frequencies == pitch_union.frequencies
assert len(note_union) == len(pitch_union)
pitch_intervals = [
    i.pitch_interval for i in note_union.to_intervals()
]
assert pitch_intervals == pitch_union.to_intervals()

This choice also allows implementing the scale’s == operator as a consistent logical extension of the note’s == operator, creating a closed set algebra for scales under == with all basic set operations. Think of it this way: Notes are variable names holding the pitch as their value, with some variables being equal:

\[\begin{split}A = \{a, b\}\\ B = \{c, d\}\\ \\ b = c \rightarrow\\ A \cap B = \{b\} = \{c\}\\ A \cup B = \{a, b, d\} == \{a, c, d\}\\\end{split}\]

How does xenharmlib then choose the note representation of a pitch when more than one enharmonically equivalent note is present? During construction of a note scale, it chooses on the principle of first encounter:

scale = n_edo12.scale(
    [
        n_edo12.note('D#', 0),
        n_edo12.note('Eb', 0),
    ]
)
assert len(scale) == 1
assert scale[0].pc_symbol == 'D#'

When using set operations Xenharmlib always prefers the representation of the scale that executed the operation:

c = n_edo12.note('C', 0)
g = n_edo12.note('G', 0)
e_flat = n_edo12.note('Eb', 0)
d_sharp = n_edo12.note('D#', 0)

scale_a = n_edo12.scale([c, e_flat])
scale_b = n_edo12.scale([d_sharp, g])

# A v B / A ^ B

union_a_b = scale_a.union(scale_b)
assert union_a_b.is_notated_same(
    n_edo12.scale([c, e_flat, g])
)

intersection_a_b = scale_a.intersection(scale_b)
assert intersection_a_b.is_notated_same(
    n_edo12.scale([e_flat])
)

# B v A / B ^ A

union_b_a = scale_b.union(scale_a)
assert union_b_a.is_notated_same(
    n_edo12.scale([c, d_sharp, g])
)

intersection_b_a = scale_b.intersection(scale_a)
assert intersection_b_a.is_notated_same(
    n_edo12.scale([d_sharp])
)

Using the == operator, we see that even though the results are different in terms of representation, commutativity with regard to union and intersection is still preserved:

assert union_a_b == union_b_a
assert intersection_a_b == intersection_b_a

In contrast, using the is_notated_same equivalency relation on the two result pairs does not preserve commutativity:

assert not union_a_b.is_notated_same(union_b_a)
assert not intersection_a_b.is_notated_same(intersection_b_a)

Since we chose the == operator as a basis for scale uniqueness, there is no way to define a closed set algebra for the is_notated_same relation at the same time, because the union operation would not be well defined.

However, on certain set operations, we can define the option to use the is_notated_same relation. For intersection, we can define that same-sounding, but differently notated notes should not be part of the intersection. For difference, we can define that such notes will not be removed from the first scale. In the same way, we can define the relationships is_disjoint, is_subset, and is_superset.

These stricter variants of the set operations are called:

c = n_edo12.note('C', 0)
g = n_edo12.note('G', 0)
e_flat = n_edo12.note('Eb', 0)
d_sharp = n_edo12.note('D#', 0)

scale_a = n_edo12.scale([c, e_flat])
scale_b = n_edo12.scale([d_sharp, g])

intersection_a_b = scale_a.note_intersection(scale_b)
assert len(intersection_a_b) == 0

diff_a_b = scale_a.note_difference(scale_b)
assert len(diff_a_b) == 2

In combination with the ignore_bi_index flag, we can, for example, build a function that returns the pitch class symbols of the common notes of two scales while being aware of the key:

def common_notes_key_aware(scale_a, scale_b):
    scale_i = scale_a.note_intersection(
        scale_b,
        ignore_bi_index=True
    ).pcs_normalized()
    return scale_i

# F# major and Gb major have exactly the same
# pitches, however, they are notated differently
f_sharp_maj = n_edo12.natural_scale().transpose(
    n_edo12.shorthand_interval('A', 4)
)
g_flat_maj = n_edo12.natural_scale().transpose(
    n_edo12.shorthand_interval('d', 5)
)

# D# minor is the relative key to F# major
d_sharp_min = f_sharp_maj.rotation(5)

print(common_notes_key_aware(f_sharp_maj, g_flat_maj))
print(common_notes_key_aware(g_flat_maj, d_sharp_min))
print(common_notes_key_aware(f_sharp_maj, d_sharp_min))
UpDownNoteScale([], 12-EDO)
UpDownNoteScale([], 12-EDO)
UpDownNoteScale([C#0, D#0, E#0, F#0, G#0, A#0, B0], 12-EDO)