"""
Module containing pitch-related utility functions, such as those that convert between midi and hertz.
"""
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ #
# This file is part of SCAMP (Suite for Computer-Assisted Music in Python) #
# Copyright © 2020 Marc Evanstein <marc@marcevanstein.com>. #
# #
# This program 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 program 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 <http://www.gnu.org/licenses/>. #
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ #
from typing import Sequence, Dict
from numbers import Real
from scamp_extensions.utilities.sequences import multi_option_function
import math
# ----------------------------------------------- Pitch Space Conversions ---------------------------------------------
[docs]@multi_option_function
def ratio_to_cents(ratio: Real) -> Real:
"""
Given a frequency ratio, convert it to a corresponding number of cents.
:param ratio: frequency ratio (e.g. 1.5 for a perfect fifth)
"""
return math.log2(ratio) * 1200
[docs]@multi_option_function
def cents_to_ratio(cents: Real) -> Real:
"""
Given a number of cents, convert it to a corresponding frequency ratio.
:param cents: number of cents (e.g. 700 for a perfect fifth)
"""
return math.pow(2, cents / 1200)
[docs]@multi_option_function
def midi_to_hertz(midi_value: Real, A: Real = 440) -> Real:
"""
Given a MIDI pitch, returns the corresponding frequency in hertz.
:param midi_value: a midi pitch (e.g. 60 for middle C)
:param A: the tuning of A4 in hertz
"""
return A * math.pow(2, (midi_value - 69) / 12)
[docs]@multi_option_function
def hertz_to_midi(hertz_value: Real, A: Real = 440) -> Real:
"""
Given a frequency in hertz, returns the corresponding (floating point) MIDI pitch.
:param hertz_value: a frequency in hertz
:param A: the tuning of A4 in hertz
"""
return 12 * math.log2(hertz_value / A) + 69
[docs]@multi_option_function
def freq_to_bark(f: Real) -> Real:
"""
Converts a frequency in hertz to a (floating point) Bark number according to the psychoacoustic Bark scale
(https://en.wikipedia.org/wiki/Bark_scale). This is a scale that compensates for the unevenness in human pitch
acuity across our range of hearing. Here we use the function approximation proposed by Terhardt, which was chosen
in part for its ease of inverse calculation.
:param f: the input frequency
"""
return 13.3 * math.atan(0.75*f/1000.0)
# the inverse formula
[docs]@multi_option_function
def bark_to_freq(b: Real) -> Real:
"""
Converts a Bark number to its corresponding frequency in hertz. See :func:`freq_to_bark`.
:param b: a (floating point) bark number
"""
return math.tan(b/13.3)*1000.0/0.75
_pitch_class_displacements = {
'c': 0,
'd': 2,
'e': 4,
'f': 5,
'g': 7,
'a': 9,
'b': 11
}
_accidental_displacements = {
'#': 1,
's': 1,
'f': -1,
'b': -1,
'x': 2,
'bb': -2
}
[docs]@multi_option_function
def note_name_to_number(note_name: str) -> int:
"""
Converts a note name (e.g. "Bb5" or "C#2") to its corresponding MIDI number.
:param note_name: The note name, e.g. "Bb5". The accidental can be any of "#", "s", "f", "b", "x", or "bb".
Uses the convention of "C4" = 60.
"""
note_name = note_name.lower().replace(' ', '')
pitch_class_name = note_name[0]
octave = note_name[-1]
accidental = note_name[1:-1]
return (int(octave) + 1) * 12 + \
_pitch_class_displacements[pitch_class_name] + \
(_accidental_displacements[accidental] if accidental in _accidental_displacements else 0)
# ----------------------------------------------------- Other ---------------------------------------------------------
[docs]def map_keyboard_to_microtonal_pitches(microtonal_pitches: Sequence[float],
squared_penalty: bool = True) -> Dict[int, float]:
"""
Given a list of microtonal (floating-point) MIDI pitches, finds an efficient map from keyboard-friendly (integer)
pitches to the original microtonal pitches. This is really useful if you're trying to audition a microtonal
collection on the keyboard and don't want to deal with making a mapping manually. Note: the code here is taken
nearly verbatim from StackOverflow user `sacha` in response to this question:
https://stackoverflow.com/questions/61825905/match-list-of-floats-to-nearest-integers-without-repeating
:param microtonal_pitches: a collection of floating-point (microtonal) pitches
:param squared_penalty: whether or not the choice is based on simple or squared error. (I.e. are we using taxicab
or euclidean distance.)
:return: a dictionary mapping keyboard-friendly (integer) pitches to the microtonal collection given
"""
import math
import numpy as np
from scipy.optimize import linear_sum_assignment
from scipy.spatial.distance import cdist
microtonal_pitches = np.array(microtonal_pitches)
# hacky safety-net -> which candidates to look at
min_ = math.floor(microtonal_pitches.min())
max_ = math.ceil(microtonal_pitches.max())
gap = max_ - min_
cands = np.arange(min_ - gap, max_ + gap)
cost_matrix = cdist(microtonal_pitches[:, np.newaxis], cands[:, np.newaxis])
if squared_penalty:
cost_matrix = np.square(cost_matrix)
row_ind, col_ind = linear_sum_assignment(cost_matrix)
solution = cands[col_ind]
# cost would be computed like this:
# `cost = cost_matrix[row_ind, col_ind].sum()`
return {rounded_p: p for p, rounded_p in zip(microtonal_pitches, solution)}