"""
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)