"""
Various and sundry utility functions used by SCAMP.
"""
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ #
# 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 __future__ import annotations
import os
import sys
import math
import itertools
import functools
from typing import Iterator, Type, Callable, Sequence, TypeVar
from expenvelope.json_serializer import SavesToJSON, SavesToJSONMeta
# -------------------------------------------- Utility Classes ----------------------------------------------
[docs]class NoteProperty:
"""
Parent class for class for classes like :class:`~scamp.spelling.SpellingPolicy`, :class:`~scamp.text.StaffText` and
:class:`~scamp.playback_adjustments.NotePlaybackAdjustment`, which can be passed to the properties argument of
:func:`~scamp.instruments.ScampInstrument.play_note`.
"""
pass
# ------------------------------------------- General Utilities ---------------------------------------------
[docs]def iterate_all_subclasses(type_name: Type) -> Iterator[Type]:
"""
Iterates all generations of subclasses of a type
"""
for subclass in type_name.__subclasses__():
yield subclass
for x in iterate_all_subclasses(subclass):
yield x
# Define the data folder path (e.g. where settings are stored)
if sys.platform.startswith("win"):
os_data_path = os.getenv("LOCALAPPDATA")
elif sys.platform.startswith("darwin"):
os_data_path = "~/Library/Application Support"
else:
# linux
os_data_path = os.getenv("XDG_DATA_HOME", "~/.local/share")
os_data_path = os.path.expanduser(os_data_path)
scamp_data_path = os.path.join(os_data_path, "SCAMP")
if not os.path.exists(scamp_data_path):
os.makedirs(scamp_data_path, exist_ok=True)
[docs]def resolve_path(path: str) -> str:
"""
Resolves the given path based on a variety of prefixes.
:param path: A path, possibly prefixed by "/", "~/", or "%PKG/". A prefix of "/" will be interpreted as an absolute
path, a prefix of "~/" will be interpreted as relative to the user's home directory, a prefix of "%PKG/"
will be interpreted as relative to the package source directory, and an unprefixed path will be interpreted as
relative to the current working directory.
:return: the resolved path
"""
if path.startswith("%PKG/"):
# Relative to the package source directory
return resolve_package_path(path[5:])
elif path.startswith("%DATA/"):
return os.path.join(scamp_data_path, path[6:])
elif path.startswith("/") or path[1:].startswith(":\\"):
# Absolute soundfont path
return path
elif path.startswith("~/"):
# Relative to user home directory
return os.path.expanduser(path)
else:
# Unprefixed paths are relative to the working directory
return os.path.join(os.getcwd(), path)
[docs]def resolve_package_path(path: str) -> str:
if getattr(sys, 'frozen', False):
# Python is running from a binary executable made by PyInstaller (the bootloader adds 'frozen' to sys)
package_dir = os.path.dirname(sys.executable)
else:
# Otherwise, inspect the stack to find the module that made this call (or the root module if desired)
package_dir = os.path.dirname(__file__)
return os.path.join(package_dir, path)
[docs]def memoize(obj: Callable) -> Callable:
"""
Decorator used for memoization (see https://en.wikipedia.org/wiki/Memoization)
:param obj: the function to be wrapped in a memoizer
:return: the wrapped, memoized function
"""
cache = obj.cache = {}
@functools.wraps(obj)
def memoizer(*args, **kwargs):
key = str(args) + str(kwargs)
if key not in cache:
cache[key] = obj(*args, **kwargs)
return cache[key]
return memoizer
# -------------------------------------------- Numerical Utilities -----------------------------------------------
[docs]def is_x_pow_of_y(x, y) -> bool:
"""
Checks if x is an integer (including negative) power of y
"""
a = math.log(x, y)
if a == int(a):
return True
else:
return False
[docs]def floor_x_to_pow_of_y(x, y):
"""
Returns the integer power of y that is closest below x.
"""
a = math.log(x, y)
return y ** math.floor(a)
[docs]def ceil_x_to_pow_of_y(x, y):
"""
Returns the integer power of y that is closest above x.
"""
a = math.log(x, y)
return y ** math.ceil(a)
[docs]def round_x_to_pow_of_y(x, y):
"""
Returns the integer power of y that is closest to x (above or below).
"""
a = math.log(x, y)
return y ** (int(round(a)) if isinstance(y, int) else round(a))
[docs]def floor_to_multiple(x, factor):
"""
Returns the multiple of factor that is closest below x.
"""
return math.floor(x / factor) * factor
[docs]def ceil_to_multiple(x, factor):
"""
Returns the multiple of factor that is closest above x.
"""
return math.ceil(x / factor) * factor
[docs]def round_to_multiple(x, factor):
"""
Returns the multiple of factor that is closest to x (above or below).
"""
return round(x / factor) * factor
[docs]def is_multiple(x, y) -> bool:
"""
Checks if x is a multiple of y.
"""
return round_to_multiple(x, y) == x
[docs]def prime_factor(n: int) -> list[int]:
"""
Returns a list of the prime factors of n.
"""
i = 2
primes = []
while i * i <= n:
while n % i == 0:
n //= i
primes.append(i)
i += 1
if n != 1:
primes.append(n)
return primes
[docs]def is_prime(a: int) -> bool:
"""
Checks if a is a prime number.
"""
return not (a < 2 or any(a % x == 0 for x in range(2, int(a ** 0.5) + 1)))
# ---------------------------------------------- List Utilities --------------------------------------------------
[docs]def make_flat_list(l: Sequence, indivisible: Type | tuple[Type] = None) -> list:
"""
Flattens a list, including ones containing multiple levels of nesting. Certain types can be excluded from expansion.
:param l: a list or similar iterable
:param indivisible: a type or tuple of types that should not be expanded out. E.g. a custom named tuple
:return: a flattened version of the list
"""
# indivisible_type is a type that we don't want to divide,
new_list = list(l)
i = 0
while i < len(new_list):
if hasattr(new_list[i], "__len__") and not isinstance(new_list[i], str) and \
(indivisible is None or not isinstance(new_list[i], indivisible)):
# if this list item can be expanded and it's not an indivisible type
# then expand it and don't increment i
new_list = new_list[:i] + list(new_list[i]) + new_list[i+1:]
else:
# otherwise increment i
i += 1
return new_list
[docs]def rotate(l: list, n: int) -> list:
"""
Rotates a list such that it starts at the nth index and loops back to end at the (n-1)st
:param l: the input list
:param n: the new start index
:return: the rotated list
"""
return l[n:] + l[:n]
[docs]def sum_nested_list(l: list):
"""
Sums up all of the values within a nested list.
For instance :code:`sum_nested_list([6, [4, 2], [[-7]]])` will return 5
:param l: the input list
:return: sum of the l's nested values
"""
if not hasattr(l, "__len__"):
return l
else:
return sum(sum_nested_list(x) for x in l)
# ---------------------------------------------- String Utilities --------------------------------------------------
[docs]def get_average_square_correlation(test_string: str, template_string: str) -> float:
"""
A test of the similarity of two strings via a (squared) cross-correlation of their characters.
(Scaled down to compensate for the influence of string lengths.)
:param test_string: string we are testing
:param template_string: template string we are testing against
"""
square_correlation_sum = 0
test_length, template_length = len(test_string), len(template_string)
for offset in range(-test_length + 1, template_length):
test_string_segment = test_string[max(0, -offset): template_length - offset]
template_string_segment = template_string[max(0, offset): max(0, offset) + len(test_string_segment)]
correlation_score = sum(a == b for a, b in zip(test_string_segment, template_string_segment))
square_correlation_sum += correlation_score ** 2
return square_correlation_sum / (test_length + template_length)
# ------------------------------------ Indigestibility (a la Clarence Barlow) ------------------------------------
# All of these are needed for the indispensability stuff below
[docs]def indigestibility(n: int) -> float:
"""
Returns the indigestibility of a number per the theories of Clarence Barlow.
(See "On Musiquantics", http://clarlow.org/wp-content/uploads/2016/10/On-MusiquanticsA4.pdf)
"""
assert isinstance(n, int) and n > 0
if is_prime(n):
return 2 * float((n-1)**2) / n
else:
total = 0
for factor in prime_factor(n):
total += indigestibility(factor)
return total
# ------------------------------------- Indispensability (a la Clarence Barlow) -------------------------------------
# Indispensability is a way of assigning importance weightings to the pulses in a meter.
# I use this to improve the readability of the notation outputted. Read the code below at your own risk.
def _first_order_backward_beat_priorities(length):
if hasattr(length, "__getitem__"):
# a list or tuple or suchlike means we're dealing with additive meter
# first reverse the group lengths, since we are doing everything backwards
# lets take the example of length = (3, 5, 2)
group_lengths = length[::-1]
# now group_lengths = (2, 5, 3), since we calculate backwards
# then construct beat groups according to their (backwards) position in the bar
beat_groups = []
beat = 0
for group in group_lengths:
beat_group = []
for i in range(group):
beat_group.append(beat)
beat += 1
beat_groups.append(beat_group)
# in our example, this results in beat_groups = [[0, 1], [2, 3, 4, 5, 6], [7, 8, 9]]
# OK, now we move the beats to a list in order from most indispensable to least
order_of_indispensability = []
# first take the first of each group (these are the beats)
for beat_group in beat_groups:
order_of_indispensability.append(beat_group.pop(0))
# example: order_of_indispensibility = [0, 2, 7]
# then gradually pick all the beats
# beat groups that are the longest get whittled away first, until they
# are no longer than any of the others. Always we go in order (backwards through the bar)
# example: 3, 4, 5 get added next (remember, it's backwards, so we're adding the pulses
# leading up to the beat following the 5 group) once there are equally many pulses left
# in each beat, we add from each group in order (i.e. backwards order).
largest_beat_group_length = max([len(x) for x in beat_groups])
while largest_beat_group_length > 0:
for beat_group in beat_groups:
if len(beat_group) == largest_beat_group_length:
order_of_indispensability.append(beat_group.pop(0))
largest_beat_group_length = max([len(x) for x in beat_groups])
return order_of_indispensability
else:
return range(length)
def _get_backward_beat_priorities(*args):
strata_backward_beat_priorities = []
for meter_stratum in args:
strata_backward_beat_priorities.append(_first_order_backward_beat_priorities(meter_stratum))
# we reverse the strata here, because the position in the lowest level stratum matters the most
strata_backward_beat_priorities.reverse()
strata_lengths = [len(x) for x in strata_backward_beat_priorities]
strata_multipliers = [1]
last_multiplier = 1
for l in strata_lengths[:-1]:
last_multiplier *= l
strata_multipliers.append(last_multiplier)
overall_beat_priorities = []
for combination in itertools.product(*strata_backward_beat_priorities):
overall_beat_priorities.append(sum(p*q for p, q in zip(combination, strata_multipliers)))
return overall_beat_priorities
def _get_indispensability_array(rhythmic_strata, normalize=False):
backward_beat_priorities = _get_backward_beat_priorities(*rhythmic_strata)
length = len(backward_beat_priorities)
backward_indispensability_array = [length-1-backward_beat_priorities.index(i) for i in range(length)]
indispensability_array = rotate(backward_indispensability_array, 1)
indispensability_array.reverse()
if normalize:
max_val = max(indispensability_array)
return [float(x)/max_val for x in indispensability_array]
else:
return indispensability_array
def _decompose_to_twos_and_threes(n):
assert isinstance(n, int)
out = []
if n % 2 == 1:
n -= 3
out.append(3)
while n > 0:
n -= 2
out.append(2)
out.reverse()
return out
def _standardize_strata(rhythmic_strata):
strata = []
for stratum in rhythmic_strata:
assert isinstance(stratum, int) and stratum > 0
if stratum > 2:
strata.append(_decompose_to_twos_and_threes(stratum))
else:
strata.append(stratum)
return strata
int_or_float = TypeVar("int_or_float", int, float)
[docs]def get_standard_indispensability_array(rhythmic_strata: Sequence, normalize: bool = False) -> list[int_or_float]:
"""
Returns a list of the indispensabilities of different pulses in a meter defined by the rhythmic_strata.
(See Barlow's "On Musiquantics", http://clarlow.org/wp-content/uploads/2016/10/On-MusiquanticsA4.pdf)
:param rhythmic_strata: a tuple representing the rhythmic groupings from large scale to small scale. For instance,
the sixteenth-note pulses in a measure of 9/8 would be represented by (3, 3, 2), since there are three beats of
three eighth notes, each of which has two sixteenth notes.
:param normalize: if True, scale all indispensabilities to the range 0 to 1
:return: a list of integer indispensabilities, or float if normalize is set to true
"""
return _get_indispensability_array(_standardize_strata(rhythmic_strata), normalize)
def _is_non_str_sequence(x):
return isinstance(x, Sequence) and not isinstance(x, str)