Interval Fans

_images/interval_fan_illustration.png

An interval fan is a harmonic primitive describing the relative interval distances of the elements of a scale or a sequence from the vantage point of a tonal center. This is in contrast to the interval sequence primitive, which describes the relative distances of notes/pitches in a scale or sequence to each other.

Interval fans describe movement in relation to a tonal center without specifying that center. This situates them closely to both transformation theory and tonal music. However, they are also simply a common form to denote an abstract scale, e.g. in Scale Workshop or Scala.

We start with an example from tonal analysis:

from xenharmlib import WesternNotation
western = WesternNotation()

# the melodic structure of the first measure of Bach's
# Fuga 1 from "Das Wohltemperierte Klavier" (BWV 846)

seq = western.seq([western.note(pcs, 4) for pcs in 'CDEFGFEA'])
tonic = western.note('C', 4)

ifan = seq.to_interval_fan(tonic)
print(ifan)
WesternNoteIntervalFan([P1, M2, M3, P4, P5, P4, M3, M6])

We received an abstract formulation of the melodic line. We start from “the tonic” (whatever it is), then move a minor second over the tonic, then a minor third, etc. We can now analyze the piece from a different viewpoint, not as a sequence of notes, but a sequence of distances from the “center of gravity”. Vice versa, we can use the interval fan to descend again to the layer of notes, “de-abstract” it by giving it a new tonic, for example D#:

new_tonic = western.note('D#', 4)
print(new_tonic.seq(ifan))
WesternNoteSeq([D#4, E#4, Fx4, G#4, A#4, G#4, Fx4, B#4])

We can also start directly from an interval fan and then use it as a template to “stamp out” different scales and sequences. Especially in writings about Just Intonation, scales are often given as interval fans:

from xenharmlib import PrimeLimitTuning

limit5 = PrimeLimitTuning(5)

major_chord = limit5.rs_interval_fan(['1/1', '5/4', '3/2'])
G0 = limit5.rs_pitch('3/2')

print(G0.scale(major_chord))
PrimeLimitPitchScale([3/2, 15/8, 9/4], 5-Limit)

Deriving Interval Fans from Intervals

You can create interval fans by an iterable of single interval objects with the default builder method interval_fan():

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

P1 = edo31.diff_interval(0)
M3 = edo31.diff_interval(10)
P5 = edo31.diff_interval(18)

ifan = edo31.interval_fan([P1, M3, P5])
print(ifan)
EDOPitchIntervalFan([0, 10, 18], 31-EDO)

Element-wise Construction

Interval fans 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)

P1 = edo31.diff_interval(0)
M3 = edo31.diff_interval(10)
P5 = edo31.diff_interval(18)

# start with an empty fan
ifan = edo31.interval_fan()

for i in [P1, M3, P5]:
    ifan = ifan.with_interval(i)
    print(ifan)
EDOPitchIntervalFan([0], 31-EDO)
EDOPitchIntervalFan([0, 10], 31-EDO)
EDOPitchIntervalFan([0, 10, 18], 31-EDO)

Instead of appending intervals at the end of the fan, users can also give an additional parameter to define where the new interval should appear:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

P1 = edo31.diff_interval(0)
M3 = edo31.diff_interval(10)
P5 = edo31.diff_interval(18)

# start with an empty fan
ifan = edo31.interval_fan()

for i in [P1, M3, P5]:
    ifan = ifan.with_interval(i, 0)
    print(ifan)
EDOPitchIntervalFan([0], 31-EDO)
EDOPitchIntervalFan([10, 0], 31-EDO)
EDOPitchIntervalFan([18, 10, 0], 31-EDO)

Index-based Construction

Interval fans 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)

ifan = edo31.diff_interval_fan([0, 10, 18])
print(ifan)
EDOPitchIntervalFan([0, 10, 18], 31-EDO)

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

from xenharmlib import PrimeLimitTuning
limit3 = PrimeLimitTuning(3)

major_triad = limit3.vec_interval_fan(
    [(0, 0), (-6, 4), (-1, 1)]
)
print(major_triad)
PrimeLimitPitchIntervalFan([1, 81/64, 3/2], 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_fan() method returns the interval fan of an origin context that is closest to it:

from xenharmlib import EDOTuning
from xenharmlib import FrequencyRatio

edo31 = EDOTuning(31)
ratios = [
    FrequencyRatio(1, 1),
    FrequencyRatio(5, 4),
    FrequencyRatio(3, 2),
    FrequencyRatio(7, 4),
]

ifan = edo31.closest_interval_fan(ratios)
print(ifan)
EDOPitchIntervalFan([0, 10, 18, 25], 31-EDO)

Iteration

Interval fans support iteration over their elements:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

ifan = edo31.diff_interval_fan([10, 8, 7, 10])

for interval in ifan:
    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 fan, you can use the in operator:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

ifan = edo31.diff_interval_fan([10, 8, 7, 10])
interval_a = edo31.diff_interval(7)
interval_b = edo31.diff_interval(9)

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

Identity

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

print(Cm_1)
print(Cm_2)

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

Equality/Identity also translates to Python’s built-in set type. If two interval fans 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()

ifan_a = edo24.diff_interval_fan(
    [0, 4, 10, 14, 18]
)
ifan_b = western.diff_interval_fan(
    [0, 2, 5, 7, 9]
)

# since the second element is equal to the first
# only the first element will be added to the set
print({ifan_a, ifan_b})
{EDOPitchIntervalFan([0, 4, 10, 14, 18], 24-EDO)}

Item Retrieval and Slicing

Single intervals from the interval fan 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)

ifan = edo31.diff_interval_fan([10, 8, 7, 10])

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

Counting

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

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

ifan = edo31.diff_interval_fan([10, 8, 7, 10])
interval = edo31.diff_interval(7)

print(ifan.count(interval))
1

Concatenation

Given two interval fans, the + glues them together into one. (In programming this is known under the term “concatenation”).

Concatenation of interval fans is especially useful in tonal music if you want to make a collage of two melodic sequences from different scores that are written for a different tonic, centering them both around a new tonic. To achieve this, you first transform the two sequences into two interval fans by providing the respective tonic, then concatenate the two interval fans and make an instance for a different tonic from the result.

from xenharmlib import WesternNotation
western = WesternNotation()

# first four vocal measures from Schubert's "Frühlingssehnsucht"
# ("Säuselnde Lüfte, ...")  written for the tonic of Bb
seq_a = western.seq(
    [western.note(pcs, 5) for pcs in 'DCC'] +
    [western.note(pcs, 4) for pcs in ['Bb', 'A', 'Bb']] +
    [western.note(pcs, 5) for pcs in 'CC'] +
    [western.note('A', 4)]
)
ifan_a = seq_a.to_interval_fan(western.note('Bb', 4))

# first two vocal measures from Schubert's "Liebesbotschaft"
# ("Rauschendes Bächlein, ...") written for the tonic of G
seq_b = western.seq([western.note(pcs, 4) for pcs in 'BCCDBBEDDC'])
ifan_b = seq_b.to_interval_fan(western.note('G', 4))

# concatenation into one collage
collage = ifan_a + ifan_b

# transform into sequence centered around the tonic C
collage_in_c = western.note('C', 4).seq(collage)
print(collage_in_c)
WesternNoteSeq([E4, D4, D4, C4, B3, C4, D4, D4, B3, E4, F3, F3, G3, E4, E4, A3, G3, G3, F3])

For illustrative purposes, here is also a more abstract set of examples for each tuning / notation:

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

ifan_a = edo31.diff_interval_fan([10, 3, 4, 5])
ifan_b = edo31.diff_interval_fan([0, 10, 1])

print(ifan_a + ifan_b)
EDOPitchIntervalFan([10, 3, 4, 5, 0, 10, 1], 31-EDO)

Repetition

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

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

ifan = edo31.diff_interval_fan([5, 7, 5, -7, -5])
print(3 * ifan)
EDOPitchIntervalFan([5, 7, 5, -7, -5, 5, 7, 5, -7, -5, 5, 7, 5, -7, -5], 31-EDO)

Index Masks and Partial Interval Fans

Like other sequence-like primitives, interval fans allow the extraction of “substructures” with the partial() method, which expects an index mask expression, i.e., a tuple with indices pointing to fan elements that should be extracted into its own new interval fan.

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

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

ifan = edo31.diff_interval_fan([0, 10, 18, -18, -28, -10])
print(ifan.partial((1, 3, 4)))
EDOPitchIntervalFan([10, -18, -28], 31-EDO)

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

from xenharmlib import EDOTuning
edo31 = EDOTuning(31)

ifan = edo31.diff_interval_fan([0, 10, 18, -18, -28, -10])

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

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

# an ellipsis at the end matches all remaining indices
# in the fan after the last mask index
print(ifan.partial((0, 3, ...)))
EDOPitchIntervalFan([0, 10, 18, -18, -28], 31-EDO)
EDOPitchIntervalFan([10, 18, -18, -28], 31-EDO)
EDOPitchIntervalFan([0, -18, -28, -10], 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)

ifan = edo31.diff_interval_fan([10, 8, 10, 9, 10, 8])
print(ifan.partial_not((1, 3, 4)))
EDOPitchIntervalFan([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)

ifan = edo31.diff_interval_fan([10, 8, 10, 9, 10, 8])
a, b = ifan.partition((1, 3, 4))
print(a)
print(b)
EDOPitchIntervalFan([8, 9, 10], 31-EDO)
EDOPitchIntervalFan([10, 10, 8], 31-EDO)