Advanced Notation Features

This section talks about general features of the notation layer that have not been covered in the quickstart.

Enharmonic Strategies

As you already know each notation layer object has a unique counterpart in the pitch layer: Every note maps to exactly one pitch, and every note interval to exactly one pitch interval, etc.

However the reverse is not true: In 12-EDO the pitch with index 10 could be notated in an infinite number of ways: Bb, A#, Cbb, Dbbbb, etc. Whether index 10 should be understood as Bb or A# depends on various contexts (at least in tonal music). A mapping from pitches to notes must therefore be opinionated: It has to choose one note over all the others.

Because no mapping fits every use case and not every use-case can be foreseen, mappings from pitch layer objects to notation layer objects are not implemented directly in the respective classes but outsourced to objects that act as a drop-in piece for notations with enharmonic ambiguity. These are called enharmonic strategies. In object-oriented programming strategies are a common design pattern to give the user a choice on how a certain functionality is implemented. The term implies that there are different strategies to tackle a problem.

Most notations with enharmonic ambiguity come with a default enharmonic strategy, for example UpDownNotation:

from xenharmlib import EDOTuning
from xenharmlib import UpDownNotation

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

note = n_edo12.guess_note(edo12.pitch(3))
print(note)
UpDownNote(D#, 0, 12-EDO)

The default strategy for UpDownNotation is a heuristic that first sets all the naturals and then fills in the gaps between the naturals with sharps/flats from the left and right bordering notes in an alternating fashion. It then fills the leftover gaps with ups/downs in the same way. (If there is a tie then sharps/ups win against flats/downs)

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

pitch_scale = edo31.scale(
    edo31.pitch_range(0, 18)
)

note_scale = n_edo31.guess_note_scale(pitch_scale)
print(note_scale)
UpDownNoteScale([C0, Dbb0, C#0, Db0, Cx0, D0, Ebb0, D#0, Eb0, Dx0, E0, Fb0, E#0, F0, Gbb0, F#0, Gb0, Fx0], 31-EDO)

This behavior can be changed by resetting the enharmonic strategy of the notation. An alternative strategy for UpDownNotation is to use upwards-pointing accidentals (sharps/ups) for all the gaps:

from xenharmlib.notation.updown import UpwardsEnharmStrategy

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

strategy = UpwardsEnharmStrategy(n_edo31)
n_edo31.enharm_strategy = strategy

pitch_scale = edo31.scale(
    edo31.pitch_range(0, 18)
)

note_scale = n_edo31.guess_note_scale(pitch_scale)
print(note_scale)
UpDownNoteScale([C0, ^C0, C#0, ^C#0, Cx0, D0, ^D0, D#0, ^D#0, Dx0, E0, ^E0, E#0, F0, ^F0, F#0, ^F#0, Fx0], 31-EDO)

Please note that enharmonic strategies only work in the context of the notation they are originally designed for.

The following strategies are available for UpDownNotation:

A (maybe unexpected) side effect of employing a different enharmonic strategy is that interval names can change drastically:

from xenharmlib.notation.updown import DownwardsEnharmStrategy

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

interval = edo12.interval(
    edo12.pitch(0), edo12.pitch(3)
)
n_interval1 = n_edo12.guess_note_interval(interval)
print(n_interval1)

strategy = DownwardsEnharmStrategy(n_edo12)
n_edo12.enharm_strategy = strategy

n_interval2 = n_edo12.guess_note_interval(interval)
print(n_interval2)
UpDownNoteInterval(A, 2, 12-EDO)
UpDownNoteInterval(m, 3, 12-EDO)

Apart from the direct conversion methods enharmonic strategies enrich the feature set of other functions of notation layer objects. For example, they make it possible to use integers in the transpose methods:

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

c0 = n_edo12.note('C', 0)
print(c0.transpose(3))
UpDownNote(D#, 0, 12-EDO)

Enharmonic strategies are especially useful for post-tonal music where transformations of musical objects dismiss functional differences of notes and intervals but a degree of notational familiarity is still desired. For example the \(T_3\) operation can easily be applied on a scale:

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

c_maj = n_edo12.natural_scale()
print(c_maj.transpose(3))
UpDownNoteScale([D#0, F0, G0, G#0, A#0, C1, D1], 12-EDO)

Please be aware that in tonal music this is not a valid D# major scale, even though the pitches are exactly the same. When writing tonal music you should not use integers for transpositions but rather note intervals. Observe the difference:

c0 = n_edo12.note('C', 0)
dsharp0 = n_edo12.note('D#', 0)
interval = c0.interval(dsharp0)

print(c_maj.transpose(interval))
UpDownNoteScale([D#0, E#0, Fx0, G#0, A#0, B#0, Cx1], 12-EDO)

If you want to choose the note names for each pitch class yourself you can use the PCBlueprintStrategy() class to set each note name manually. This more general class works with all periodic notations:

from xenharmlib.core.enharm_strategies import PCBlueprintStrategy

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

pc_note_names = [
    'C', 'C#', 'D', 'Eb',
    'E', 'F', 'F#', 'G',
    'G#', 'A', 'Bb', 'B'
]
custom_strategy = PCBlueprintStrategy(
    [n_edo12.note(s, 0) for s in pc_note_names]
)
n_edo12.enharm_strategy = custom_strategy

c0 = n_edo12.note('C', 0)
print(c0.transpose(1))
print(c0.transpose(3))
UpDownNote(C#, 0, 12-EDO)
UpDownNote(Eb, 0, 12-EDO)

Designing your own enharmonic strategy from scratch

PCBlueprintStrategies are quite primitive. They do not take into account any context (for example the original accidental directions of a note scale, which could indicate the key and a suitable transposition). If you want to create a heuristic more to your needs or devise an intelligent strategy to contribute to the library itself, read on.

There are two types of methods in notations that delegate to an enharmonic strategy object. Methods that always delegate to the strategy object (for example, the pcs complement method) and methods that sometimes delegate to it (for example, the transpose methods that only delegate if the provided parameter is an integer)

In general, an enharmonic strategy has the following structure:

from xenharmlib.core.enharm_strategies import EnharmonicStrategy

class MyStrategy(EnharmonicStrategy):

    def function_name(self, caller_object, *args):
        ...

The interface definition for enharmonic strategies is as follows:

from xenharmlib.core.enharm_strategies import EnharmonicStrategy

class MyStrategy(EnharmonicStrategy):

    def guess_note(self, notation, pitch):
        """
        The method notation.guess_note delegates to this function.
        It should return a suitable note for the provided pitch
        """
        return notation.note('C', 0)

    def guess_note_interval(self, notation, pitch_interval):
        """
        The method notation.guess_note_interval delegates to this
        function. It should return a suitable note interval for
        the provided pitch interval
        """
        return notation.interval(
            notation.note('C', 0),
            notation.note('C', 3),
        )

    def guess_note_scale(self, notation, pitch_scale):
        """
        The method notation.guess_note_scale delegates to this
        function. It should return a suitable note scale for the
        provided pitch scale
        """
        return notation.scale(
            [notation.note('C', 0), notation.note('C', 3)]
        )

    def note_transpose(self, note, pitch_diff: int):
        """
        The method note.transpose delegates to this function if
        the desired transposition distance was given as an integer.
        It should return a suitable note with the correct pitch
        difference.
        """
        return note.notation.note('C', 0)

    def note_scale_transpose(self, note_scale, pitch_diff: int):
        """
        The method note_scale.transpose delegates to this
        function if the desired transposition distance was
        given as an integer. It should return a suitable
        note scale with all notes transposed by the correct
        pitch difference.
        """
        return note_scale.notation.scale('C', 0)

    def note_scale_pcs_complement(self, note_scale):
        """
        The method note_scale.pcs_complement delegates to this
        function. It should return a pcs normalized scale with
        all notes not present in the original scale.
        """
        return notation.scale(
            [notation.note('C', 0), notation.note('D#', 0)]
        )