"""
Set of routines to work with musical pitches, convert to and from frequencies,
notenames, etc. Microtones are fully supported, both as fractional midinotes and as
notenames.
Features
========
* convert between frequencies, midinotes and notenames
* microtones are fully supported
* split a pitch into its multiple components (pitch class, octave, microtonal deviation, etc.)
* transpose a pitch taking its spelling into consideration
* create custom pitch converters to work with custom reference frequencies, or modify the
reference frequency globally
Microtonal notation
===================
Some shortcuts are used for *round* microtones:
========== ============
Symbol Cents
========== ============
``+`` +50
``-`` -50
``>`` +25
``<`` -25
========== ============
There is some flexibility regarding notenames: **the octave can be placed
before the note or after**, and the **pitch-class is case-insensitive**.
Some example notenames
~~~~~~~~~~~~~~~~~~~~~~
========== ============ ======================
Midinote Notename Alternative notation
========== ============ ======================
60.25 4C+25 / 4C> c4+25 / c>4
60.45 4C+45 C4+45
60.5 4C c4
60.75 4Db-25 Db4-25 / db4-25
61.5 4D- d4-
61.80 4D-20 D4
63 4D# d#4
63.5 4D#+ d#+4
63.7 4E-30 E4-30
========== ============ ======================
Global settings vs Converter objects
====================================
To convert to and from frequencies a reference frequency (``A4``) is needed.
In **pitchtools** there are set of global functions (:func:`m2f`, :func:`f2m`,
:func:`n2f`, etc) which rely on a global reference. This reference can be
modified via :func:`set_reference_freq`.
It is also possible to create an ad-hoc converter (:class:`PitchConverter`).
This makes it possible to customize the reference frequency without any side-effects
----------------------
Example
-------
.. code::
# Set the value globally
>>> set_reference_freq(443)
>>> n2f("A4")
443.0
# Create a Converter object
>>> cnv = PitchConverter(a4=435)
>>> print(cnv.n2f("4C"))
258.7
"""
from __future__ import annotations
from dataclasses import dataclass as _dataclass
import math
import sys
import re as _re
import itertools as _itertools
from functools import cache as _cache
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Sequence
# pitch_t: TypeAlias = str| float | int
_EPS = sys.float_info.epsilon
_flats = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B", "C"]
_sharps = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", "C"]
_unicode_flats = ["C", "D♭", "D", "E♭", "E", "F", "G♭", "G", "A♭", "A", "B♭", "B", "C"]
_unicode_sharps = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B", "C"]
_enharmonic_pitchclass = {
"Cb": ("B", -1),
"C#": ("Db", 0),
"Db": ("C#", 0),
"D#": ("Eb", 0),
"Eb": ("D#", 0),
"E#": ("F", 0),
"Fb": ("E", 0),
"F#": ("Gb", 0),
"Gb": ("F#", 0),
"G#": ("Ab", 0),
"Ab": ("G#", 0),
"A#": ("Bb", 0),
"Bb": ("A#", 0),
"B#": ("C", 1),
}
_pitchclass_chromatic = {
'C': 0,
'C#': 1,
'Db': 1,
'D': 2,
'D#': 3,
'Eb': 3,
'E': 4,
'E#': 5,
'Fb': 4,
'F': 5,
'F#': 6,
'Gb': 6,
'G': 7,
'G#': 8,
'Ab': 8,
'A': 9,
'A#': 10,
'Bb': 10,
'B': 11,
'B#': 0,
'Cb': 11,
}
_notes2 = {"c": 0, "C": 0,
"d": 2, "D": 2,
"e": 4, "E": 4,
"f": 5, "F": 5,
"g": 7, "G": 7,
"a": 9, "A": 9,
"b": 11, "B": 11}
_r1 = _re.compile(r"(?P<pch>[A-Ha-h][b|#]?)(?P<oct>[-]?[\d]+)(?P<micro>[-+><↓↑][\d]*)?")
_r2 = _re.compile(r"(?P<oct>[-]?\d+)(?P<pch>[A-Ha-h][b|#]?)(?P<micro>[-+><↓↑]\d*)?")
_r1b = _re.compile(r"([A-Ha-h][b|#]?)([-]?[\d]+)([-+><↓↑][\d]*)?")
_r2b = _re.compile(r"([-]?\d{1,3})([A-Ga-g][b|#]?)([-+><↓↑]\d*)?")
[docs]
@_dataclass
class NoteParts:
"""
Represents the parts of a notename
It is returned by :func:`split_notename`
Attributes:
octave (int): octave number, 4=central octave
diatonic_name (str: "C", "D", "E", ... (diatonic step)
alteration (str): the alteration as str, "#", "b", "+", "-", ">", "<"
cents_deviation (int): number of cents deviation from the chromatic pitch
.. seealso:: :func:`notated_pitch`
"""
octave: int
"""octave number, 4=central octave"""
diatonic_name: str
"""name of the diatonic step ('C', 'D', 'E', etc.)"""
alteration: str
"""The alteration as str: '#', 'b', '+', '-', '>', '<'"""
cents_deviation: int
"""Number of cents deviation from the chromatic pitch"""
__slots__ = ('octave', 'diatonic_name', 'alteration', 'cents_deviation')
def __iter__(self):
return iter((self.octave, self.diatonic_name, self.alteration, self.cents_deviation))
@property
def alteration_cents(self) -> int:
"""The cents corresponding to the alteration"""
return alteration_to_cents(self.alteration)
@property
def diatonic_step(self) -> int:
"""The diatonic step as an index, where 0 is C"""
return 'CDEFGABC'.index(self.diatonic_name)
[docs]
def midinote(self) -> float:
pc = _notes2[self.diatonic_name]
alt = self.alteration
if alt == '#':
pc += 1
elif alt == "b":
pc -= 1
return (self.octave + 1) * 12 + pc + self.cents_deviation/100.
@_dataclass
class _ParsedMidinote:
"""
Represents the parts of a pitch as represented by a midinote
Attributes:
pitchindex (int): 0=C, 1=C#, ...
deviation (float): in semitones, deviation from the chromatic pitch
octave (int): 4 = central octave
chromatic_pitch (str): the written pitch, "Eb", "F#", etc. No microtones
"""
pitchindex: int
deviation: float
octave: int
chromatic_pitch: str
_cents_repr_eighthtones = {
0: '',
25: '>',
50: '+',
-25: '<',
-50: '-'
}
_cents_repr_quartertones = {
0: '',
50: '+',
-50: '-'
}
_black_key_indexes = {1, 3, 6, 8, 10}
[docs]
def cents_repr(cents: int, eighthToneShortcuts=True) -> str:
"""
Return the string representation of cents
====== =======================
cents string representation
====== =======================
0 ''
15 +15
25 > (if ``eighthToneShortcuts == True``)
45 +45
50 +
60 +60
-15 -15
-25 < (if ``eighthToneShortcuts == True``)
====== =======================
.. seealso:: :func:`construct_notename`
"""
if eighthToneShortcuts:
shortcut = _cents_repr_eighthtones.get(cents)
else:
shortcut = _cents_repr_quartertones.get(cents)
if shortcut:
return shortcut
else:
return f'+{cents}' if cents > 0 else str(cents)
[docs]
@_dataclass
class NotatedPitch:
"""
A parsed notename to be queried in relation to its musical notation
It is returned by :func:`notated_pitch`
Attributes:
octave: the octave (4=central octave)
diatonic_index: The index of the pitch class without accidentals, 0=C, 1=D, 2=E, ...
diatonic_name: The name corresponding to the diatonic_index: "C", "D", "E", ...
chromatic_index: The index of the chormatic pitch class: 0=C, 1=C#/Db, 2=D, ...
chromatic_name: The name corresponding to the chromatic_index "C", "Db", ...
diatonic_alteration: the alteration in relation to the diatonic pitch. For C# this would be 1.0, for Db this would be -1.0
chromatic_alteration: for C#+50 this would be 0.5
accidental_name: the name of the accidental used ('natural', 'natural-up','quarter-sharp', etc.)
Example
~~~~~~~
>>> notated_pitch("4C#+15")
NotatedPitch(octave=4, diatonic_index=0, diatonic_name='C', chromatic_index=1,
chromatic_name='C#', diatonic_alteration=1.15, chromatic_alteration=0.15,
accidental_name='sharp-up')
>>> notated_pitch("4Db+15")
NotatedPitch(octave=4, diatonic_index=1, diatonic_name='D', chromatic_index=1,
chromatic_name='Db', diatonic_alteration=-0.85, chromatic_alteration=0.15,
accidental_name='flat-up')
"""
octave: int
diatonic_index: int
diatonic_name: str
chromatic_index: int
chromatic_name: str
diatonic_alteration: float
chromatic_alteration: float
accidental_name: str
@property
def fullname(self) -> str:
"""The full name of this notated pitch"""
return f"{self.octave}{self.chromatic_name}{self.cents_str}"
@property
def vertical_position(self) -> int:
"""
Abstract value indicating the vertical notated position
"""
return self.octave * 7 + self.diatonic_index
@property
def midinote(self) -> float:
"""The midinote corresponding to this notated pitch"""
return (self.octave + 1) * 12 + self.chromatic_index + self.chromatic_alteration
[docs]
def microtone_index(self, semitone_divisions=2) -> int:
"""The index of the nearest microtone
For example, if semitone_divisions is 2, then
==== ================
note microtone index
==== ================
4C 0
5C 0
4C+ 1
4C# 2
4Db 2
… …
==== ================
"""
m = self.midinote
quantized = round(m * semitone_divisions) / semitone_divisions
idx = quantized % 12
return int(idx * semitone_divisions)
@property
def is_white_key(self) -> bool:
"""Is this a white key?"""
return self.chromatic_index not in _black_key_indexes
@property
def is_black_key(self) -> bool:
"""Is this a black key?"""
return self.chromatic_index in _black_key_indexes
@property
def cents_deviation(self) -> int:
"""The cents deviation from the notated chromatic pitch"""
return int(self.chromatic_alteration * 100)
@property
def cents_sign(self) -> str:
"""The sign of the cents deviation ('', '+', '-')"""
if self.chromatic_alteration == 0:
return ''
elif self.chromatic_alteration > 0:
return '+'
else:
return '-'
@property
def cents_str(self) -> str:
"""The cents deviation as a string"""
cents = self.cents_deviation
if cents == 0:
return ''
elif cents == 50:
return '+'
elif cents == -50:
return '-'
elif cents > 0:
return f'+{cents}'
else:
return str(cents)
[docs]
def alteration_direction(self, min_alteration=0.5):
"""
Returns the direction of the alteration
(the table assumes min_alteration==0.5)
===== ====================
Note Alteration Direction
===== ====================
4C 0
4C# 1
4Eb -1
4C+ 1
4F-25 0
4Bb -1
4A+25 0
===== ====================
"""
if self.diatonic_alteration >= min_alteration:
return 1
elif self.diatonic_alteration <= -min_alteration:
return -1
return 0
[docs]
class PitchConverter:
"""
Convert between midinote, frequency and notename.
Args:
a4: the reference frequency
eightnote_symbol: if True, a special symbol is used
(">", "<") when a note is exactly 25 cents higher
or lower (for example, "4C>"). Otherwise, a notename
would be, for example, "4C+25"
unicode_accidentals: use unicode symbols for accidentals
Example
~~~~~~~
>>> cnv = PitchConverter(a4=435)
>>> cnv.m2f(69)
435.0
>>> cnv.f2n(440)
'4A+20'
"""
_default: PitchConverter | None = None
"""Default pitch converter"""
__slots__ = ('a4', 'eighthnote_symbol', 'unicode_accidentals')
def __init__(self, a4=442.0, eightnote_symbol=True, unicode_accidentals=False):
self.a4 = a4
self.eighthnote_symbol = eightnote_symbol
self.unicode_accidentals = unicode_accidentals
def __repr__(self):
return f"PitchConverter(a4={self.a4})"
[docs]
@classmethod
def default(cls) -> PitchConverter:
"""
Get the default PitchConverter
Returns:
"""
if PitchConverter._default is None:
PitchConverter._default = PitchConverter()
assert PitchConverter._default is not None
return PitchConverter._default
[docs]
def set_reference_freq(self, a4: float) -> None:
"""
Set the reference freq. (the freq. of A4) for this converter
Args:
a4: the freq. of A4 in Hz
.. seealso:: :func:`get_reference_frequency`, :meth:`PitchConverter.get_reference_freq`
"""
self.a4 = a4
[docs]
def get_reference_freq(self) -> float:
"""
Get the reference frequency for this converter
Returns:
the freq. of A4
.. seealso:: :func:`set_reference_frequency`, :meth:`~PitchConverter.set_reference_freq`
"""
return self.a4
[docs]
def f2m(self, freq: float) -> float:
"""
Convert a frequency in Hz to a midi-note
Args:
freq: the frequency to convert, in Hz
Returns:
the midi note corresponding to freq
.. seealso:: :meth:`~PitchConverter.set_reference_freq`
"""
if freq < 9:
return 0
return 12.0 * math.log(freq / self.a4, 2) + 69.0
[docs]
def freq_round(self, freq: float, semitone_divisions=1) -> float:
"""
Round the freq. to the nearest semitone or fraction thereof
Args:
freq: the freq. to round
semitone_divisions: the number of divisions per semitone
Returns:
the rounded frequency
.. seealso:: :func:`pitch_round`, :func:`quantize_notename`
"""
return self.m2f(round(self.f2m(freq) * semitone_divisions) / semitone_divisions)
[docs]
def m2f(self, midinote: float) -> float:
"""
Convert a midi-note to a frequency
Args:
midinote: the midinote to convert to frequency
Returns:
the freq. corresponding to midinote
.. seealso:: :func:`set_reference_freq`
"""
return 2 ** ((midinote - 69) / 12.0) * self.a4
[docs]
def m2n(self, midinote: float) -> str:
"""
Convert midinote to notename
Args:
midinote: a midinote (60=C4)
Returns:
the notename corresponding to midinote.
.. seealso:: :func:`n2m`, :func:`construct_notename`, :func:`notated_pitch`
"""
octave, chromatic_name, alteration, cents = self.midi_to_note_parts(midinote)
if cents == 0:
return str(octave) + chromatic_name + alteration
if cents > 0:
if cents < 10:
return f"{octave}{chromatic_name}{alteration}+0{cents}"
return f"{octave}{chromatic_name}{alteration}+{cents}"
else:
if -10 < cents:
return f"{octave}{chromatic_name}{alteration}-0{abs(cents)}"
return f"{octave}{chromatic_name}{alteration}{cents}"
[docs]
def n2m(self, note: str) -> float:
"""
Convert a notename to a midinote
Args:
note: the notename
Returns:
the midinote corresponding to note
.. seealso:: :func:`m2n`
"""
return n2m(note)
[docs]
def n2f(self, note: str) -> float:
""" Convert a notename to its corresponding frequency """
return self.m2f(n2m(note))
[docs]
def f2n(self, freq: float) -> str:
"""
Return the notename corresponding to the given freq
Args:
freq: the freq. to convert
Returns:
the corresponding notename
"""
return self.m2n(self.f2m(freq))
[docs]
def pianofreqs(self, start="A0", stop="C8") -> list[float]:
"""
Generate an array of the frequencies for all the piano keys
Args:
start: the starting note
stop: the ending note
Returns:
a list of frequencies
"""
m0 = int(n2m(start))
m1 = int(n2m(stop))
midinotes = range(m0, m1 + 1)
freqs = [self.m2f(m) for m in midinotes]
return freqs
[docs]
def asmidi(self, x: int | float | str) -> float:
"""
Convert x to a midinote
Args:
x: an object which can be converted to a midinote (a freq., a notename)
Returns:
The corresponding midinote.
Example
-------
.. code::
>>> from pitchtools import *
>>> cnv = PitchConverter()
>>> cnv.asmidi("4C+10Hz")
272.8
"""
if isinstance(x, str):
return self.str2midi(x)
else:
return x
[docs]
def str2midi(self, s: str) -> float:
"""
Accepts all that n2m accepts but with the addition of frequencies
.. note:: The hz part must be at the end
Args:
s: pitch describes as a string. Possible values: "100hz", "4F+20hz", "8C-4hz"
Returns:
the corresponding midinote
"""
ending = s[-2:]
if ending != "hz" and ending != "Hz":
return self.n2m(s)
srev = s[::-1]
minusidx = srev.find("-")
plusidx = srev.find("+")
if minusidx < 0 and plusidx < 0:
return self.f2m(float(s[:-2]))
if minusidx > 0 and plusidx > 0:
if minusidx < plusidx:
freq = -float(s[-minusidx:-2])
notename = s[:-minusidx - 1]
else:
freq = float(s[-plusidx:-2])
notename = s[:-plusidx - 1]
elif minusidx > 0:
freq = -float(s[-minusidx:-2])
notename = s[:-minusidx - 1]
else:
freq = float(s[-plusidx:-2])
notename = s[:-plusidx - 1]
return self.f2m(self.n2f(notename) + freq)
[docs]
def midi_to_note_parts(self, midinote: float) -> tuple[int, str, str, int]:
"""
Convert a midinote into its parts as a note
Args:
midinote: the midinote to analyze
Returns:
a tuple (``octave``: *int*, ``chromatic_note``: *str*, ``microtonal_alternation``: *str*, ``cents_deviation``: *int*),
where ``octave`` is the octave number; ``chromatic_note`` is the pitch class
Example
~~~~~~~
>>> import pitchtools as pt
>>> pt.midi_to_note_parts(60.5)
(4, 'C', '+', 0)
>>> pt.midi_to_note_parts(61.2)
(4, 'C#', '', 20)
"""
i = int(midinote)
micro = midinote - i
octave = int(midinote / 12.0) - 1
ps = int(midinote % 12)
cents = int(micro * 100 + 0.5)
sharps = _sharps if not self.unicode_accidentals else _unicode_sharps
flats = _flats if not self.unicode_accidentals else _unicode_flats
if cents == 0:
return (octave, sharps[ps], "", 0)
elif cents == 50:
if ps in {1, 3, 6, 8, 10}:
return (octave, sharps[ps + 1], "-", 0)
return (octave, sharps[ps], "+", 0)
elif cents == 25 and self.eighthnote_symbol:
symbol = ">" if not self.unicode_accidentals else "↑"
if ps in (6, 10,):
return (octave, flats[ps], symbol, 0)
return (octave, sharps[ps], symbol, 0)
elif cents == 75 and self.eighthnote_symbol:
ps += 1
if ps > 11:
octave += 1
symbol = "<" if not self.unicode_accidentals else "↓"
if ps in {1, 3, 6, 8, 10}:
return (octave, flats[ps], symbol, 0)
else:
return (octave, sharps[ps], symbol, 0)
elif cents > 50:
cents = 100 - cents
ps += 1
if ps > 11:
octave += 1
return (octave, flats[ps], "", -cents)
else:
return (octave, sharps[ps], "", cents)
[docs]
def normalize_notename(self, notename: str) -> str:
"""
Convert notename to its canonical form
The canonical form follows the scheme ``octave:pitchclass:microtone``
Args:
notename: the note to normalize
Returns:
the normalized notename
Example
-------
.. code::
>>> normalize_notename("a4+24")
4A+24
"""
return self.m2n(self.n2m(notename))
[docs]
def as_midinotes(self, x: str | Sequence[str | float] | float) -> list[float]:
"""
Tries to interpret `x` as a list of pitches, returns these as midinotes
Args:
x: either list of midinotes (floats/ints), a list of notenames (str), one
str with notenames (divided by spaces or commas), or a single notename or midinote
Returns:
the corresponding list of midinotes.
Example
~~~~~~~
>>> as_midinotes(["4G", "4C"])
[67., 60.]
>>> as_midinotes((67, 60))
[67., 60.]
>>> as_midinotes("4G 4C 4C+10hz")
[67., 60., 60.65]
>>> as_midinotes("4C,4E,4F")
[60., 64., 65.]
"""
if isinstance(x, str):
notenames = _re.split(r"[\ ,]", x)
return [self.str2midi(n) for n in notenames]
elif isinstance(x, (list, tuple)):
midinotes = []
for n in x:
if isinstance(n, str):
midinotes.append(self.str2midi(n))
elif isinstance(n, (int, float)):
midinotes.append(n)
else:
raise TypeError(f"Expected a str, an int or a float, got {n}")
return midinotes
elif isinstance(x, (float, int)):
return [x]
else:
raise TypeError(f"Expected a str, list of str or list of float, got {x}")
[docs]
@_cache
def n2m(note: str) -> float:
"""
Convert a notename to a midinote
Args:
note: the notename
Returns:
the corresponding midi note
Two formats are supported:
* 1st format (pitchclass first): ``C#2``, ``D4``, ``Db4+20``, ``C4>``, ``Eb5<``
* 2nd format (octave first): ``2C#``, ``4D+``, ``7Eb-14``
.. note::
The second format, with its clear hierarchy ``octave:pitch:microtone`` is
the canonical one and used when converting a midinote to a notename
======== ========
Input Output
======== ========
4C 60
4D-20 61.8
4Eb+ 63.5
4E< 63.75
4C#-12 60.88
======== ========
Microtonal alterations
~~~~~~~~~~~~~~~~~~~~~~
========== ========
Alteration Cents
========== ========
``+`` +50
``-`` -50
``>`` +25
``<`` -25
========== ========
.. seealso:: :func:`str2midi`
"""
oct, pch, alt, cents = _split_notename(note)
pc = _notes2[pch]
if alt == '#' or alt == '♯':
pc += 1
elif alt == "b" or alt == '♭':
pc -= 1
return (oct + 1) * 12 + pc + cents/100.
# @_cache
def _n2m(note: str) -> float:
"""
Convert a notename to a midinote
Args:
note: the notename
Returns:
the corresponding midi note
Two formats are supported:
* 1st format (pitchclass first): ``C#2``, ``D4``, ``Db4+20``, ``C4>``, ``Eb5<``
* 2nd format (octave first): ``2C#``, ``4D+``, ``7Eb-14``
.. note::
The second format, with its clear hierarchy ``octave:pitch:microtone`` is
the canonical one and used when converting a midinote to a notename
======== ========
Input Output
======== ========
4C 60
4D-20 61.8
4Eb+ 63.5
4E< 63.75
4C#-12 60.88
======== ========
Microtonal alterations
~~~~~~~~~~~~~~~~~~~~~~
========== ========
Alteration Cents
========== ========
``+`` +50
``-`` -50
``>`` +25
``<`` -25
========== ========
.. seealso:: :func:`str2midi`
"""
if not isinstance(note, str):
raise TypeError(f"expected a str, got {note} of type {type(note)}")
if len(note) < 2:
raise ValueError(f"Cannot convert '{note}' to a midi note")
if note[0].isalpha():
m = _r1.search(note)
else:
m = _r2.search(note)
if not m:
raise ValueError("Could not parse note " + note)
groups = m.groupdict()
pitchstr = groups["pch"]
octavestr = groups["oct"]
microstr = groups["micro"]
pc = _notes2[pitchstr[0]]
if len(pitchstr) == 2:
alt = pitchstr[1]
if alt == "#":
pc += 1
elif alt == "b":
pc -= 1
else:
raise ValueError(f"Could not parse alteration in {note}")
octave = int(octavestr)
m0 = microstr[0]
if not microstr:
micro = 0.0
elif m0 == '+':
if len(microstr) > 1:
micro = int(microstr) / 100.
else:
micro = 0.5
elif m0 == '-':
if len(microstr) > 1:
micro = int(microstr) / 100.
else:
micro = -0.5
elif microstr == ">" or microstr == "↑":
micro = 0.25
elif microstr == "<" or microstr == "↓":
micro = -0.25
else:
raise ValueError(f"Could not parse notename's suffix '{microstr}' of note '{note}'")
if pc > 11:
pc = 0
octave += 1
elif pc < 0:
pc = 12 + pc
octave -= 1
return (octave + 1) * 12 + pc + micro
[docs]
def is_valid_notename(notename: str, minpitch=12) -> bool:
"""
Returns true if *notename* is valid
Args:
notename: the notename to check
minpitch: a min. midi note
Returns:
True if this is a valid notename
.. seealso:: :func:`split_notename`
"""
try:
midi = n2m(notename)
return midi >= minpitch
except ValueError:
return False
def _pitchname(pitchidx: int, micro: float) -> str:
"""
Given a pitchindex (0-11) and a microtonal alteracion (between -0.5 and +0.5),
return the pitchname which better represents pitchindex
0, 0.4 -> C
1, -0.2 -> Db
3, 0.4 -> D#
3, -0.2 -> Eb
"""
blacknotes = {1, 3, 6, 8, 10}
if micro < 0:
if pitchidx in blacknotes:
return _flats[pitchidx]
else:
return _sharps[pitchidx]
elif micro == 0:
return _sharps[pitchidx]
else:
if pitchidx in blacknotes:
return _sharps[pitchidx]
return _flats[pitchidx]
def _parse_midinote(midinote: float) -> _ParsedMidinote:
"""
Convert a midinote into its pitch components
Args:
midinote: the midinote, where 60 corresponds to central C (C4)
Returns:
a ParsedMidinote, which is a NamedTuple with fields pitchindex (int),
deviation (in semitones, float), octave (int) and chromaticPitch (str)
====== =========
Input Output
====== =========
63.2 (3, 0.2, 4, "D#")
62.8 (3, -0.2, 4, "Eb")
====== =========
"""
i = int(midinote)
micro = midinote - i
octave = int(midinote / 12.0) - 1
ps = int(midinote % 12)
cents = int(micro * 100 + 0.5)
if cents == 50:
if ps in (1, 3, 6, 8, 10):
ps += 1
micro = -0.5
else:
micro = 0.5
elif cents > 50:
micro = micro - 1.0
ps += 1
if ps == 12:
octave += 1
ps = 0
pitchname = _pitchname(ps, micro)
return _ParsedMidinote(ps, round(micro, 2), octave, pitchname)
[docs]
def ratio2interval(ratio: float) -> float:
"""
Convert the ratio between 2 freqs. to their interval in semitones
Args:
ratio: a ratio between two frequencies
Returns:
The interval (in semitones) between those frequencies
Example
=======
.. code::
>>> f1 = n2f("C4")
>>> f2 = n2f("D4")
>>> ratio2interval(f2/f1)
2
.. seealso:: :func:`interval2ratio`, :func:`r2i`, :func:`i2r`
"""
return 12 * math.log(ratio, 2)
[docs]
def interval2ratio(interval: float) -> float:
"""
Convert a semitone interval to a ratio between 2 freqs.
Args:
interval: an interval in semitones
Returns:
the ratio between frequencies corresponding to the given interval
Example
=======
.. code::
>>> f1 = n2f("C4")
>>> r = interval2ratio(7) # a 5th higher
>>> f2n(f1*r)
4G
.. seealso:: :func:`ratio2interval`, :func:`r2i`, :func:`i2r`
"""
return 2 ** (interval / 12.0)
r2i = ratio2interval
i2r = interval2ratio
[docs]
def quantize_midinote(midinote: float, divisions_per_semitone, method="round"
) -> float:
"""
Quantize midinote to the next semitone division
Args:
midinote: the midinote to round
divisions_per_semitone: resolution of the pitch grid (1, 2 or 4)
method: "round" to quantize to the nearest value in grid, "floor" to
take the next lesser value
Returns:
the quantized midinote
.. seealso:: :func:`quantize_notename`, :func:`pitch_round`, :func:`freq_round`
"""
if method == "round":
return round(midinote * divisions_per_semitone) / divisions_per_semitone
elif method == "floor":
return int(midinote * divisions_per_semitone) / divisions_per_semitone
raise ValueError(f"method should be either 'round' or 'floor', got {method}")
[docs]
def quantize_notename(notename: str, divisions_per_semitone: int) -> str:
"""
Quantize notename to the next semitone divisions
Args:
notename: the notename to quantize
divisions_per_semitone: the number of divisions of the semitone
(1 to quantize to nearest chromatic note)
Returns:
the notename of the quantized pitch
Example
-------
.. code::
>>> quantize_notename("4A+18", 4)
4A+25
.. seealso:: :func:`quantize_midinote`
"""
parts = split_notename(notename)
cents = int(round(parts.cents_deviation / 100 * divisions_per_semitone) / divisions_per_semitone * 100)
if cents >= 100 or cents <= -100:
notename = m2n(round(n2m(notename) * divisions_per_semitone) / divisions_per_semitone)
parts = split_notename(notename)
cents = parts.cents_deviation
return construct_notename(parts.octave, parts.diatonic_name, parts.alteration, cents)
[docs]
def construct_notename(octave: int,
letter: str | int,
alter: int | str,
cents: int,
freqdev: int = 0,
normalize=False) -> str:
"""
Utility function to construct a valid notename
Args:
octave: the octave of the notename (4 = central octave)
letter: the pitch letter, one of "a", "b", "c", ... (case is not important). Can also
be an int, where 0=C, 1=D, ...
alter: 1 for sharp, -1 for flat, 0 for natural. An alteration as str is also
possible. `alter` should not be microtonal, any microtonal
deviation must be set via the `cents` param)
cents: cents deviation from chromatic pitch
normalize: if True, normalize/check the resulting notename (see :func:`normalize_notename`)
freqdev: frequency deviation from the given pitch (in Hz)
Returns:
the notename
======= ======= ====== ====== ==================
octave letter alter cents notename
======= ======= ====== ====== ==================
4 a -1 -25 4Ab-25
6 d # +40 6D#+40
5 e 0 -50 5E-
======= ======= ====== ====== ==================
.. seealso:: :func:`split_notename`
"""
if isinstance(letter, int):
letter = 'CDEFGABC'[letter]
if isinstance(alter, str):
alterstr = alter
else:
alterstr = "#" if alter == 1 else "b" if alter == -1 else ""
if cents == 50:
centsstr = "+"
elif cents == -50:
centsstr = "-"
else:
centsstr = "+" + str(cents) if cents > 0 else str(cents) if cents < 0 else ""
notename = f"{octave}{letter.upper()}{alterstr}{centsstr}"
if normalize:
notename = normalize_notename(notename)
if freqdev:
sign = '+' if freqdev > 0 else '-'
notename += f'{sign}{abs(freqdev)}'
return notename
[docs]
def pitchbend2cents(pitchbend: int, maxcents=200) -> int:
"""
Convert a MIDI pitchbend to its corresponding deviation in cents
Args:
pitchbend: the MIDI pitchbend value, between 0-16383 (8192 = 0 semitones)
maxcents: the cents corresponding to the max. bend
Returns:
the bend expressed in cents
.. seealso:: :func:`cents2pitchbend`
"""
return int(((pitchbend / 16383.0) * (maxcents * 2.0)) - maxcents + 0.5)
[docs]
def cents2pitchbend(cents: int, maxcents=200) -> int:
"""
Convert a deviation in cents to the corresponding value as pitchbend.
Args:
cents: the bend interval, in cents
maxcents: the cents corresponding to the max. bend
Returns:
the bend MIDI value (between 0-16383)
.. seealso:: :func:`pitchbend2cents`
"""
return int((cents + maxcents) / (maxcents * 2.0) * 16383.0 + 0.5)
_centsrepr = {
'#+': 150,
'#>': 125,
'#': 100,
'#<': 75,
'+': 50,
'>': 25,
'': 0,
'<': -25,
'-': -50,
'b>': -75,
'b': -100,
'b<': -125,
'b-': -150,
'♯+': 150,
'♯↑': 125,
'♯': 100,
'♯↓': 75,
'↑': 25,
'↓': -25,
'♭↑': -75,
'♭': -100,
'♭↓': -125,
'♭-': -150
}
[docs]
def alteration_to_cents(alteration: str) -> int:
"""
Convert an alteration to its corresponding cents deviation
Args:
alteration: the alteration as str (see table below)
Returns:
the alteration in cents
============= ==========
Alternation Cents
============= ==========
``#+`` 150
``#>`` 125
``# `` 100
``#<`` 75
``+ `` 50
``> `` 25
``< `` -25
``- `` -50
``b>`` -75
``b `` -100
``b<`` -125
``b-`` -150
============= ==========
"""
cents = _centsrepr.get(alteration)
if cents is None:
raise ValueError(f"Unknown alteration: {alteration}, "
f"it should be one of {', '.join(_centsrepr.keys())}")
return cents
def _int_or_none(s: str) -> int | None:
try:
return int(s)
except ValueError:
return None
def _parse_centstr(centstr: str) -> int | None:
cents = _centsrepr.get(centstr)
return cents if cents is not None else _int_or_none(centstr)
def _parse_deviation(deviation: str) -> tuple[int, int]:
"""
Splits a string of the form +10-30hz or -10hz or +20+1hz
Args:
deviation:
Returns:
a tuple (centsdeviation, freqdeviation)
"""
if deviation.endswith('hz'):
match = _re.match(r"([-+]\d+)?([-+]\d+)hz", deviation)
if not match:
raise ValueError(f"Could not parse deviation '{deviation}'")
centstr = match.group(1)
cents = int(centstr) if centstr else 0
freq = int(match.group(2))
return cents, freq
else:
cents = _parse_centstr(deviation)
if cents is None:
raise ValueError(f"Could not parse deviation {deviation}")
return cents, 0
def _split_notename_regex(notename: str) -> NoteParts:
# This is somewhat slower (~ 50% slower) than the version below
if m := _re.match(_r2b, notename):
octave, pitch, centstr = m.groups()
elif m := _re.match(_r1b, notename):
pitch, octave, centstr = m.groups()
else:
raise ValueError(f"Invalid notename '{notename}'")
if pitch[-1] in 'b#':
letter, alter = pitch[:-1], pitch[-1]
else:
letter, alter = pitch, ''
cents = _parse_centstr(centstr)
if cents is None:
raise ValueError(f"Could not parse cents {centstr} while parsing {notename}")
return NoteParts(int(octave), letter, alter, cents)
[docs]
@_cache
def split_notename(notename: str) -> NoteParts:
"""
Splits a notename into octave, letter, alteration and cents
Args:
notename: the notename to split
Microtonal alterations, like "+" (+50), "-" (-50), ">" (+25), "<" (-25)
are resolved into cents alterations. Hertz deviations (for example '4C+15hz') are not supported
here
======= ===================
Input Output
======= ===================
4C#+10 4, "C", "#", 10
Eb4-15 4, "E", "b", -15
4C+ 4, "C", "", 50
5Db< 5, "D", "b", -25
======= ===================
.. seealso:: :func:`notated_pitch`, :func:`construct_notename`
"""
return NoteParts(*_split_notename(notename))
@_cache
def _split_notename(notename: str) -> tuple[int, str, str, int]:
"""
Splits a notename into octave, letter, alteration and cents
Args:
notename: the notename to split
Microtonal alterations, like "+" (+50), "-" (-50), ">" (+25), "<" (-25)
are resolved into cents alterations. Hertz deviations (for example '4C+15hz') are not supported
here
======= ===================
Input Output
======= ===================
4C#+10 4, "C", "#", 10
Eb4-15 4, "E", "b", -15
4C+ 4, "C", "", 50
5Db< 5, "D", "b", -25
======= ===================
.. seealso:: :func:`notated_pitch`, :func:`construct_notename`
"""
if len(notename) <= 1:
raise ValueError(f"A note consists of at least an octave and a pitchclass, got '{notename}'")
if notename.endswith('hz'):
raise ValueError(f'Pitches with a frequency deviation are not supported '
f'in this context, got "{notename}"')
c0 = notename[0]
if c0.isdecimal() or c0 == '-':
# 4C#-10, -1Ab+
if c0 == '-':
cursor = 1
octavesign = -1
else:
cursor = 0
octavesign = 1
# common case: octave between 0 and 9
n0 = notename[cursor]
n1 = notename[cursor+1]
if n0.isdecimal() and not n1.isdecimal():
octave, letter = int(n0), n1
cursor += 2
else:
for i in range(10):
if not notename[cursor+i].isdecimal():
break
else:
raise ValueError(f"Invalid octave in '{notename}'")
octave = int(notename[cursor:cursor+i])
cursor += i
letter = notename[cursor]
cursor += 1
octave *= octavesign
if len(notename) == cursor:
cents = 0
alter = ""
else:
r0 = notename[cursor]
if r0 == 'b' or r0 == '#':
alter = r0
centstr = notename[cursor+1:]
else:
centstr = notename[cursor:]
alter = ''
cents = _parse_centstr(centstr) if centstr else 0
if cents is None:
raise ValueError(f"Could not parse cents '{centstr}' while parsing note '{notename}'")
else:
# C#4-10
letter = notename[0]
# find alteration
l1 = notename[1]
if l1 == "#" or l1 == 'b':
alter = l1
cursor = 2
else:
alter = ""
cursor = 1
octchar = notename[cursor]
if octchar.isdecimal():
octave = int(octchar)
cursor += 1
else:
raise ValueError(f"Invalid octave in '{notename}'")
deviation = notename[cursor:]
cents = _parse_centstr(deviation)
if cents is None:
raise ValueError(f"Could not parse cents '{deviation}' while parsing note '{notename}'")
return octave, letter.upper(), alter, cents
[docs]
def split_cents(notename: str) -> tuple[str, int]:
"""
Split a notename into the chromatic note and the cents deviation.
The cents deviation can be a negative or possitive integer or an abbreviation
('+', '-', '>', '<')
Args:
notename: the notename to split
Returns:
the chromatic pitch and the cents deviation from this chromatic pitch
======== ============
Input Output
======== ============
"4E-" ("4E", -50)
"5C#+10" ("5C#", 10)
======== ============
.. seealso:: :func:`split_notename`
"""
parts = split_notename(notename)
return f"{parts.octave}{parts.diatonic_name}{parts.alteration}", parts.cents_deviation
[docs]
@_cache
def enharmonic(n: str, maxcents=50, diatonic_enharmonics=False) -> str:
"""
Returns the enharmonic variant of notename
For simplicity, we considere a possible enharmonic variant a note
with the same sounding pitch and an alteration smaller than 150
cents from the note without any alteration (no double sharps or
flats). If diatonic_enharmonics is False, enharmonics like Fb or E#
are not returned.
Args:
notename (str): the note to find an enharmonic variant to
maxcents: the returned enharmonic has a cents deviation
lower than this. For example,
"4A+30" has no enharmonic and is returned as is,
since the only possible enharmonic would be "4Bb-70",
but a cents deviation of 70 is not allowed
Returns:
either the enharmonic variant or the note itself
===== =========== ================
Note Enharmonic Has Enharmonic?
===== =========== ================
4# 4Db ✓
4C+ 4Db- ✓
4E 4E –
4E# 4F ✓
4A+10 4A+10 –
4E-25 4E-25 –
4E- 4D#+ ✓
===== =========== ================
.. seealso:: :func:`transpose`
"""
mincents = 100 - maxcents
p = notated_pitch(n)
if abs(p.cents_deviation) >= 100:
raise ValueError(f"Invalid notename: {n}, abs. cents deviation should be lower than 100, got {p.cents_deviation}")
if p.chromatic_alteration == 0:
if p.chromatic_name not in _enharmonic_pitchclass:
return n
chrompitch, octaveoffset = _enharmonic_pitchclass[p.chromatic_name]
return f"{p.octave+octaveoffset}{chrompitch}"
if abs(p.diatonic_alteration) < 1:
if p.is_white_key:
# 4A+30 -> 4Bb-70, 4A+80 -> 4Bb-20, 4A+20 -> 4A+20, 4E-60 -> 4D#+40, 4A-80 -> 4G#+20
if abs(p.cents_deviation) < mincents:
if diatonic_enharmonics and ((p.chromatic_name in "BE" and p.cents_deviation > 0) or (p.chromatic_name in "CF" and p.cents_deviation < 0)):
pitch, octaveoffset = _enharmonic_pitchclass[p.chromatic_name]
cents = cents_repr(p.cents_deviation + (-100 if p.cents_deviation > 0 else 100))
return f"{p.octave+octaveoffset}{pitch}{cents}"
return n
if p.diatonic_alteration > 0:
pitch = _flats[p.chromatic_index+1]
octave = p.octave if p.chromatic_index < 11 else p.octave + 1
return f"{octave}{pitch}{cents_repr(p.cents_deviation-100)}"
else:
pitch = _sharps[p.chromatic_index-1]
octave = p.octave if p.chromatic_index != 0 else p.octave - 1
return f"{octave}{pitch}{cents_repr(p.cents_deviation+100)}"
else:
# 4C#-20 -> 4Db-20 or 4D+80, depending on maxcents
# 4Db+20 -> 4C#+20 or 4D-80
if abs(p.cents_deviation) < 100 - maxcents:
pitch, octaveoffset = _enharmonic_pitchclass[p.chromatic_name]
return f"{p.octave+octaveoffset}{pitch}{p.cents_str}"
elif p.diatonic_alteration > 0:
# 4D#-50 -> 4D+
pitch = _flats[p.chromatic_index-1]
octave = p.octave if p.chromatic_index > 0 else p.octave - 1
return f"{octave}{pitch}{cents_repr(p.cents_deviation+100)}"
else:
pitch = _sharps[p.chromatic_index+1]
octave = p.octave if p.chromatic_index < 11 else p.octave + 1
return f"{octave}{pitch}{cents_repr(100-p.cents_deviation)}"
elif p.diatonic_alteration > 1:
# 4C#+20 -> 4Db+20 (if maxcents=50), 4C#+60 -> 4D-40
if 100 - p.cents_deviation < maxcents:
# 4C#+20 -> 4Db+20
pitch = _flats[p.chromatic_index]
return f"{p.octave}{pitch}{cents_repr(p.cents_deviation)}"
else:
# 4C#+60 -> 4D-40
pitch = _flats[p.chromatic_index+1]
return f"{p.octave}{pitch}{cents_repr(p.cents_deviation - 100)}"
else:
# assert p.diatonic_alteration < -1
# 4Ab-20 -> 4G#-20 or 4G+80, depending on maxcents
if 100 + p.cents_deviation > maxcents:
# 4Db-20 -> 4C#-20
pitch = _sharps[p.chromatic_index]
octave = p.octave - 1 if pitch == "B" else p.octave + 1 if pitch == "C" else p.octave
return f"{octave}{pitch}{p.cents_str}"
else:
# 4Db-60 -> 4C+40
pitch = _flats[p.chromatic_index-1]
return f"{p.octave}{pitch}{cents_repr(p.cents_deviation + 100)}"
def _enharmonic_old(notename: str) -> str:
"""
Returns the enharmonic variant of notename
For simplicity, we considere a possible enharmonic variant a note
with the same sounding pitch and an alteration smaller than 150
cents from the note without any alteration (no double sharps or
flats). Also not accepted are enharmonics like Fb or E#
Args:
notename (str): the note to find an enharmonic variant to
Returns:
either the enharmonic variant or the note itself
===== =========== ================
Note Enharmonic Has Enharmonic?
===== =========== ================
4# 4Db ✓
4C+ 4Db- ✓
4E 4E –
4E# 4F ✓
4A+10 4A+10 –
4E-25 4E-25 –
4E- 4D#+ ✓
===== =========== ================
.. seealso:: :func:`transpose`
"""
p = notated_pitch(notename)
if abs(p.diatonic_alteration) < 1:
if abs(p.cents_deviation) < 50:
return notename
if p.cents_deviation >= 50:
chrom = _flats[p.chromatic_index + 1]
octave = p.octave if p.chromatic_name != "B" else p.octave + 1
return f"{octave}{chrom}{cents_repr(p.cents_deviation - 100)}"
else:
# 4E- : 4D#+
# 4E-60 : 4D#+40
chrom = _sharps[(p.chromatic_index - 1) % 12]
octave = p.octave if p.chromatic_name != "C" else p.octave - 1
return f"{octave}{chrom}{cents_repr(100 + p.cents_deviation)}"
if p.diatonic_alteration >= 1:
# 4C# : 4Db
# 4C#+25 : 4Db+25
# 4C#+ : 4D-
# 4C#+60 : 4D-40
# 4D#-25 : 4Eb-25
# 4D#-70 : 4D+30
if 0 <= abs(p.cents_deviation) < 50:
chrom = _flats[p.chromatic_index]
return f"{p.octave}{chrom}{p.cents_str}"
elif 50 <= p.cents_deviation < 100:
chrom = _flats[p.chromatic_index + 1]
centstr = cents_repr(p.cents_deviation - 100)
elif -100 < p.cents_deviation < 50:
chrom = _flats[(p.chromatic_index - 1) % 12]
centstr = cents_repr(100 + p.cents_deviation)
else:
raise ValueError(f"Invalid cents deviation {p.cents_deviation} ({notename=}, {p=})")
return f"{p.octave}{chrom}{centstr}"
else: # p.diatonic_alteration == -1:
# 4Db : 4C#
# 4Db-25 : 4C#-25
# 4Db- : 4C+
# 4Db-60 : 4C+40
# 4Eb+25 : 4D#+25
# 4Eb+70 : 4E-30
if 0 <= abs(p.cents_deviation) < 50:
chrom = _sharps[p.chromatic_index]
return f"{p.octave}{chrom}{p.cents_str}"
elif 50 <= p.cents_deviation < 100:
chrom = _sharps[p.chromatic_index - 1]
centstr = cents_repr(p.cents_deviation - 100)
elif -100 < p.cents_deviation:
chrom = _sharps[p.chromatic_index - 1]
centstr = cents_repr(100 + p.cents_deviation)
else:
raise ValueError(f"Invalid cents deviation {p.cents_deviation} ({notename=}, {p=})")
return f"{p.octave}{chrom}{centstr}"
[docs]
def pitch_round(midinote: float, semitone_divisions=1) -> tuple[str, int]:
"""
Round midinote to the next (possibly microtonal) note
Returns the rounded notename and the cents deviation
from the original pitch to the nearest semitone
Args:
midinote: the midinote to round, as float
semitone_divisions: the number of division per semitone
Returns:
a tuple (rounded note, cents deviation). NB: the cents deviation can
be negative (see example below)
Example
=======
.. code::
>>> pitch_round(60.1)
("4C", 10)
>>> pitch_round(60.75)
("4D<", -25)
.. seealso:: :func:`quantize_midinote`
"""
rounding_factor = 1 / semitone_divisions
rounded_midinote = round(midinote / rounding_factor) * rounding_factor
notename = m2n(rounded_midinote)
basename, cents = split_cents(notename)
mididev = midinote - n2m(basename)
centsdev = int(round(mididev * 100))
return notename, centsdev
[docs]
def notated_interval(n0: str, n1: str) -> tuple[int, float]:
"""
Gives information regarding the notated interval between n0 and n1
Args:
n0: the first notename
n1: the second notename
Return:
a tuple (delta vertical position, delta midinote).
Examples
~~~~~~~~
>>> notated_interval("4C", "4D")
(1, 2) # 1 vertical step, 2 semitone steps
>>> notated_interval("4C", "4C+")
(0, 0.5)
>>> notated_interval("4C", "4Db")
(1, 1)
>>> notated_interval("4Db", "4C")
(-1, -1)
.. seealso:: :func:`notated_pitch`
"""
vertpos0 = vertical_position(n0)
vertpos1 = vertical_position(n1)
return (vertpos1 - vertpos0, n2m(n1) - n2m(n0))
[docs]
def enharmonic_variations(notes: list[str],
fixedslots: dict[int, int] | None = None,
force: bool = False,
) -> list[tuple[str, ...]]:
"""
Generates all enharmonic variations of the given notes
Args:
notes: a list of notenames
fixedslots: a dict of slot:alteration_direction, fixes the given slots
to a given alteration direction (1=#, -1=b). If Slot 0 corresponds to C,
1 to C+/Db-, 2 to C#/Db, etc.
force: if True, it will always return at least a variation even if there are
no valid solutions
Returns:
a list of enharmonic alternatives
.. seealso:: :func:`enharmonic`
"""
# C D E F G A B
NON_ENHARMONIC_SLOTS = frozenset({0, 4, 8, 10, 14, 18, 22})
if fixedslots is None:
fixedslots = {}
# Pre-compute outside the search
per_note: list[tuple[tuple[str, int, int, bool], ...]] = []
for n in notes:
variants = (n, enharmonic(n))
pair = []
for v in variants:
notated = notated_pitch(v)
slot = notated.microtone_index(semitone_divisions=2)
pair.append((v, slot, notated.alteration_direction(), slot in NON_ENHARMONIC_SLOTS))
per_note.append(tuple(pair))
results: list[tuple[str, ...]] = []
def backtrack(note_idx: int, current_slots: dict[int, int], row: list[str]) -> None:
if note_idx == len(per_note):
results.append(tuple(row))
return
for name, slot, direction, skip in per_note[note_idx]:
if skip:
row.append(name)
backtrack(note_idx + 1, current_slots, row)
row.pop()
break
fixed_dir = current_slots.get(slot)
if fixed_dir is not None and fixed_dir != 0 and fixed_dir != direction:
continue
is_new = slot not in current_slots
current_slots[slot] = direction
row.append(name)
backtrack(note_idx + 1, current_slots, row)
row.pop()
if is_new:
del current_slots[slot]
elif (slotval := fixedslots.get(slot)) is not None:
current_slots[slot] = slotval
backtrack(0, fixedslots.copy(), [])
# Deduplicate (can still occur when both variants share the same spelling)
out = list(dict.fromkeys(results))
return out if out or not force else [tuple(notes)]
def _enharmonic_variations(notes: list[str],
fixedslots: dict[int, int] | None = None,
force=False
) -> list[tuple[str, ...]]:
"""
Generates all enharmonic variations of the given notes
Args:
notes: a list of notenames
fixedslots: a dict of slot:alteration_direction, fixes the given slots
to a given alteration direction (1=#, -1=b). If Slot 0 corresponds to C,
1 to C+/Db-, 2 to C#/Db, etc.
force: if True, it will always return at least a variation even if there are
no valid solutions
Returns:
a list of enharmonic alternatives
.. seealso:: :func:`enharmonic`
"""
# C C+ C# D- D D+ D# E- E E+ F F+ F# G- G G+ G# A- A A+ A# B- B B+
# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3
non_enharmonic_slots = {0, 4, 8, 10, 14, 18, 22}
variants_per_note = [(n, enharmonic(n)) for n in notes]
allvariants: list[tuple[str, ...]] = []
if fixedslots is None:
fixedslots = {}
for indexes in _itertools.product(*[(0, 1)] * len(notes)):
# indexes contains a row of the form (0, 0, 1) for 3 notes
row: list[str] = []
rowslots = fixedslots.copy()
for idx, variants in zip(indexes, variants_per_note):
notename = variants[idx]
notated = notated_pitch(notename)
slotindex = notated.microtone_index(semitone_divisions=2)
if slotindex in non_enharmonic_slots:
row.append(notename)
continue
fixed_dir = rowslots.get(slotindex)
if fixed_dir is None or fixed_dir == 0 or fixed_dir == notated.alteration_direction():
rowslots[slotindex] = notated.alteration_direction()
row.append(notename)
else:
# the slot has an opposite direction, for example one note
# was spelled C#, so we can't accept Db as alteration
break
if len(row) == len(notes):
# a valid row
allvariants.append(tuple(row))
out = list(set(allvariants))
return out if out or not force else [tuple(notes)]
_chromatic_transpositions = {
'C': ('C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C'),
'C#': ('C#', 'D', 'D#', 'E', 'E#', 'F#', 'G', 'G#', 'A', 'A#', 'B', 'C', 'C#'),
'Db': ('Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'Db'),
'D': ('D', 'Eb', 'E', 'F', 'F#', 'G', 'G#', 'A', 'Bb', 'B', 'C', 'C#', 'D'),
'D#': ('D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B', 'C', 'C#', 'D', 'D#'),
'Eb': ('Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'Db', 'D', 'Eb'),
'E': ('E', 'F', 'F#', 'G', 'G#', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'D#', 'E'),
'F': ('F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'Db', 'D', 'Eb', 'E', 'F'),
'F#': ('F#', 'G', 'G#', 'A', 'A#', 'B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#'),
'Gb': ('Gb', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb'),
'G': ('G', 'Ab', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G'),
'G#': ('G#', 'A', 'A#', 'B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#'),
'Ab': ('Ab', 'A', 'Bb', 'B', 'C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab'),
'A': ('A', 'Bb', 'B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A'),
'A#': ('A#', 'B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#'),
'Bb': ('Bb', 'B', 'C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb'),
'B': ('B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B')
}
_chromatic_interval_to_diatonic_interval = {
0: 0,
1: 1,
2: 1,
3: 2,
4: 2,
5: 3,
6: 3,
7: 4,
8: 5,
9: 5,
10: 6,
11: 6,
12: 7
}
[docs]
def transpose(notename: str, interval: float, white_enharmonic=True) -> str:
"""
Transpose a note by an interval taking spelling into account
The main difference with just doing ``m2n(n2m(notename)+interval)`` is that
here the spelling of the note is taken into consideration when transposing.
Notice that as the interval is given as a float there is no means to convey
enharmonic intervals, like augmented second, etc.
========= ========= =============================== ===========================
notename interval transpose ``m2n(n2m(name)+interval)``
========= ========= =============================== ===========================
4Eb 5 4Ab 4G#
4D# 5 4G# 4G#
4Db 2 4Eb 4D#
4C# 2 4D# 4D#
4Db 4.2 4F+20 4F+20
4C# 4.2 4E#+20 (white_enharmonic=True) 4F+20
========= ========= =============================== ===========================
Args:
notename: the note
interval: an interval in semitones
white_enharmonic: if True, allow pitches like 4E# or 5Cb or any
microtonal variation (4E#+20). If False, such pitches are
replaced by their enharmonic (4F, 4B).
Returns:
the transposed pitch
.. seealso:: :func:`enharmonic`
"""
midi1 = n2m(notename)
midi2 = midi1 + interval
octave = split_notename(m2n(midi2)).octave
parts1 = split_notename(notename)
chromatic1 = parts1.diatonic_name + parts1.alteration
rounded_interval = round(interval)
deviation = interval - rounded_interval
deviationcents = round(deviation * 100) + parts1.cents_deviation
chromatic2 = _chromatic_transpositions[chromatic1][rounded_interval % 12]
diff = n2m(f"{octave}{chromatic2}") - n2m(notename)
if diff < 0 < interval:
octave += 1
elif diff > 0 and interval < 0:
octave -= 1
letter = chromatic2[0]
alter = chromatic2[1] if len(chromatic2) == 2 else 0
if not white_enharmonic:
if letter == 'E' and alter == '#':
letter, alter = 'F', ''
elif letter == 'F' and alter == 'b':
letter, alter = 'E', ''
elif letter == 'B' and alter == '#':
letter, alter = 'C', ''
octave += 1
elif letter == 'C' and alter == 'b':
letter, alter = 'B', ''
octave -= 1
out = construct_notename(octave=octave, letter=letter, alter=alter, cents=deviationcents)
if deviationcents < -50 or deviationcents > 50:
out = normalize_notename(out)
return out
[docs]
def freq2mel(freq: float) -> float:
"""
Convert a frequency to its place in the mel-scale
.. note::
The mel scale is a perceptual scale of pitches judged by listeners to be
equal in distance from one another
.. seealso:: :func:`mel2freq`
"""
return 1127.01048 * math.log(1. + freq / 700)
[docs]
def mel2freq(mel: float) -> float:
"""
Convert a position in the mel-scale to its corresponding frequency
Args:
mel: the mel index (can be fractional)
Returns:
the corresponding freq. in Hz
.. note::
The mel scale is a perceptual scale of pitches judged by listeners to be
equal in distance from one another
.. seealso:: :func:`freq2mel`
"""
return 700. * (math.exp(mel / 1127.01048) - 1.0)
_centsToAccidentalName = {
# cents name
0: 'natural',
25: 'natural-up',
50: 'quarter-sharp',
75: 'sharp-down',
100: 'sharp',
125: 'sharp-up',
150: 'three-quarters-sharp',
175: 'three-quarters-sharp-up',
-25: 'natural-down',
-50: 'quarter-flat',
-75: 'flat-up',
-100: 'flat',
-125: 'flat-down',
-150: 'three-quarters-flat',
-175: 'three-quarters-flat-down'
}
[docs]
def accidental_name(alteration_cents: int, semitone_divisions=4) -> str:
"""
The name of the accidental corresponding to the given cents
Args:
alteration_cents: 100 = sharp, -50 = quarter-flat, etc.
semitone_divisions: number of divisions of the semitone
Returns:
the name of the corresponding accidental, as string
========== ==================
cents alteration name
========== ==================
0 natural
25 natural-up
50 quarter-sharp
75 sharp-down
100 sharp
125 sharp-up
150 three-quarters-sharp
-25 natural-down
-50 quarter-flat
-75 flat-up
-100 flat
-125 flat-down
-150 three-quarters-flat
========== ==================
.. seealso:: :func:`construct_notename`, :func:`split_notename`
"""
assert semitone_divisions in {1, 2, 4}, "semitone_divisions should be 1, 2, or 4"
centsResolution = 100 // semitone_divisions
alteration_cents = round(alteration_cents / centsResolution) * centsResolution
return _centsToAccidentalName[alteration_cents]
def _roundres(x: float, resolution: float) -> float:
return round(x / resolution) * resolution
[docs]
def vertical_position(note: str) -> int:
"""
Return the vertical notated position of a note
The only relevant information for the vertical position
is the octave and the diatonic pitch class. So, 4G# and 4G
have the same vertical position, 4Ab and 4G# do not (the vertical
position of 4Ab is 4*7+6=34, for 4G# it is 33)
.. seealso:: :func:`notated_pitch`, :func:`notated_interval`
"""
notated = notated_pitch(note)
return notated.vertical_position
[docs]
def vertical_position_to_note(pos: int) -> str:
"""
Given a vertical position as an integer, returns the corresponding (diatonic) note
Args:
pos: the position as index, where 0 is C
Returns:
the note corresponding to the given vertical position
>>> vertical_position_to_note(2)
0E
>>> vertical_position_to_note(0)
0C
"""
# CDEFGAB
# 0123456
octave = pos // 7
diatonic_step = pos % 7
step = "CDEFGAB"[diatonic_step]
return f"{octave}{step}"
[docs]
@_cache
def notated_pitch(pitch: float | str, semitone_divisions=4) -> NotatedPitch:
"""
Convert a note or a (fractional) midinote to a NotatedPitch
Args:
pitch: a midinote as float (60=4C), or a notename
semitone_divisions: number of divisions per semitone (only relevant
when passing a midinote as pitch
Returns:
the corresponding pitch as NotatedPitch
.. seealso:: :func:`split_notename`, :class:`NotatedPitch`
Example
~~~~~~~
>>> notated_pitch("4C#+15")
NotatedPitch(octave=4, diatonic_index=0, diatonic_name='C', chromatic_index=1,
chromatic_name='C#', diatonic_alteration=1.15, chromatic_alteration=0.15,
accidental_name='sharp-up')
>>> notated_pitch("4Db+15")
NotatedPitch(octave=4, diatonic_index=1, diatonic_name='D', chromatic_index=1,
chromatic_name='Db', diatonic_alteration=-0.85, chromatic_alteration=0.15,
accidental_name='flat-up')
"""
if isinstance(pitch, (int, float)):
pitch = _roundres(pitch, 1 / semitone_divisions)
return _notated_pitch_notename(pitch)
@_cache
def _notated_pitch_notename(notename: str) -> NotatedPitch:
parts = split_notename(notename)
diatonic_index = 'CDEFGABC'.index(parts.diatonic_name)
chromatic_note = parts.diatonic_name + parts.alteration
cents = parts.cents_deviation
diatonic_alteration = (alteration_to_cents(parts.alteration) + cents) / 100
return NotatedPitch(octave=parts.octave,
diatonic_index=diatonic_index,
diatonic_name=parts.diatonic_name,
chromatic_index=_pitchclass_chromatic[chromatic_note],
chromatic_name=chromatic_note,
diatonic_alteration=diatonic_alteration,
chromatic_alteration=cents / 100,
accidental_name=accidental_name(int(diatonic_alteration * 100)))
[docs]
@_cache
def notename_upper(notename: str) -> str:
"""
Convert from 4eb to 4Eb or from eb4 to Eb4
Args:
notename: the notename to convert
Returns:
the uppercase version of notename
"""
parts = split_notename(notename)
return construct_notename(parts.octave, parts.diatonic_name, parts.alteration, parts.cents_deviation)
[docs]
def split_frequency_deviation(pitch: str) -> tuple[str, int]:
"""
For a pitch given as 4Eb-14hz, returns `('4Eb', -14)`
Args:
pitch: the pitch as string
Returns:
a tuple (notename, frequency_deviation)
"""
if not pitch.endswith('hz'):
return pitch, 0
parts = _re.split(r'([-+])', pitch)
if parts[-1].endswith('hz'):
freqdev = int(parts[-1][:-2])
if len(parts) == 1:
# Pure frequency, like '442hz'
return '', freqdev
if parts[-2] == '-':
freqdev = -freqdev
notename = ''.join(parts[:-2])
return notename, freqdev
else:
return pitch, 0
[docs]
def pitchclass(notename: str, semitone_divisions=1) -> int:
"""
Returns the pitchclass of a given note, rounded to the nearest semitone division
This index is independent of the pitche's octave.
For chromatic resolotion (semitone_divisions=1), 4C has a pitchclass of 0
(the same as any other C), 4C# and 4Db have a pitchclass of 1, 4D a pitchclass
of 2, etc. If semitone_divisions is 2, then 4C has still a pitchclass of ß but
4C# would have a pitchclass of 2, while 4C+ and 4Db- would result in a pitchclass
of 1. Enharmonic pitches (4C# and 4Db) result in the same pitchclass.
Args:
notename: the pitch as notename
semitone_divisions: the number of divisions per semitone
(1=chromatic, 2=quarter tones, ...)
Returns:
the pitch-class index.
"""
notated = notated_pitch(notename)
if semitone_divisions == 1:
return notated.chromatic_index
return notated.microtone_index(semitone_divisions=semitone_divisions)
[docs]
def notes2ratio(n1: float|str, n2: float|str, maxdenominator=16
) -> tuple[int, int]:
"""
Find the ratio between n1 and n2
Args:
n1: first note (a midinote or a notename)
n2: second note (a midinote or a notename)
maxdenominator: the maximum denominator possible
Returns:
a Fraction with the ratio between the two notes
NB: to obtain the ratios of the harmonic series, the second note
should match the intonation of the corresponding overtone of
the first note
====== ======= =====
Note 1 Note 2 Ratio
====== ======= =====
C4 D4 8/9
C4 Eb4+20 5/6
C4 E4 4/5
C4 F#4-30 5/7
C4 G4 2/3
C4 A4 3/5
C4 Bb4-30 4/7
C4 B4 8/15
====== ======= =====
.. seealso:: :func:`ratio2interval`
"""
f1 = n2f(n1) if isinstance(n1, str) else m2f(n1)
f2 = n2f(n2) if isinstance(n2, str) else m2f(n2)
from fractions import Fraction
fraction = Fraction.from_float(f1 / f2).limit_denominator(maxdenominator)
return fraction.numerator, fraction.denominator
# --- Global functions ---
_default_converter = PitchConverter.default()
"""The default pitch converter"""
midi_to_note_parts = _default_converter.midi_to_note_parts
set_reference_freq = _default_converter.set_reference_freq
get_reference_freq = _default_converter.get_reference_freq
f2m = _default_converter.f2m
m2f = _default_converter.m2f
m2n = _default_converter.m2n
n2f = _default_converter.n2f
f2n = _default_converter.f2n
freq_round = _default_converter.freq_round
normalize_notename = _default_converter.normalize_notename
str2midi = _default_converter.str2midi
as_midinotes = _default_converter.as_midinotes
# --- Amplitude converters ---
[docs]
def db2amp(db: float) -> float:
"""
convert dB to amplitude (0, 1)
Args:
db: a value in dB
.. seealso:: :func:`amp2db`
"""
return 10.0 ** (0.05 * db)
[docs]
def amp2db(amp: float) -> float:
"""
convert amp (0, 1) to dB
``20.0 * log10(amplitude)``
Args:
amp: the amplitude between 0 and 1
Returns:
the corresponding amplitude in dB
.. seealso:: :func:`db2amp`
"""
amp = max(amp, _EPS)
return math.log10(amp) * 20
def _test_enharmonic():
assert (result := enharmonic("4E+")) == "4F-", f"got {result}"
assert (result := enharmonic("4F#")) == "4Gb", f"got {result}"
assert (result := enharmonic("4C+")) == "4Db-", f"got {result}"
assert (result := enharmonic("4G-")) == "4F#+", f"got {result}"
assert (result := enharmonic("4G+60")) == "4Ab-40", f"got {result}"
assert (result := enharmonic("4E+25")) == "4E+25", f"got {result}"
assert (result := enharmonic("5Eb-55")) == "5D+45", f"got {result}"
assert (result := enharmonic("4G-25")) == "4G-25", f"got {result}"
assert (result := enharmonic("4C-25")) == "4C-25", f"got {result}"
assert (result := enharmonic("4C-")) == "3B+", f"got {result}"
assert (result := enharmonic("3B-")) == "3A#+", f"got {result}"
assert (result := enharmonic("4F-55")) == "4E+45", f"got {result}"
def _tests():
_test_enharmonic()