maialib 1.10.2__cp311-cp311-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- maialib/__init__.py +4 -0
- maialib/maiacore/Release/__init__.pyi +3 -0
- maialib/maiacore/Release/maiacore.cp311-win_amd64.pyd +0 -0
- maialib/maiacore/Release/maiacore.pyi +1414 -0
- maialib/maiacore/__init__.py +4 -0
- maialib/maiacore/__init__.pyi +24 -0
- maialib/maiapy/__init__.py +3 -0
- maialib/maiapy/__init__.pyi +3 -0
- maialib/maiapy/other.py +148 -0
- maialib/maiapy/other.pyi +79 -0
- maialib/maiapy/plots.py +806 -0
- maialib/maiapy/plots.pyi +103 -0
- maialib/maiapy/sethares_dissonance.py +481 -0
- maialib/maiapy/sethares_dissonance.pyi +128 -0
- maialib/setup.py +61 -0
- maialib/xml-scores-examples/Bach_Cello_Suite_1.mxl +0 -0
- maialib/xml-scores-examples/Beethoven_Symphony_5_mov_1.xml +170525 -0
- maialib/xml-scores-examples/Chopin_Fantasie_Impromptu.mxl +0 -0
- maialib/xml-scores-examples/Dvorak_Symphony_9_mov_4.mxl +0 -0
- maialib/xml-scores-examples/Mahler_Symphony_8_Finale.mxl +0 -0
- maialib/xml-scores-examples/Mozart_Requiem_Introitus.mxl +0 -0
- maialib/xml-scores-examples/Strauss_Also_Sprach_Zarathustra.mxl +0 -0
- maialib-1.10.2.dist-info/METADATA +822 -0
- maialib-1.10.2.dist-info/RECORD +27 -0
- maialib-1.10.2.dist-info/WHEEL +5 -0
- maialib-1.10.2.dist-info/licenses/LICENSE.txt +674 -0
- maialib-1.10.2.dist-info/top_level.txt +2 -0
maialib/maiapy/plots.pyi
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import maialib.maiacore as mc
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import plotly
|
|
4
|
+
|
|
5
|
+
def plotPartsActivity(score: mc.Score, **kwargs) -> tuple[plotly.graph_objs._figure.Figure, pd.DataFrame]:
|
|
6
|
+
'''Plots a timeline graph showing the musical activity of each score instrument
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
score (maialib.Score): A maialib Score object loaded with a valid MusicXML file
|
|
10
|
+
|
|
11
|
+
Kwargs:
|
|
12
|
+
measureStart (int): Start measure to plot
|
|
13
|
+
measureEnd (int): End measure to plot
|
|
14
|
+
partNames (list): A str list that contains the filtered desired score parts to plot
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
A list: [Plotly Figure, The plot data as a Pandas Dataframe]
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
RuntimeError, KeyError
|
|
21
|
+
|
|
22
|
+
Examples of use:
|
|
23
|
+
|
|
24
|
+
>>> plotPartsActivity(myScore)
|
|
25
|
+
>>> plotPartsActivity(myScore, measureStart=50)
|
|
26
|
+
>>> plotPartsActivity(myScore, measureStart=50, measureEnd=100)
|
|
27
|
+
>>> plotPartsActivity(myScore, measureStart=50, measureEnd=100, partNames=["Violin 1", "Cello"])
|
|
28
|
+
'''
|
|
29
|
+
def plotPianoRoll(score: mc.Score, **kwargs) -> tuple[plotly.graph_objs._figure.Figure, pd.DataFrame]:
|
|
30
|
+
'''Plots a piano roll graph showing the musical activity of each score instrument
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
score (maialib.Score): A maialib Score object loaded with a valid MusicXML file
|
|
34
|
+
|
|
35
|
+
Kwargs:
|
|
36
|
+
measureStart (int): Start measure to plot
|
|
37
|
+
measureEnd (int): End measure to plot
|
|
38
|
+
partNames (list): A str list that contains the filtered desired score parts to plot
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A list: [Plotly Figure, The plot data as a Pandas Dataframe]
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
RuntimeError, KeyError
|
|
45
|
+
|
|
46
|
+
Examples of use:
|
|
47
|
+
|
|
48
|
+
>>> plotPianoRoll(myScore)
|
|
49
|
+
>>> plotPianoRoll(myScore, measureStart=50)
|
|
50
|
+
>>> plotPianoRoll(myScore, measureStart=50, measureEnd=100)
|
|
51
|
+
>>> plotPianoRoll(myScore, measureStart=50, measureEnd=100, partNames=["Violin 1", "Cello"])
|
|
52
|
+
'''
|
|
53
|
+
def plotScorePitchEnvelope(score: mc.Score, **kwargs) -> tuple[plotly.graph_objs._figure.Figure, pd.DataFrame]:
|
|
54
|
+
'''Plot a score pitch envelope
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
score (maialib.Score): A maialib Score object loaded with a valid MusicXML file
|
|
58
|
+
|
|
59
|
+
Kwargs:
|
|
60
|
+
numPoints: (int): Number of interpolated points
|
|
61
|
+
showHigher (bool): Plot the envelop upper limit
|
|
62
|
+
showLower (bool): Plot the envelop lower limit
|
|
63
|
+
showMean (bool): Plot the envelop mean curve
|
|
64
|
+
showMeanOfExtremes (bool): Plot the envelop mean of extremes curve
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A list: [Plotly Figure, The plot data as a Pandas Dataframe]
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
RuntimeError, KeyError
|
|
71
|
+
|
|
72
|
+
Examples of use:
|
|
73
|
+
|
|
74
|
+
>>> myScore = ml.Score("/path/to/score.xml")
|
|
75
|
+
>>> plotScorePitchEnvelope(myScore)
|
|
76
|
+
>>> plotScorePitchEnvelope(myScore, numPoints=10)
|
|
77
|
+
>>> plotScorePitchEnvelope(myScore, showLower=False)
|
|
78
|
+
>>> plotScorePitchEnvelope(myScore, showMean=False, showMean=True)
|
|
79
|
+
'''
|
|
80
|
+
def plotChordsNumberOfNotes(score: mc.Score, **kwargs) -> tuple[plotly.graph_objs._figure.Figure, pd.DataFrame]:
|
|
81
|
+
'''Plot chord number of notes varying in time
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
score (maialib.Score): A maialib Score object loaded with a valid MusicXML file
|
|
85
|
+
|
|
86
|
+
Kwargs:
|
|
87
|
+
measureStart (int): Start measure to plot
|
|
88
|
+
measureEnd (int): End measure to plot
|
|
89
|
+
numPoints (int): Number of interpolated points
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A list: [Plotly Figure, The plot data as a Pandas Dataframe]
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
RuntimeError, KeyError
|
|
96
|
+
|
|
97
|
+
Examples of use:
|
|
98
|
+
|
|
99
|
+
>>> myScore = ml.Score("/path/to/score.xml")
|
|
100
|
+
>>> plotChordsNumberOfNotes(myScore)
|
|
101
|
+
>>> plotChordsNumberOfNotes(myScore, numPoints=15)
|
|
102
|
+
>>> plotChordsNumberOfNotes(myScore, measureStart=10, measureEnd=20)
|
|
103
|
+
'''
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import plotly
|
|
4
|
+
import plotly.express as px
|
|
5
|
+
import plotly.graph_objects as go
|
|
6
|
+
from maialib import maiacore as mc
|
|
7
|
+
from typing import List, Tuple, Callable, Optional
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"plotSetharesDissonanceCurve",
|
|
11
|
+
"plotScoreSetharesDissonance",
|
|
12
|
+
"plotChordDyadsSetharesDissonanceHeatmap",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _dissmeasure(fvec: List[float], amp: List[float], model: str = "min") -> float:
|
|
17
|
+
"""
|
|
18
|
+
Given a list of partials in fvec, with amplitudes in amp, this routine
|
|
19
|
+
calculates the dissonance by summing the roughness of every sine pair
|
|
20
|
+
based on a model of Plomp-Levelt's roughness curve.
|
|
21
|
+
|
|
22
|
+
The older model (model='product') was based on the product of the two
|
|
23
|
+
amplitudes, but the newer model (model='min') is based on the minimum
|
|
24
|
+
of the two amplitudes, since this matches the beat frequency amplitude.
|
|
25
|
+
"""
|
|
26
|
+
# Sort by frequency
|
|
27
|
+
sort_idx = np.argsort(fvec)
|
|
28
|
+
am_sorted = np.asarray(amp)[sort_idx]
|
|
29
|
+
fr_sorted = np.asarray(fvec)[sort_idx]
|
|
30
|
+
|
|
31
|
+
# Sum amplitudes for unique frequencies
|
|
32
|
+
freq_amp_dict = {}
|
|
33
|
+
for f, a in zip(fr_sorted, am_sorted):
|
|
34
|
+
freq_amp_dict[f] = freq_amp_dict.get(f, 0) + a
|
|
35
|
+
|
|
36
|
+
# Extract updated frequencies and amplitudes from the dictionary
|
|
37
|
+
fr_sorted = np.array(list(freq_amp_dict.keys()))
|
|
38
|
+
am_sorted = np.array(list(freq_amp_dict.values()))
|
|
39
|
+
|
|
40
|
+
# Used to stretch dissonance curve for different freqs:
|
|
41
|
+
Dstar = 0.24 # Point of maximum dissonance
|
|
42
|
+
S1 = 0.0207
|
|
43
|
+
S2 = 18.96
|
|
44
|
+
|
|
45
|
+
C1 = 5
|
|
46
|
+
C2 = -5
|
|
47
|
+
|
|
48
|
+
# Plomp-Levelt roughness curve:
|
|
49
|
+
A1 = -3.51
|
|
50
|
+
A2 = -5.75
|
|
51
|
+
|
|
52
|
+
# Generate all combinations of frequency components
|
|
53
|
+
idx = np.transpose(np.triu_indices_from(np.eye(len(fr_sorted)), k=0))
|
|
54
|
+
fr_pairs = fr_sorted[idx]
|
|
55
|
+
am_pairs = am_sorted[idx]
|
|
56
|
+
|
|
57
|
+
Fmin = fr_pairs[:, 0]
|
|
58
|
+
S = Dstar / (S1 * Fmin + S2)
|
|
59
|
+
Fdif = fr_pairs[:, 1] - fr_pairs[:, 0]
|
|
60
|
+
|
|
61
|
+
if model == "min":
|
|
62
|
+
a = np.amin(am_pairs, axis=1)
|
|
63
|
+
elif model == "product":
|
|
64
|
+
a = np.prod(am_pairs, axis=1) # Older model
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError('model should be "min" or "product"')
|
|
67
|
+
SFdif = S * Fdif
|
|
68
|
+
D = np.sum(a * (C1 * np.exp(A1 * SFdif) + C2 * np.exp(A2 * SFdif)))
|
|
69
|
+
|
|
70
|
+
return D, fr_pairs, am_pairs
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def plotSetharesDissonanceCurve(
|
|
74
|
+
fundamentalFreq: float = 440,
|
|
75
|
+
numPartials: int = 6,
|
|
76
|
+
ratioLowLimit: float = 1.0,
|
|
77
|
+
ratioHighLimit: float = 2.3,
|
|
78
|
+
ratioStepIncrement: float = 0.001,
|
|
79
|
+
amplCallback: Optional[Callable[[List[float]], List[float]]] = None,
|
|
80
|
+
partialsDecayExpRate: float = 0.88,
|
|
81
|
+
) -> Tuple[go.Figure, pd.DataFrame]:
|
|
82
|
+
"""
|
|
83
|
+
Generate and return the sensory dissonance curve (Sethares) for a harmonic spectrum.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
fundamentalFreq : float, default=440
|
|
88
|
+
Base frequency (f₀) in Hz on which the partials are built.
|
|
89
|
+
|
|
90
|
+
numPartials : int, default=6
|
|
91
|
+
Number of harmonics (partials) to include.
|
|
92
|
+
|
|
93
|
+
ratioLowLimit : float, default=1.0
|
|
94
|
+
Lower bound of the frequency ratio axis (intervals).
|
|
95
|
+
|
|
96
|
+
ratioHighLimit : float, default=2.3
|
|
97
|
+
Upper bound of the frequency ratio axis.
|
|
98
|
+
|
|
99
|
+
ratioStepIncrement : float, default=0.001
|
|
100
|
+
Step size between successive frequency ratios in the dissonance curve.
|
|
101
|
+
|
|
102
|
+
amplCallback : Optional[Callable[[List[float]], List[float]]], default=None
|
|
103
|
+
Optional function that receives a list of partial frequencies and returns
|
|
104
|
+
corresponding amplitudes. If None, amplitudes decay exponentially by
|
|
105
|
+
`partialsDecayExpRate`.
|
|
106
|
+
|
|
107
|
+
partialsDecayExpRate : float, default=0.88
|
|
108
|
+
Exponential decay rate for harmonics when `amplCallback` is None:
|
|
109
|
+
amplitude_i = (partialsDecayExpRate)**i.
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
fig : go.Figure
|
|
114
|
+
Plotly figure of the sensory dissonance curve with a log-scaled frequency ratio
|
|
115
|
+
axis. Includes vertical lines for musically notable intervals (e.g., 3/2, 5/4).
|
|
116
|
+
|
|
117
|
+
df : pandas.DataFrame
|
|
118
|
+
DataFrame with columns:
|
|
119
|
+
- 'ratio': frequency ratio values
|
|
120
|
+
- 'dissonance': sensory dissonance computed for each ratio
|
|
121
|
+
- 'freqs': frequency pair vectors used for calculation
|
|
122
|
+
- 'amps': amplitude pair vectors used in calculation
|
|
123
|
+
|
|
124
|
+
Behavior
|
|
125
|
+
--------
|
|
126
|
+
1. Constructs frequency vector `freqs` with integer multiples of `fundamentalFreq`.
|
|
127
|
+
2. Computes amplitude vector `amps` via `amplCallback`, or using exponential decay.
|
|
128
|
+
3. Validates matching lengths for `freqs` and `amps`, raising ValueError if mismatched.
|
|
129
|
+
4. Constructs a `ratios` array from `ratioLowLimit` to `ratioHighLimit`.
|
|
130
|
+
5. For each ratio r:
|
|
131
|
+
- Concatenates `freqs` with r × `freqs`; likewise for amplitudes.
|
|
132
|
+
- Applies `_dissmeasure` to compute sensory dissonance, frequency pairs, and amplitude pairs.
|
|
133
|
+
6. Builds a Plotly figure plotting dissonance vs. ratio and overlays lines at common musical intervals.
|
|
134
|
+
7. Returns the figure and a pandas DataFrame for further analysis.
|
|
135
|
+
|
|
136
|
+
Exceptions
|
|
137
|
+
----------
|
|
138
|
+
ValueError:
|
|
139
|
+
Raised if the output of `amplCallback` (if provided) does not match `numPartials` in length.
|
|
140
|
+
"""
|
|
141
|
+
freqs = fundamentalFreq * np.array(list(range(1, numPartials + 1)))
|
|
142
|
+
amps = (
|
|
143
|
+
partialsDecayExpRate ** np.array(list(range(0, numPartials)))
|
|
144
|
+
if amplCallback == None
|
|
145
|
+
else amplCallback(freqs)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if len(freqs) != len(amps):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
"The size of amplVec must be equal to the 'numPartials' (6 is the default)"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Calculate the number of points based on ratioStepIncrement
|
|
154
|
+
numPoints = int((ratioHighLimit - ratioLowLimit) / ratioStepIncrement) + 1
|
|
155
|
+
|
|
156
|
+
ratios = np.linspace(ratioLowLimit, ratioHighLimit, numPoints)
|
|
157
|
+
dissonances = []
|
|
158
|
+
fr_pairsVec = []
|
|
159
|
+
amp_pairsVec = []
|
|
160
|
+
for r in ratios:
|
|
161
|
+
extended_freqs = np.concatenate([freqs, r * freqs])
|
|
162
|
+
extended_amps = np.concatenate([amps, amps])
|
|
163
|
+
d, fr_pairs, amp_pairs = _dissmeasure(extended_freqs, extended_amps)
|
|
164
|
+
dissonances.append(d)
|
|
165
|
+
fr_pairsVec.append(fr_pairs)
|
|
166
|
+
amp_pairsVec.append(amp_pairs)
|
|
167
|
+
|
|
168
|
+
# Plotting using Plotly
|
|
169
|
+
fig = go.Figure()
|
|
170
|
+
fig.add_trace(go.Scatter(x=ratios, y=dissonances, mode="lines", name="Dissonance"))
|
|
171
|
+
|
|
172
|
+
# Adding lines for the notable intervals
|
|
173
|
+
intervals = [
|
|
174
|
+
(1, 1),
|
|
175
|
+
(6, 5),
|
|
176
|
+
(5, 4),
|
|
177
|
+
(4, 3),
|
|
178
|
+
(3, 2),
|
|
179
|
+
(5, 3),
|
|
180
|
+
(2, 1),
|
|
181
|
+
(4, 1),
|
|
182
|
+
(8, 1),
|
|
183
|
+
(16, 1),
|
|
184
|
+
(32, 1),
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
# Filter intervals based on ratioHighLimit
|
|
188
|
+
filtered_intervals = [(n, d) for n, d in intervals if n / d <= ratioHighLimit]
|
|
189
|
+
|
|
190
|
+
for n, d in filtered_intervals:
|
|
191
|
+
fig.add_shape(
|
|
192
|
+
type="line",
|
|
193
|
+
x0=n / d,
|
|
194
|
+
y0=min(dissonances),
|
|
195
|
+
x1=n / d,
|
|
196
|
+
y1=max(dissonances),
|
|
197
|
+
line=dict(color="Silver", width=1),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
partialsTitle = "partial" if numPartials == 1 else "partials"
|
|
201
|
+
fig.update_layout(
|
|
202
|
+
title=f"<b>Sethares' Sensory Dissonance Curve</b><br><i>f<sub>0</sub>={fundamentalFreq}Hz | {numPartials} harmonic {partialsTitle}</i>",
|
|
203
|
+
title_x=0.5,
|
|
204
|
+
xaxis_title="Frequency Ratio",
|
|
205
|
+
yaxis_title="Sensory Dissonance",
|
|
206
|
+
xaxis_type="log",
|
|
207
|
+
xaxis=dict(
|
|
208
|
+
tickvals=[n / d for n, d in filtered_intervals],
|
|
209
|
+
ticktext=["{}/{}".format(n, d) for n, d in filtered_intervals],
|
|
210
|
+
),
|
|
211
|
+
yaxis=dict(showticklabels=True),
|
|
212
|
+
plot_bgcolor="white",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
fig.add_shape(
|
|
216
|
+
# Rectangle with reference to the plot
|
|
217
|
+
type="rect",
|
|
218
|
+
xref="paper",
|
|
219
|
+
yref="paper",
|
|
220
|
+
x0=0,
|
|
221
|
+
y0=0,
|
|
222
|
+
x1=1.0,
|
|
223
|
+
y1=1.0,
|
|
224
|
+
line=dict(
|
|
225
|
+
color="black",
|
|
226
|
+
width=1,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
df = pd.DataFrame(
|
|
231
|
+
data=list(zip(ratios, dissonances, fr_pairsVec, amp_pairsVec)),
|
|
232
|
+
columns=["ratio", "dissonance", "freqs", "amps"],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return fig, df
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _setharesDissonanceDataFrameInterpolation(
|
|
239
|
+
df: pd.DataFrame, interpolatePoints: int
|
|
240
|
+
) -> pd.DataFrame:
|
|
241
|
+
def split(a, n):
|
|
242
|
+
k, m = divmod(len(a), n)
|
|
243
|
+
return (a[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n))
|
|
244
|
+
|
|
245
|
+
firstMeasureNumber = df.measure.min(skipna=True)
|
|
246
|
+
lastMeasureNumber = df.measure.max(skipna=True)
|
|
247
|
+
|
|
248
|
+
if interpolatePoints >= lastMeasureNumber:
|
|
249
|
+
raise Exception(
|
|
250
|
+
"ERROR: The score number of measures must be greater then the interpolate points value"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
ranges = list(split(range(firstMeasureNumber, lastMeasureNumber + 1), interpolatePoints))
|
|
254
|
+
data = []
|
|
255
|
+
for sub in ranges:
|
|
256
|
+
sub_df = df.query(f"(measure >= {sub.start}) & (measure < {sub.stop})")
|
|
257
|
+
floatMeasure = (sub.start + sub.stop) / 2
|
|
258
|
+
dissonance = round(sub_df["dissonance"].mean(skipna=True))
|
|
259
|
+
chordSizeMean = round(sub_df["chordSize"].mean(skipna=True))
|
|
260
|
+
|
|
261
|
+
obj = {
|
|
262
|
+
"floatMeasure": floatMeasure,
|
|
263
|
+
"dissonance": dissonance,
|
|
264
|
+
"chordSizeMean": chordSizeMean,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
data.append(obj)
|
|
268
|
+
|
|
269
|
+
new_df = pd.DataFrame.from_records(data)
|
|
270
|
+
return new_df
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def plotScoreSetharesDissonance(
|
|
274
|
+
score: mc.Score,
|
|
275
|
+
plotType="line",
|
|
276
|
+
lineShape="linear",
|
|
277
|
+
numPartialsPerNote: int = 6,
|
|
278
|
+
useMinModel: bool = True,
|
|
279
|
+
partialsDecayExpRate: float = 0.88,
|
|
280
|
+
amplCallback: Optional[Callable[[List[float]], List[float]]] = None,
|
|
281
|
+
dissCallback: Optional[Callable[[List[float]], float]] = None,
|
|
282
|
+
**kwargs,
|
|
283
|
+
) -> Tuple[go.Figure, pd.DataFrame]:
|
|
284
|
+
"""Plot 2D line graph of the Sethares Dissonance over time
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
score (maialib.Score): A maialib Score object loaded with a valid MusicXML file
|
|
288
|
+
plotType (str): Can be 'line' or 'scatter'
|
|
289
|
+
lineShape (str): Can be 'linear' or 'spline'
|
|
290
|
+
numPartialsPerNote (int): Amount of spectral partials for each note
|
|
291
|
+
useMinModel (bool): Sethares dissonance values can be computed using the 'minimal amplitude' model
|
|
292
|
+
or the 'product amplitudes' model. The 'min' model is a more recent approach
|
|
293
|
+
partialsDecayExpRate (float): Partials decay exponential rate (default: 0.88)
|
|
294
|
+
amplCallback: Custom user function callback to generate the amplitude of each spectrum partial
|
|
295
|
+
dissCallback: Custom user function callback to receive all paired partial dissonances and computes
|
|
296
|
+
a single total dissonance value output
|
|
297
|
+
Kwargs:
|
|
298
|
+
measureStart (int): Start measure to plot
|
|
299
|
+
measureEnd (int): End measure to plot
|
|
300
|
+
numPoints (int): Number of interpolated points
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
A list: [Plotly Figure, The plot data as a Pandas Dataframe]
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
RuntimeError, KeyError
|
|
307
|
+
|
|
308
|
+
Examples of use:
|
|
309
|
+
|
|
310
|
+
>>> myScore = ml.Score("/path/to/score.xml")
|
|
311
|
+
>>> ml.plotScoreSetharesDissonance(myScore)
|
|
312
|
+
>>> ml.plotScoreSetharesDissonance(myScore, numPoints=15)
|
|
313
|
+
>>> ml.plotScoreSetharesDissonance(myScore, measureStart=10, measureEnd=20)
|
|
314
|
+
"""
|
|
315
|
+
# ===== GET THE PLOT TITLE ===== #
|
|
316
|
+
workTitle = score.getTitle()
|
|
317
|
+
if workTitle.strip() == "":
|
|
318
|
+
workTitle = "No Title"
|
|
319
|
+
plotTitle = f"<b>Sethares Sensory Dissonance</b><br><i>{workTitle}</i>"
|
|
320
|
+
|
|
321
|
+
# ===== COMPUTE THE SETHARES DISSONANCE ===== #
|
|
322
|
+
df = score.getChordsDataFrame(kwargs)
|
|
323
|
+
|
|
324
|
+
df["dissonance"] = df.apply(
|
|
325
|
+
lambda row: row.chord.getSetharesDissonance(
|
|
326
|
+
numPartialsPerNote, useMinModel, amplCallback, partialsDecayExpRate, dissCallback
|
|
327
|
+
),
|
|
328
|
+
axis=1,
|
|
329
|
+
)
|
|
330
|
+
df["chordNotes"] = df.apply(
|
|
331
|
+
lambda row: ", ".join([str(x.getPitch()) for x in row.chord.getNotes()]), axis=1
|
|
332
|
+
)
|
|
333
|
+
df["chordSize"] = df.apply(lambda row: row.chord.size(), axis=1)
|
|
334
|
+
dissonanceMean = df.dissonance.mean()
|
|
335
|
+
|
|
336
|
+
# ===== CHECK THE INTERPOLATION POINTS ===== #
|
|
337
|
+
plotHoverData = ["chordNotes", "chordSize"]
|
|
338
|
+
if "numPoints" in kwargs:
|
|
339
|
+
df = _setharesDissonanceDataFrameInterpolation(df, kwargs["numPoints"])
|
|
340
|
+
plotHoverData = ["chordSizeMean"]
|
|
341
|
+
|
|
342
|
+
# ===== PLOT ===== #
|
|
343
|
+
fig = None
|
|
344
|
+
if plotType == "line":
|
|
345
|
+
fig = px.line(
|
|
346
|
+
df,
|
|
347
|
+
x="floatMeasure",
|
|
348
|
+
y="dissonance",
|
|
349
|
+
hover_data=plotHoverData,
|
|
350
|
+
title=plotTitle,
|
|
351
|
+
line_shape=lineShape,
|
|
352
|
+
)
|
|
353
|
+
else:
|
|
354
|
+
fig = px.scatter(
|
|
355
|
+
df, x="floatMeasure", y="dissonance", hover_data=plotHoverData, title=plotTitle
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
fig.add_hline(
|
|
359
|
+
y=dissonanceMean, line_width=3, line_dash="dash", line_color="green", annotation_text="Mean"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
fig.update_layout(
|
|
363
|
+
title_x=0.5,
|
|
364
|
+
xaxis_title="Measures",
|
|
365
|
+
yaxis_title="Sensory Dissonance",
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
fig.add_shape(
|
|
369
|
+
# Rectangle with reference to the plot
|
|
370
|
+
type="rect",
|
|
371
|
+
xref="paper",
|
|
372
|
+
yref="paper",
|
|
373
|
+
x0=0,
|
|
374
|
+
y0=0,
|
|
375
|
+
x1=1.0,
|
|
376
|
+
y1=1.0,
|
|
377
|
+
line=dict(
|
|
378
|
+
color="black",
|
|
379
|
+
width=1,
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
return fig, df
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def plotChordDyadsSetharesDissonanceHeatmap(
|
|
387
|
+
chord: mc.Chord,
|
|
388
|
+
numPartialsPerNote: int = 6,
|
|
389
|
+
useMinModel: bool = True,
|
|
390
|
+
amplCallback: Optional[Callable[[List[float]], List[float]]] = None,
|
|
391
|
+
partialsDecayExpRate: float = 0.88,
|
|
392
|
+
dissonanceThreshold: float = 0.1,
|
|
393
|
+
dissonanceDecimalPoint: int = 2,
|
|
394
|
+
showValues: bool = False,
|
|
395
|
+
valuesDecimalPlaces: int = 2,
|
|
396
|
+
) -> Tuple[plotly.graph_objs._figure.Figure, pd.DataFrame]:
|
|
397
|
+
"""Plot chord dyads Sethares dissonance heatmap
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
chord (maialib.Chord): A maialib Chord
|
|
401
|
+
|
|
402
|
+
Kwargs:
|
|
403
|
+
numPartialsPerNote (int): Amount of spectral partials for each note
|
|
404
|
+
useMinModel (bool): Sethares dissonance values can be computed using the 'minimal amplitude' model
|
|
405
|
+
or the 'product amplitudes' model. The 'min' model is a more recent approach
|
|
406
|
+
amplCallback: Custom user function callback to generate the amplitude of each spectrum partial
|
|
407
|
+
partialsDecayExpRate (float): Partials decay exponential rate (default: 0.88)
|
|
408
|
+
dissonanceThreshold (float): Dissonance threshold to skip small dissonance values
|
|
409
|
+
dissonanceDecimalPoint (int): Round chord dissonance value in the plot title
|
|
410
|
+
showValues (bool): If True, show numerical values inside heatmap cells
|
|
411
|
+
valuesDecimalPlaces (int): Number of decimal places to display in cell values
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
A list: [Plotly Figure, The plot data as a Pandas Dataframe]
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
RuntimeError, KeyError
|
|
418
|
+
|
|
419
|
+
Examples of use:
|
|
420
|
+
|
|
421
|
+
>>> import maialib as ml
|
|
422
|
+
>>> myChord = ml.Chord(["C3", "E3", "G3"])
|
|
423
|
+
>>> fig, df = plotChordDyadsSetharesDissonanceHeatmap(myChord)
|
|
424
|
+
>>> fig.show()
|
|
425
|
+
"""
|
|
426
|
+
df = chord.getSetharesDyadsDataFrame(
|
|
427
|
+
numPartialsPerNote=numPartialsPerNote,
|
|
428
|
+
useMinModel=useMinModel,
|
|
429
|
+
amplCallback=amplCallback,
|
|
430
|
+
partialsDecayExpRate=partialsDecayExpRate,
|
|
431
|
+
)
|
|
432
|
+
dfFiltered = df[df.dissonance > dissonanceThreshold]
|
|
433
|
+
|
|
434
|
+
# Pivot to matrix (targetFreq as rows, baseFreq as columns)
|
|
435
|
+
matrix_df = dfFiltered.pivot(index="targetFreq", columns="baseFreq", values="dissonance")
|
|
436
|
+
|
|
437
|
+
# Reorder rows and columns for consistency
|
|
438
|
+
matrix_df = matrix_df.reindex(index=sorted(matrix_df.index), columns=sorted(matrix_df.columns))
|
|
439
|
+
|
|
440
|
+
# Indices for uniform heatmap
|
|
441
|
+
x_ticks = list(range(len(matrix_df.columns)))
|
|
442
|
+
y_ticks = list(range(len(matrix_df.index)))
|
|
443
|
+
|
|
444
|
+
# Actual labels (frequencies)
|
|
445
|
+
x_labels = [round(v, 0) for v in matrix_df.columns]
|
|
446
|
+
y_labels = [round(v, 0) for v in matrix_df.index]
|
|
447
|
+
|
|
448
|
+
# Text formatting (if showValues=True)
|
|
449
|
+
if showValues:
|
|
450
|
+
text_format = f".{valuesDecimalPlaces}f"
|
|
451
|
+
else:
|
|
452
|
+
text_format = False
|
|
453
|
+
|
|
454
|
+
# Create heatmap with equal squares
|
|
455
|
+
fig = px.imshow(
|
|
456
|
+
matrix_df.values,
|
|
457
|
+
labels=dict(x="Base Frequency (Hz)", y="Target Frequency (Hz)", color="Dissonance"),
|
|
458
|
+
color_continuous_scale="Inferno",
|
|
459
|
+
origin="lower", # Force Y-axis to start from bottom
|
|
460
|
+
text_auto=text_format,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Adjust ticks to show actual frequencies
|
|
464
|
+
fig.update_xaxes(tickmode="array", tickvals=x_ticks, ticktext=x_labels)
|
|
465
|
+
fig.update_yaxes(
|
|
466
|
+
tickmode="array",
|
|
467
|
+
tickvals=y_ticks,
|
|
468
|
+
ticktext=y_labels,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Keep squares always equal
|
|
472
|
+
fig.update_yaxes(scaleanchor="x", scaleratio=1)
|
|
473
|
+
|
|
474
|
+
# Title
|
|
475
|
+
roundedDissonanceValue = round(df.dissonance.sum(), dissonanceDecimalPoint)
|
|
476
|
+
fig.update_layout(
|
|
477
|
+
title=f"<b>Chord Dyads Sethares Dissonance Heatmap</b><br><i>Chord Dissonance={roundedDissonanceValue}</i>",
|
|
478
|
+
title_x=0.5,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
return fig, dfFiltered
|