# -*- coding: utf-8 -*-
#
# This file is part of `quickly`, a library for LilyPond and the `.ly` format
#
# Copyright © 2019-2021 by Wilbert Berendsen <info@wilbertberendsen.nl>
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Classes and functions to deal with LilyPond pitches.
A pitch consists of a step (note, the index in the global default scale) and an
alteration, which is a rational value (fraction or floating point) in whole
tones. The notes 0..6 correspond with the usual "white keys" C, D, E, F, G, A,
B; a sharp is represented by a +0.5 alteration value, and a flat by a -0.5
value.
The octave of a pitch is 0 for the octave starting at middle C, just like
LilyPond handles the octave.
All functions and classes in this module, and also in the :mod:`.key` module,
allow specifying a different global default scale (set in the
:py:data:`MAJOR_SCALE` module constant), to theoretically support other tone
systems, but that will probably almost never be necessary.
"""
import bisect
import collections
import contextlib
import parce.util
from parce.lang.lilypond_words import pitch_names
#: Major scale: C D E F G A B, with the default pitch offset from the starting
#: C in whole tones.
MAJOR_SCALE = (0, 1, 2, 2.5, 3.5, 4.5, 5.5)
#: Which pitch values get a flat by default instead of a sharp when converting
#: a MIDI key number to a pitch.
MAJOR_FLATS = (1.5, 5)
# reverse pitch names
def _make_reverse_pitch_table():
for language, pitches in pitch_names.items():
notes = collections.defaultdict(lambda: collections.defaultdict(list))
for name, (octave, note, alter) in pitches.items():
notes[note, alter][octave].append(name)
yield language, {note_alter:
{octave: tuple(names) for octave, names in d.items()}
for note_alter, d in notes.items()}
pitch_names_reversed = dict(_make_reverse_pitch_table())
del _make_reverse_pitch_table
[docs]class Pitch:
"""A pitch with ``octave``, ``note``, and ``alter`` attributes.
The attributes may be manipulated directly, and have the same contents and
meaning as the three values in LilyPond's ``(ly:make-pitch octave note
alter)`` construct.
The ``octave`` is an integer where 0 stands for the octave containing
"middle C" (with one apostrophe in LilyPond's format). The ``note`` is an
integer in the 0..6 range, where 0 stands for C; the ``alter`` is an
integer, float or fraction denoting the alteration in whole tones, where
all pitch languages support the values -1, -0.5, 0, 0.5, 1, and some
languages also support semi, three-quarter alterations like 0.25 (i.e.
``Fraction(1, 4)``), or even other alterations.
Pitches compare equal when their attributes are the same, and also support
the ``>``, ``<``, ``>=`` and ``<=`` operators. These operators compare on
octave first, then note, then alter.
``format(pitch)`` returns always the dutch notation (or a question mark if
there's no known name for the note, alter combination), but you can use
:class:`PitchProcessor` to read/write pitch names in all LilyPond
languages.
"""
def __init__(self, octave, note, alter):
self.octave = octave
self.note = note
self.alter = alter
def __format__(self, format_spec):
p = PitchProcessor()
try:
s = p.to_string(self)
except KeyError:
s = '?'
return format(s, format_spec)
def __repr__(self):
return "<{} octave={}, note={}, alter={} ({})>".format(
self.__class__.__name__, self.octave, self.note, self.alter, self)
def _as_tuple(self):
"""Return our attributes as a sortable tuple."""
return (self.octave, self.note, self.alter)
def __eq__(self, other):
return isinstance(other, Pitch) and self._as_tuple() == other._as_tuple()
def __ne__(self, other):
return not isinstance(other, Pitch) or self._as_tuple() != other._as_tuple()
def __gt__(self, other):
if isinstance(other, Pitch):
return self._as_tuple() > other._as_tuple()
return NotImplemented
def __lt__(self, other):
if isinstance(other, Pitch):
return self._as_tuple() < other._as_tuple()
return NotImplemented
def __ge__(self, other):
if isinstance(other, Pitch):
return self._as_tuple() >= other._as_tuple()
return NotImplemented
def __le__(self, other):
if isinstance(other, Pitch):
return self._as_tuple() <= other._as_tuple()
return NotImplemented
[docs] def copy(self):
"""Return a new Pitch with our attributes."""
return type(self)(self.octave, self.note, self.alter)
[docs] def to_midi(self, scale=None):
"""Return the MIDI key number for this pitch."""
scale = scale or MAJOR_SCALE
return int((self.octave + 5) * 12 + (scale[self.note] + self.alter) * 2)
[docs] @classmethod
def from_midi(cls, key, scale=None, flats=None):
"""Return a :class:`Pitch` from the MIDI key value.
All altered notes get a sharp, unless a pitch value is listed in the
``flats`` parameter. By default, the pitch values 1.5 and 5 get a flat,
resulting in an e-flat instead of d-sharp and a b-flat instead of an
a-sharp.
An example::
>>> from quickly.pitch import Pitch
>>> Pitch.from_midi(60)
<Pitch note=0, alter=0, octave=1 (c')>
>>> Pitch.from_midi(61)
<Pitch note=0, alter=0.5, octave=1 (cis')>
>>> Pitch.from_midi(70)
<Pitch note=6, alter=-0.5, octave=1 (bes')>
>>> Pitch.from_midi(70, flats=())
<Pitch note=5, alter=0.5, octave=1 (ais')>
A more powerful way to convert MIDI key numbers to pitches is in the
:class:`~.key.KeySignature` class.
"""
scale = scale or MAJOR_SCALE
flats = MAJOR_FLATS if flats is None else flats
octave, step = divmod(key, 12)
pitch = step / 2
if pitch in flats:
note = bisect.bisect_left(scale, pitch)
else:
note = bisect.bisect_right(scale, pitch) - 1
alter = pitch - scale[note]
a = int(alter)
if a == alter:
alter = a
return cls(octave - 5, note, alter)
[docs] def make_absolute(self, prev_pitch, default_octave=-1, scale=None):
"""Make ourselves absolute, i.e. set our octave from ``prev_pitch``.
The default octave is the octave a pitch name without octave indication
by itself has (-1 by default).
"""
l = len(scale or MAJOR_SCALE)
self.octave += prev_pitch.octave - (self.note - prev_pitch.note + 3) // l - default_octave
[docs] def make_relative(self, prev_pitch, default_octave=-1, scale=None):
"""Make ourselves relative, i.e. change our octave from ``prev_pitch``.
The default octave is the octave a pitch name without octave indication
by itself has (-1 by default).
"""
l = len(scale or MAJOR_SCALE)
self.octave -= prev_pitch.octave - (self.note - prev_pitch.note + 3) // l - default_octave
[docs]class PitchProcessor:
"""Read and write pitch names in all LilyPond languages.
The language to use by default can be given on instantiation or set in the
``language`` attribute. Some languages have multiple pitch names for the
same note; using the ``prefer_`` attributes you can control which style is
chosen when writing the pitch name.
"""
#: Prefer long names in english, e.g. ``c-sharpsharp`` above ``css``
prefer_long = False
#: Prefer ``ré`` above ``re`` (in francais)
prefer_accented = False
#: Prefer ``dox`` above ``doss``, ``cx`` above ``css``, etc in enspanol, english, francais
prefer_x = False
#: Prefer ``ss`` above ``s`` inside note names in norsk
prefer_double_s = False
#: Prefer ``es`` above ``ees`` and ``as`` above ``aes`` (in nederlands, norsk)
prefer_classic = True
#: Prefer names marked as deprecated
prefer_deprecated = False
_language = "nederlands"
def __init__(self, language=None):
if language:
self.language = language
def __repr__(self):
return "<{} ({})>".format(type(self).__name__, self._language)
@property
def language(self):
return self._language
@language.setter
def language(self, language):
if language not in pitch_names:
raise KeyError("unknown language name")
self._language = language
@language.deleter
def language(self):
self._language = "nederlands"
language.__doc__ = \
"""The language to use (default: ``"nederlands"``).
Deleting this attribute sets it back to ``"nederlands"``.
Raises a :obj:`KeyError` if the language you try to set does not exist.
Valid languages are: {langs}.
Do not modify the language between a :meth:`read_node` and
:meth:`write_node` operation on the same node. For translation of pitch
names, use two PitchProcessors.
""".format(langs = ", ".join('``"{}"``'.format(name)
for name in sorted(pitch_names)))
[docs] def pitch(self, name):
"""Return a :class:`Pitch` for the specified note name.
Raises a :obj:`KeyError` if the language does not know the pitch name,
or when the language name is unknown.
For example::
>>> from quickly.pitch import PitchProcessor
>>> p = PitchProcessor()
>>> p.read('cis')
<Pitch octave=-1, note=0, alter=0.5 (cis)>
"""
return Pitch(*pitch_names[self._language][name])
[docs] def name_octave(self, pitch):
"""Return a two-tuple (name, octave) for the :class:`Pitch`.
The name is the note name, the octave is the number of ``,`` (if
negative) or ``'`` that still need to be added.
Raises a :obj:`KeyError` if the language does not contain a pitch name.
"""
octave_dict = pitch_names_reversed[self._language][pitch.note, pitch.alter]
for octave in sorted(octave_dict, key=lambda o: abs(o - pitch.octave)):
names = octave_dict[octave]
octave = pitch.octave - octave
if len(names) == 1:
name = names[0]
else:
name = self._suitable(self._language, names) or names[-1]
return name, octave
[docs] def to_string(self, pitch):
"""Return a string representing the pitch.
Raises a :obj:`KeyError` if the language does not contain a pitch name.
For example::
>>> from quickly.pitch import PitchProcessor
>>> p = PitchProcessor()
>>> p.write(Pitch(-1, 0, 0))
'c'
>>> p.write(Pitch(0, 4, 1))
"gisis'"
>>> p.language = 'english'
>>> p.write(Pitch(0, 4, 1))
"gss'"
>>> p.prefer_long = True
>>> p.write(Pitch(0, 4, 1))
"g-sharpsharp'"
"""
name, octave = self.name_octave(pitch)
return name + octave_to_string(octave)
[docs] def read_node(self, node):
"""Return a Pitch, initialized from the node.
The ``node`` is a :class:`~.dom.lily.Note`, positioned
:class:`~.dom.lily.PitchedRest` or any other
:class:`~.dom.lily.Pitchable`. For example::
>>> from quickly.pitch import PitchProcessor
>>> from quickly.dom import lily
>>> n = lily.Note('re')
>>> p = PitchProcessor('français')
>>> p.read_node(n)
<Pitch octave=-1, note=1, alter=0 (d)>
The octave handling might be a little confusing at first sight: A Note
node without octave characters has octave 0, while the pitch has octave
-1. This is because, just like in LilyPond, the pitch name itself
carries the octave -1, and the octave count of the node is added to it
to get the resulting octave of the actual pitch::
>>> n.octave # number of ' or ,
0
>>> p.read_node(n).octave # actual octave
-1
"""
p = self.pitch(node.head)
p.octave += node.octave
return p
[docs] def write_node(self, node, pitch):
"""Write the Pitch's note, alter and octave to the node.
The ``node`` is a :class:`~.dom.lily.Note`, positioned
:class:`~.dom.lily.PitchedRest` or any other
:class:`~.dom.lily.Pitchable`. Example::
>>> from quickly.pitch import Pitch, PitchProcessor
>>> from quickly.dom import lily
>>> n = lily.Note('c')
>>> p = PitchProcessor()
>>> p.write_node(n, Pitch(2, 1, 0.5))
>>> n.dump()
<lily.Note 'dis' (1 child)>
╰╴<lily.Octave 3>
"""
p = self.pitch(node.head)
if (p.note, p.alter) == (pitch.note, pitch.alter):
# keep the head value, even if there are multiple pitch names
# in the current language (that can differ in octave)
node.octave = pitch.octave - p.octave
else:
node.head, node.octave = self.name_octave(pitch)
[docs] @contextlib.contextmanager
def process(self, node, write=True):
"""Return a context manager that yields a :class:`Pitch` when entered.
The ``node`` is a :class:`~.dom.lily.Note`, positioned
:class:`~.dom.lily.PitchedRest` or any other
:class:`~.dom.lily.Pitchable`. You can manipulate the Pitch, and when
done, the node will be updated if the pitch was changed. An example::
>>> from quickly.pitch import PitchProcessor
>>> from quickly.dom import lily
>>> n = lily.Note('c')
>>> p = PitchProcessor()
>>> with p.process(n) as pitch:
... pitch.note += 2
... pitch.alter = 0.5
... pitch.octave += 1
...
>>> n.write()
"eis'"
>>> n.dump()
<lily.Note 'eis' (1 child)>
╰╴<lily.Octave 1>
If you set the ``write`` parameter to False on invocation, the pitch
changes will not be written back to the DOM node, this enables you to
e.g. apply changes only within a certain range.
"""
p = self.read_node(node)
yield p
if write:
self.write_node(node, p)
[docs] def pitchable(self, pitch, cls=None):
"""Return a new Pitchable element for the pitch.
By default, a Note is returned, but you may specify any Pitchable
subclass.
>>> from quickly.pitch import *
>>> p = PitchProcessor('nederlands')
>>> n = p.pitchable(Pitch(2, 3, -.25))
>>> n.dump()
<lily.Note 'feh' (1 child)>
╰╴<lily.Octave 3>
>>> n.write()
"feh'''"
"""
name, octave = self.name_octave(pitch)
from .dom import lily
if cls is None:
cls = lily.Note
return cls(name, octave=octave)
[docs] def find_language(self, node):
r"""Search backwards from node to find the last set language.
If an ``\include`` command is found that names a language file, or a
``\language`` command with a valid language, that language is set.
"""
from .dom import lily
for n in node < (lily.Language, lily.Include):
lang = n.language
if lang:
self.language = lang
break
[docs] def follow_language(self, nodes):
r"""Iterate over the DOM nodes and follow language changes.
.. currentmodule:: quickly.dom
Yield every node, except for :class:`lily.Language` or
:class:`lily.Include` if a language name is included. Sets the language
attribute according to the ``\language`` or ``\include`` command.
"""
from .dom import lily
for n in nodes:
if isinstance(n, (lily.Language, lily.Include)):
lang = n.language
if lang:
self.language = lang
continue
yield n
[docs] def distill_preferences(self, names):
"""Iterate over the ``names`` and try to distill the preferred style.
Adjust the preferences based on the encountered pitch names.
This can be used to analyze existing music and use the same pitch name
preferences for newly entered music.
The ``names`` iterable may be a set but also an ordered sequence or
generator. If a name is encountered that is a language name, that
language is followed to test following pitch names. (The ``language``
attribute is not changed.)
"""
language = self._language
prefer_accented = None
prefer_classic = None
prefer_double_s = None
prefer_long = None
prefer_x = None
prefer_deprecated = None
pnames = pitch_names[language]
for name in names:
if name in pnames:
if language in ("nederlands", "norsk"):
if name[:2] in {'es', 'as'}:
prefer_classic = True
elif not prefer_classic and name[:2] in {'ee', 'ae'}:
prefer_classic = False
if language == "norsk":
if 'ss' in name:
prefer_double_s = True
elif not prefer_double_s and 's' in name:
prefer_double_s = False
elif language == "english":
if '-' in name:
prefer_long = True
elif not prefer_long and pnames[name][1] in {-1, -0.5, 0.5, 1}:
prefer_long = False
elif language == "français":
if 'é' in name:
prefer_accented = True
elif not prefer_accented and 'e' in name:
prefer_accented = False
elif language == "deutsch":
if name in {'eeh', 'ases', 'aseh', 'aeh'}:
prefer_deprecated = True
elif not prefer_deprecated and name in {'eh', 'asas', 'asah', 'ah'}:
prefer_deprecated = False
elif language == "suomi":
if name in {'ases', 'bb', 'heses'}:
prefer_deprecated = True
elif not prefer_deprecated and name in {'asas', 'bes'}:
prefer_deprecated = False
if name.endswith('x'):
prefer_x = True
elif not prefer_x and (
(name.endswith('ss') and language in ("english", "espanol", "español"))
or (name.endswith('dd') and language == "français")):
prefer_x = False
elif name in pitch_names:
language = name
pnames = pitch_names[language]
if prefer_accented is not None:
self.prefer_accented = prefer_accented
if prefer_classic is not None:
self.prefer_classic =prefer_classic
if prefer_double_s is not None:
self.prefer_double_s = prefer_double_s
if prefer_long is not None:
self.prefer_long = prefer_long
if prefer_x is not None:
self.prefer_x = prefer_x
if prefer_deprecated is not None:
self.prefer_deprecated = prefer_deprecated
_suitable = parce.util.Dispatcher()
@_suitable("nederlands")
def _nederlands(self, names):
for name in names:
if self.prefer_classic == (name in {'es', 'eses', 'as', 'ases'}):
return name
@_suitable("catalan")
def _catalan(self, names):
for name in names:
if self.prefer_deprecated == name.endswith('s'):
return name
@_suitable("deutsch")
def _deutsch(self, names):
for name in names:
if self.prefer_deprecated == (name in {'eeh', 'ases', 'aseh', 'aeh'}):
return name
@_suitable("english")
def _english(self, names):
if self.prefer_long:
for name in names:
if "-" in name:
return name
for name in names:
if "-" not in name and self.prefer_x == name.endswith('x'):
return name
@_suitable("espanol")
@_suitable("español")
def _espanol(self, names):
for name in names:
if self.prefer_x == name.endswith('x'):
return name
@_suitable("français")
def _francais(self, names):
subset = [name for name in names if self.prefer_accented == ('é' in name)]
if subset:
if len(subset) == 1:
return subset[0]
names = subset
for name in names:
if self.prefer_x == (name.endswith('x')):
return name
@_suitable("norsk")
def _norsk(self, names):
subset = [name for name in names if self.prefer_double_s == ('ss' in name)]
if subset:
if len(subset) == 1:
return subset[0]
names = subset
for name in names:
if self.prefer_classic == ('ee' in name or 'ae' in name):
return name
@_suitable("suomi")
def _suomi(self, names):
for name in names:
if self.prefer_deprecated == (name in {'ases', 'bb', 'heses'}):
return name
[docs]def octave_to_string(n):
"""Convert a numeric value to an octave notation.
The octave notation consists of zero or more ``'`` or ``,``. The octave
``0`` returns the empty string.
"""
return "," * -n if n < 0 else "'" * n
[docs]def octave_from_string(octave):
"""Convert an octave string to a numeric value.
``''`` is converted to 2, ``,`` to -1. The empty string gives 0.
"""
return octave.count("'") - octave.count(",")
[docs]def determine_language(names):
"""Yield the language names that have all the specified pitch names.
This can be used to auto-determine the language of music if the language
name somehow is not set in a file. Just harvest all the pitch names and
call this function. The pitch names ``"r"``, ``"R"``, ``"s"`` and ``"q"``
are ignored. For example::
>>> from quickly.pitch import determine_language
>>> list(determine_language(['c', 'd', 'es', 'fis', 'bis']))
['nederlands']
>>> list(determine_language(['c', 'do']))
[] # ambiguous
"""
def langs():
# prefer often used languages
ubiquitous = ["nederlands", "english", "deutsch", "français", "italiano"]
exotic = ["arabic", "bagpipe", "persian"]
def others():
# remove synonyms
seen = set(id(pitch_names[name]) for name in ubiquitous + exotic)
for name, value in pitch_names.items():
if id(value) not in seen:
seen.add(id(value))
yield name
yield from ubiquitous
yield from sorted(others())
yield from exotic
names = set(names) - set('rRsq') # remove language-agnostic names ;-)
for language in langs():
if not names - set(pitch_names[language]):
yield language