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.
@@ -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