Source code for klotho.dynatos.dynamics.dynamics

"""
Dynamics classes and utilities for musical expression.

This module provides classes for working with musical dynamics, including
individual dynamic markings and dynamic ranges.
"""

import numpy as np
from functools import lru_cache
from .utils import dbamp

__all__ = [
    'Dynamic',
    'DynamicRange',
]

DYNAMIC_MARKINGS = ('ppp', 'pp', 'p', 'mp', 'mf', 'f', 'ff', 'fff')

[docs] class Dynamic: """ Represents a musical dynamic marking with both symbolic and numeric representations. A Dynamic object encapsulates both the traditional musical marking (e.g., 'f', 'pp') and its corresponding decibel value, providing seamless conversion between symbolic and numeric representations of dynamics. Parameters ---------- marking : str The symbolic dynamic marking (e.g., 'f', 'pp', 'mf'). db_value : float The decibel value corresponding to this dynamic level. Examples -------- >>> dyn = Dynamic('f', -12) >>> dyn.marking 'f' >>> dyn.db -12 >>> dyn.amp 0.25118864315095825 """
[docs] def __init__(self, marking, db_value): self._marking = marking self._db_value = db_value
@property def marking(self): """str : The symbolic dynamic marking.""" return self._marking @property def db(self): """float : The decibel value.""" return self._db_value @property def amp(self): """float : The linear amplitude value converted from decibels.""" return dbamp(self._db_value) def __float__(self): return float(self._db_value) def __repr__(self): return f"Dynamic(marking='{self._marking}', db={self._db_value:.2f}, amp={self.amp:.4f})"
class DynamicRange: """ Manages a range of musical dynamics with customizable curve mapping. DynamicRange creates a mapping between traditional dynamic markings and their corresponding decibel values, with optional curve shaping for non-linear dynamic progressions. +------------------+---------+------------------+ | Name | Letters | Level | +==================+=========+==================+ | fortississimo | fff | very very loud | +------------------+---------+------------------+ | fortissimo | ff | very loud | +------------------+---------+------------------+ | forte | f | loud | +------------------+---------+------------------+ | mezzo-forte | mf | moderately loud | +------------------+---------+------------------+ | mezzo-piano | mp | moderately quiet | +------------------+---------+------------------+ | piano | p | quiet | +------------------+---------+------------------+ | pianissimo | pp | very quiet | +------------------+---------+------------------+ | pianississimo | ppp | very very quiet | +------------------+---------+------------------+ Parameters ---------- min_dynamic : float, optional Minimum decibel value for the quietest dynamic (default is -60). max_dynamic : float, optional Maximum decibel value for the loudest dynamic (default is -3). curve : float, optional Curve shaping factor. 0 = linear, positive = logarithmic, negative = exponential (default is 0). dynamics : tuple of str, optional Tuple of dynamic markings to use (default is standard 8-level dynamics). Examples -------- >>> dr = DynamicRange() >>> dr['f'].db -12.857142857142858 >>> dr.at(0.5).marking 'mp' See Also -------- https://en.wikipedia.org/wiki/Dynamics_(music) """ def __init__(self, min_dynamic=-60, max_dynamic=-3, curve=0, dynamics=DYNAMIC_MARKINGS): self._min_db = min_dynamic self._max_db = max_dynamic self._curve = curve self._dynamics = dynamics self._range = self._calculate_range() @property def min_dynamic(self): """Dynamic : The quietest dynamic in the range.""" return self._range[self._dynamics[0]] @property def max_dynamic(self): """Dynamic : The loudest dynamic in the range.""" return self._range[self._dynamics[-1]] @property def curve(self): """float : The curve shaping factor.""" return self._curve @property def ranges(self): """dict : Mapping of dynamic markings to Dynamic objects.""" return self._range def _calculate_range(self): min_db = float(self._min_db) max_db = float(self._max_db) num_dynamics = len(self._dynamics) result = {} for i, dyn in enumerate(self._dynamics): normalized_pos = i / (num_dynamics - 1) if self._curve == 0: curved_pos = normalized_pos else: curved_pos = (np.exp(self._curve * normalized_pos) - 1) / (np.exp(self._curve) - 1) db_value = min_db + curved_pos * (max_db - min_db) result[dyn] = Dynamic(dyn, db_value) return result def __getitem__(self, dynamic): return self._range[dynamic] @lru_cache(maxsize=128) def at(self, position): """ Get the dynamic at a normalized position within the range. Parameters ---------- position : float Position between 0 and 1, where 0 is the quietest and 1 is the loudest. Returns ------- Dynamic The dynamic at the given position, with interpolated dB value. Raises ------ ValueError If position is not between 0 and 1. """ if position < 0 or position > 1: raise ValueError(f"Position {position} must be between 0 and 1") if position == 0: return self._range[self._dynamics[0]] if position == 1: return self._range[self._dynamics[-1]] num_dynamics = len(self._dynamics) dynamic_positions = np.linspace(0, 1, num_dynamics) zone_index = 0 for i in range(num_dynamics - 1): if position < dynamic_positions[i + 1]: zone_index = i break else: zone_index = num_dynamics - 1 marking = self._dynamics[zone_index] min_db = float(self._min_db) max_db = float(self._max_db) if self._curve == 0: curved_pos = position else: curved_pos = (np.exp(self._curve * position) - 1) / (np.exp(self._curve) - 1) db_value = min_db + curved_pos * (max_db - min_db) return Dynamic(marking, db_value)