Interval Sequences

Interval sequences provide an abstract way to represent musical structures by storing the relative distances between notes, rather than their absolute pitches. This allows for the definition of abstract scale, sequence, and chord types - for example, representing the major scale as such, rather than as specific instances like C major or E major.

One method for generating an interval sequence is to begin with a scale or sequence of frequency representations and call their to_interval_seq() method. Xenharmlib will then calculate the intervals between successive notes and return the resulting interval sequence:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

c_maj_scale = edo31.index_scale(
    k * 18 for k in range(-1, 6)
).pcs_normalized()

major_seq = c_maj_scale.to_interval_seq()
print(major_seq)
EDOPitchIntervalSeq([5, 5, 3, 5, 5, 5], 31-EDO)

As you can see, you obtained a sequence of major and minor seconds in the order that they occur in the scale. The resulting interval sequence is only one possible representation of the major scale. Another representation, more common in textbooks, is derived from a form of the scale in which the series is bookended by two equivalent notes. This scale form can be obtained by the method plusone_normalized():

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

c_maj_scale = edo31.index_scale(
    k * 18 for k in range(-1, 6)
).pcs_normalized()

alt_c_maj = c_maj_scale.plusone_normalized()
alt_maj_seq = alt_c_maj.to_interval_seq()

print(alt_c_maj)
print(alt_maj_seq)
EDOPitchScale([0, 5, 10, 13, 18, 23, 28, 31], 31-EDO)
EDOPitchIntervalSeq([5, 5, 3, 5, 5, 5, 3], 31-EDO)

Both methods for defining the major scale have valid use cases. It is up to you to decide which form fits your task.

Interval sequences implement the complete set of methods from Python’s built-in lists and tuples that do not mutate the object. This means that interval sequences can be treated as if they were lists or tuples of intervals and used as a drop-in replacement.

Deriving Interval Sequences from Intervals

Like other harmonic “collection primitives”, interval sequences can be built by assembly from the primitives they contain, in this case intervals:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

M2 = edo31.diff_interval(5)
m2 = edo31.diff_interval(3)

major_seq = edo31.interval_seq([M2, M2, m2, M2, M2, M2])
print(major_seq)
EDOPitchIntervalSeq([5, 5, 3, 5, 5, 5], 31-EDO)

Index-based Construction

Interval sequences can also be constructed by providing pitch differences which (depending on origin context) can be integers or lattice points. In case of notations, xenharmlib uses the notation’s enharmonic strategy to transform differences into full note interval objects.

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

major_seq = edo31.diff_interval_seq([5, 5, 3, 5, 5, 5])
print(major_seq)
EDOPitchIntervalSeq([5, 5, 3, 5, 5, 5], 31-EDO)

Origin contexts built on lattice point indexing also allow construction from an iterable of tuples with vec_interval_seq() as an alternative to the more verbose above method, which expects a full LatticePoint object.

from xenharmlib import PrimeLimitTuning
limit3 = PrimeLimitTuning(3)

major_seq = limit3.vec_interval_seq(
    [(-3, 2), (-3, 2), (8, -5), (-3, 2), (-3, 2), (-3, 2)]
)
print(major_seq)
PrimeLimitPitchIntervalSeq([9/8, 9/8, 256/243, 9/8, 9/8, 9/8], 3-Limit)

Construction Based on Closest Frequency Ratio

Origin contexts based on integer indices allow construction based on the approximation of frequency ratios. Given an arbitrary iterable of frequency ratios, the closest_interval_seq() method returns the interval sequence of an origin context that is closest to it:

from xenharmlib import EDOTuning
from xenharmlib import FrequencyRatio

edo31 = EDOTuning(31)
ratios = [
    FrequencyRatio(5, 4),
    FrequencyRatio(6, 5),
]

iseq = edo31.closest_interval_seq(ratios)
print(iseq)
EDOPitchIntervalSeq([10, 8], 31-EDO)

Iteration

Like other sequence types, interval sequences support iteration:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

iseq = edo31.diff_interval_seq([10, 8, 7, 10])

for interval in iseq:
    print('-->', interval)
--> EDOPitchInterval(10, 31-EDO)
--> EDOPitchInterval(8, 31-EDO)
--> EDOPitchInterval(7, 31-EDO)
--> EDOPitchInterval(10, 31-EDO)

Containment

If you want to know if a specific interval is contained inside of an interval sequence, you can use the in operator:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

iseq = edo31.diff_interval_seq([10, 8, 7, 10])
interval_a = edo31.diff_interval(7)
interval_b = edo31.diff_interval(9)

print(interval_a in iseq)
print(interval_b in iseq)
True
False

Identity

Two interval sequences are considered identical if each interval in one interval sequence corresponds to another interval in the other sequence at the same position. This relation works across origin contexts; for example, 12-EDO and 24-EDO interval sequences, or western note interval sequences 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.diff_interval_seq([8, 6])
Cm_2 = n_edo24.interval_seq(
    [
        n_edo24.shorthand_interval('M', 3),
        n_edo24.shorthand_interval('m', 3),
    ]
)
Cm_3 = western.interval_seq(
    [
        western.shorthand_interval('M', 3),
        western.shorthand_interval('m', 3),
    ]
)

print(Cm_1 == Cm_2 == Cm_3)
True

Keep in mind that even if two interval sequences might have the same symbolic string representation in a notation, they are not necessarily equal. A major third interval in 31-EDO has, for example, a different frequency ratio than a major third interval 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.interval_seq(
    [
        n_edo31.shorthand_interval('M', 3),
        n_edo31.shorthand_interval('m', 3),
    ]
)
Cm_2 = n_edo12.interval_seq(
    [
        n_edo12.shorthand_interval('M', 3),
        n_edo12.shorthand_interval('m', 3),
    ]
)

print(Cm_1)
print(Cm_2)

print(Cm_1 == Cm_2)
UpDownNoteIntervalSeq([M3, m3], 31-EDO)
UpDownNoteIntervalSeq([M3, m3], 12-EDO)
False

Equality/Identity also translates to Python’s built-in set type. If two interval sequences are considered equal, combining them in a Python set will result in a one-element set:

from xenharmlib import EDOTuning
from xenharmlib import WesternNotation

edo24 = EDOTuning(24)
western = WesternNotation()

iseq_a = edo24.diff_interval_seq([4, 10, 4, 18])
iseq_b = western.diff_interval_seq([2, 5, 2, 9])

# since the second element is equal to the first
# only the first element will be added to the set
print({iseq_a, iseq_b})
{EDOPitchIntervalSeq([4, 10, 4, 18], 24-EDO)}

Item Retrieval and Slicing

Single intervals from the sequence can be obtained by their index. You can also use slices (including step size) to extract portions of an interval sequence:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

iseq = edo31.diff_interval_seq([10, 8, 7, 10])

print(iseq[2])
print(iseq[1:3])
print(iseq[::2])
EDOPitchInterval(7, 31-EDO)
EDOPitchIntervalSeq([8, 7], 31-EDO)
EDOPitchIntervalSeq([10, 7], 31-EDO)

Counting

For statistical analysis, xenharmlib can calculate the number of times a specific interval occurs in an interval sequence:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

iseq = edo31.diff_interval_seq([10, 8, 7, 10])
interval = edo31.diff_interval(7)

print(iseq.count(interval))
1

Concatenation

Interval sequences can be “glued together” with the + operator. (In programming known under the term “concatenation”) This can be especially useful when programmatically creating polychords.

Think of defining the major triad and the minor triad as an abstract sequence, resulting in interval sequences with two intervals each. Concatenation of the interval sequences then means the following in the scale space: Take a triad and then, using the highest note in the chord as a new root, add another triad on top of it.

Putting a minor on top of a major then gives the abstract sequence for the “dominant seventh add ninth” polychord. Putting a major on top of a minor gives the abstract “minor-major ninth chord”:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

major = edo31.diff_interval_seq([10, 8])
minor = edo31.diff_interval_seq([8, 10])

print(major + minor)
print(minor + major)
EDOPitchIntervalSeq([10, 8, 8, 10], 31-EDO)
EDOPitchIntervalSeq([8, 10, 10, 8], 31-EDO)

Repetition

Repetition of an interval sequence can be achieved by multiplying (*) an interval sequence with an integer.

This technique can be useful if you have an interval sequence that first ascends and then descends without returning to the point of origin. Repeating such an interval sequence results in a wave of “upward and downward motions” that in its entirety slowly moves upwards:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

iseq = edo31.diff_interval_seq([5, 7, 5, -7, -5])
print(3 * iseq)
EDOPitchIntervalSeq([5, 7, 5, -7, -5, 5, 7, 5, -7, -5, 5, 7, 5, -7, -5], 31-EDO)

Index Masks and Partial Interval Sequences

Like with scales, the interval sequence primitive allows extraction of “substructures” with the partial() method, which expects an index mask expression, i.e., a tuple with indices pointing to sequence elements that should be extracted.

The mask (1, 3, 4), for example, extracts the second, fourth, and fifth element of the sequence (like with item retrieval indices in a mask expression start with 0):

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

iseq = edo31.diff_interval_seq([10, 8, 10, 9, 10, 8])
print(iseq.partial((1, 3, 4)))
EDOPitchIntervalSeq([8, 9, 10], 31-EDO)

Mask expressions targeted at longer continuous spans inside the interval sequence can be written as a “shortform” with the ellipsis symbol (...):

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

iseq = edo31.diff_interval_seq([10, 8, 10, 9, 10, 8])

# an ellipsis as a prefix matches all elements from
# the start of the sequence until and including (!)
# the first mask index
print(iseq.partial((..., 3, 4)))

# an ellipsis between two indices matches the two indices
# in the sequence and all elements between them
print(iseq.partial((1, ..., 4)))

# an ellipsis at the end matches all remaining indices
# in the sequence after the last mask index
print(iseq.partial((0, 3, ...)))
EDOPitchIntervalSeq([10, 8, 10, 9, 10], 31-EDO)
EDOPitchIntervalSeq([8, 10, 9, 10], 31-EDO)
EDOPitchIntervalSeq([10, 9, 10, 8], 31-EDO)

Selection can also be inverted with the partial_not() method that returns all elements not covered by the mask expression:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

iseq = edo31.diff_interval_seq([10, 8, 10, 9, 10, 8])
print(iseq.partial_not((1, 3, 4)))
EDOPitchIntervalSeq([10, 10, 8], 31-EDO)

To get both the substructure indicated by the mask and its complement as a tuple, you can use the partition() method:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

iseq = edo31.diff_interval_seq([10, 8, 10, 9, 10, 8])
a, b = iseq.partition((1, 3, 4))
print(a)
print(b)
EDOPitchIntervalSeq([8, 9, 10], 31-EDO)
EDOPitchIntervalSeq([10, 10, 8], 31-EDO)

Templating and Categorization

One of the main use cases for interval sequences is templating. You can, for example, define abstract scales (like “major scale”) and then make scale instances from it:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

M2 = edo31.diff_interval(5)
m2 = edo31.diff_interval(3)

major_seq = edo31.interval_seq([M2, M2, m2, M2, M2, M2])
Emaj_scale = edo31.pitch(10).scale(major_seq)
print(Emaj_scale)
EDOPitchScale([10, 15, 20, 23, 28, 33, 38], 31-EDO)

Vice versa, interval sequences can also be used to categorize scale objects. In the next snippet, we devise three functions that generate abstract definitions of the three types of minor scales for a given context and an additional identifier function that takes a scale and returns the type of minor scale.

def natural_minor(context):
    return context.pc_scale(
        ['A', 'B', 'C', 'D', 'E', 'F', 'G']
    ).to_interval_seq()

def harmonic_minor(context):
    return context.pc_scale(
        ['A', 'B', 'C', 'D', 'E', 'F', 'G#']
    ).to_interval_seq()

def melodic_minor(context):
    return context.pc_scale(
        ['A', 'B', 'C', 'D', 'E', 'F#', 'G#']
    ).to_interval_seq()

def which_minor(scale):
    seq = scale.to_interval_seq()
    context = scale.origin_context
    if seq == natural_minor(context): return 'natural'
    if seq == harmonic_minor(context): return 'harmonic'
    if seq == melodic_minor(context): return 'melodic'
    raise ValueError('Scale is not minor')

The generic approach allows us to reuse the functions for all notations supporting Western-style naturals and sharps and flats.

from xenharmlib import WesternNotation

western = WesternNotation()
scale = western.pc_scale(['C', 'D', 'Eb', 'F', 'G', 'A', 'B'])
print(which_minor(scale))
melodic