sethares-dissonance 2.1.1 → 4.0.0
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.
- package/README.md +19 -54
- package/dist/benches/dissonance-curve.bench.js +71 -0
- package/dist/classes/DissonanceCurve.d.ts +22 -3
- package/dist/classes/DissonanceCurve.js +53 -9
- package/dist/classes/private/HarmonicWithLoudness.d.ts +21 -0
- package/dist/classes/private/HarmonicWithLoudness.js +42 -0
- package/dist/classes/private/SpectrumWithLoudness.d.ts +31 -0
- package/dist/classes/private/SpectrumWithLoudness.js +105 -0
- package/dist/lib/const.d.ts +30 -24
- package/dist/lib/const.js +5 -10
- package/dist/lib/loudness.d.ts +5 -0
- package/dist/lib/loudness.js +16 -0
- package/dist/lib/types.d.ts +10 -9
- package/dist/lib/utils.d.ts +16 -13
- package/dist/lib/utils.js +53 -98
- package/dist/manual-testing/index.js +16 -0
- package/package.json +4 -3
- package/dist/tests/DissonanceCurve.test.js +0 -127
- package/dist/tests/utils.test.js +0 -9
- /package/dist/{tests/DissonanceCurve.test.d.ts → benches/dissonance-curve.bench.d.ts} +0 -0
- /package/dist/{tests/utils.test.d.ts → manual-testing/index.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -15,34 +15,40 @@ npm install sethares-dissonance
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
|
-
import { DissonanceCurve
|
|
18
|
+
import { DissonanceCurve } from "sethares-dissonance";
|
|
19
|
+
import { Spectrum } from "tuning-core";
|
|
19
20
|
|
|
20
|
-
const context = Spectrum.
|
|
21
|
-
const complement = Spectrum.
|
|
21
|
+
const context = Spectrum.harmonic(10, 440);
|
|
22
|
+
const complement = Spectrum.harmonic(10, 440);
|
|
22
23
|
|
|
23
24
|
const curve = new DissonanceCurve({
|
|
24
25
|
context,
|
|
25
26
|
complement,
|
|
26
27
|
start: 1,
|
|
27
28
|
end: 2,
|
|
28
|
-
|
|
29
|
+
maxGapCents: 20,
|
|
30
|
+
firstOrderContribution: 1,
|
|
31
|
+
secondOrderContribution: 0.25,
|
|
32
|
+
thirdOrderContribution: 0.1,
|
|
33
|
+
phantomHarmonicsNumber: 3,
|
|
29
34
|
});
|
|
30
35
|
|
|
31
36
|
const points = curve.points;
|
|
32
|
-
const plotData = curve.plot();
|
|
37
|
+
const plotData = curve.plot(); // [ratio, dissonance] tuples
|
|
38
|
+
const plotDataCents = curve.plotCents(); // [cents, dissonance] tuples
|
|
33
39
|
```
|
|
34
40
|
|
|
35
41
|
## React
|
|
36
42
|
|
|
37
43
|
Because DissonanceCurve class mutates data in place, it is not directly suited for React applications as reference to the instance does not change. React will not detect dissonance curve mutations in dependency arrays or state and thus will not rerender. You can solve it in two ways.
|
|
38
44
|
|
|
39
|
-
###
|
|
45
|
+
### Create new instances with useMemo hook
|
|
40
46
|
|
|
41
47
|
Create a fresh `DissonanceCurve` whenever options change. `useMemo` ensures a new instance is created when dependencies change, so React detects the update and re-renders.
|
|
42
48
|
|
|
43
49
|
```tsx
|
|
44
50
|
import { useMemo } from "react";
|
|
45
|
-
import { DissonanceCurve
|
|
51
|
+
import { DissonanceCurve } from "sethares-dissonance";
|
|
46
52
|
|
|
47
53
|
function DissonanceChart({ context, complement, start = 1, end = 2 }) {
|
|
48
54
|
const curve = useMemo(
|
|
@@ -52,7 +58,11 @@ function DissonanceChart({ context, complement, start = 1, end = 2 }) {
|
|
|
52
58
|
complement,
|
|
53
59
|
start,
|
|
54
60
|
end,
|
|
55
|
-
|
|
61
|
+
maxGapCents: 20,
|
|
62
|
+
firstOrderContribution: 1,
|
|
63
|
+
secondOrderContribution: 0.25,
|
|
64
|
+
thirdOrderContribution: 0.1,
|
|
65
|
+
phantomHarmonicsNumber: 3,
|
|
56
66
|
}),
|
|
57
67
|
[context, complement, start, end],
|
|
58
68
|
);
|
|
@@ -62,49 +72,4 @@ function DissonanceChart({ context, complement, start = 1, end = 2 }) {
|
|
|
62
72
|
}
|
|
63
73
|
```
|
|
64
74
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
### 2. Use read-only wrappers (Recommended)
|
|
68
|
-
|
|
69
|
-
This method relies on creating new instances of read-only objects rather than new DissonanceCurve instances. It is demonstrated in the following `useDissonanceCurve` hook. Memoize options with `useMemo` when they contain objects (e.g. `Spectrum`) to avoid recalculating on every render.
|
|
70
|
-
|
|
71
|
-
```tsx
|
|
72
|
-
"use client";
|
|
73
|
-
|
|
74
|
-
import { useMemo, useState } from "react";
|
|
75
|
-
import {
|
|
76
|
-
DissonanceCurve,
|
|
77
|
-
type DissonanceCurveOptions,
|
|
78
|
-
} from "sethares-dissonance";
|
|
79
|
-
import type { ReadOnlyDissonanceCurve } from "sethares-dissonance";
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Read-only view of DissonanceCurve without the methods that cause mutation.
|
|
83
|
-
* Use this type when exposing the curve from hooks to prevent external mutation.
|
|
84
|
-
*/
|
|
85
|
-
type ReadOnlyDissonanceCurve = Omit<
|
|
86
|
-
DissonanceCurve,
|
|
87
|
-
"recalculate"
|
|
88
|
-
>;
|
|
89
|
-
|
|
90
|
-
function createReadOnlyWrapper(curve: DissonanceCurve) {
|
|
91
|
-
return {
|
|
92
|
-
get maxDissonance() {
|
|
93
|
-
return curve.maxDissonance
|
|
94
|
-
},
|
|
95
|
-
plotCents: () => curve.plotCents()
|
|
96
|
-
// ... can map more methods if needed
|
|
97
|
-
} satisfies Partial<ReadOnlyDissonanceCurve>
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function useDissonanceCurve(
|
|
101
|
-
options: DissonanceCurveOptions,
|
|
102
|
-
): ReadOnlyDissonanceCurve {
|
|
103
|
-
const [curve] = useState(() => new DissonanceCurve(options));
|
|
104
|
-
|
|
105
|
-
return useMemo(() => {
|
|
106
|
-
curve.recalculate(options);
|
|
107
|
-
return createReadOnlyWrapper(curve);
|
|
108
|
-
}, [options, curve]);
|
|
109
|
-
}
|
|
110
|
-
```
|
|
75
|
+
**NOTE:** that `curve` object contains methods that can mutate the internal state of DissonanceCurve which will not be registered by React and will not cause a rerender.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Spectrum } from "tuning-core";
|
|
2
|
+
import { DissonanceCurve } from "../classes";
|
|
3
|
+
/**
|
|
4
|
+
* Performance benchmark for DissonanceCurve creation.
|
|
5
|
+
* Tests creating a dissonance curve with harmonic spectra (6 harmonics, 440 Hz),
|
|
6
|
+
* range 0.25–4, and Sethares parameters.
|
|
7
|
+
*/
|
|
8
|
+
const ITERATIONS = 100;
|
|
9
|
+
function benchmark(name, fn) {
|
|
10
|
+
// Warmup
|
|
11
|
+
for (let i = 0; i < 10; i++) {
|
|
12
|
+
fn();
|
|
13
|
+
}
|
|
14
|
+
// Actual benchmark
|
|
15
|
+
const start = performance.now();
|
|
16
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
17
|
+
fn();
|
|
18
|
+
}
|
|
19
|
+
const end = performance.now();
|
|
20
|
+
const timeMs = end - start;
|
|
21
|
+
const timePerOpMs = timeMs / ITERATIONS;
|
|
22
|
+
return {
|
|
23
|
+
operation: name,
|
|
24
|
+
timeMs,
|
|
25
|
+
timePerOpMs,
|
|
26
|
+
iterations: ITERATIONS,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function runBenchmarks() {
|
|
30
|
+
const results = [];
|
|
31
|
+
const options = {
|
|
32
|
+
context: Spectrum.harmonic(6, 440),
|
|
33
|
+
complement: Spectrum.harmonic(6, 440),
|
|
34
|
+
start: 1,
|
|
35
|
+
end: 2,
|
|
36
|
+
firstOrderContribution: 1,
|
|
37
|
+
secondOrderContribution: 0.25,
|
|
38
|
+
thirdOrderContribution: 0.1,
|
|
39
|
+
phantomHarmonicsNumber: 3,
|
|
40
|
+
maxGapCents: 30,
|
|
41
|
+
};
|
|
42
|
+
const sampleCurve = new DissonanceCurve(options);
|
|
43
|
+
console.log(`Test setup: Spectrum.harmonic(6, 440), range 0.25–4, curve points: ${sampleCurve.points.length}\n`);
|
|
44
|
+
results.push(benchmark("DissonanceCurve creation", () => {
|
|
45
|
+
new DissonanceCurve(options);
|
|
46
|
+
}));
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
function formatResults(results) {
|
|
50
|
+
console.log("=".repeat(80));
|
|
51
|
+
console.log("DissonanceCurve Creation Performance Benchmark");
|
|
52
|
+
console.log(`Iterations per test: ${ITERATIONS.toLocaleString()}`);
|
|
53
|
+
console.log("=".repeat(80));
|
|
54
|
+
console.log("\n");
|
|
55
|
+
const fastest = results.reduce((min, r) => (r.timeMs < min.timeMs ? r : min), results[0]);
|
|
56
|
+
console.log("Results:");
|
|
57
|
+
console.log("-".repeat(80));
|
|
58
|
+
console.log(`${"Operation".padEnd(45)} ${"Total Time (ms)".padEnd(18)} ${"Time per Op (ms)".padEnd(18)} ${"Relative Speed".padEnd(15)}`);
|
|
59
|
+
console.log("-".repeat(80));
|
|
60
|
+
for (const result of results) {
|
|
61
|
+
const relativeSpeed = (result.timeMs / fastest.timeMs).toFixed(2) + "x";
|
|
62
|
+
const timeMsStr = result.timeMs.toFixed(2);
|
|
63
|
+
const timePerOpStr = result.timePerOpMs.toFixed(4);
|
|
64
|
+
console.log(`${result.operation.padEnd(45)} ${timeMsStr.padEnd(18)} ${timePerOpStr.padEnd(18)} ${relativeSpeed.padEnd(15)}`);
|
|
65
|
+
}
|
|
66
|
+
console.log("-".repeat(80));
|
|
67
|
+
console.log(`\nFastest: ${fastest.operation} (${fastest.timeMs.toFixed(2)} ms)`);
|
|
68
|
+
console.log("=".repeat(80) + "\n");
|
|
69
|
+
}
|
|
70
|
+
const results = runBenchmarks();
|
|
71
|
+
formatResults(results);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Fraction, type FractionInput } from "fraction.js";
|
|
2
2
|
import { Spectrum } from "tuning-core";
|
|
3
3
|
import type { DissonanceParams, DissonanceCurvePoint, DissonanceCurveData } from "../lib";
|
|
4
|
+
import { SpectrumWithLoudness } from "./private/SpectrumWithLoudness";
|
|
4
5
|
export type DissonanceCurveOptions = DissonanceParams & {
|
|
5
6
|
context: Spectrum;
|
|
6
7
|
complement: Spectrum;
|
|
7
|
-
maxDenominator?: number;
|
|
8
8
|
start?: FractionInput;
|
|
9
9
|
end?: FractionInput;
|
|
10
|
+
maxGapCents?: number;
|
|
10
11
|
};
|
|
11
12
|
/**
|
|
12
13
|
* Represents a dissonance curve calculated using Sethares' sensory dissonance model.
|
|
@@ -37,14 +38,18 @@ export type DissonanceCurveOptions = DissonanceParams & {
|
|
|
37
38
|
*
|
|
38
39
|
* // Get plot data with cents
|
|
39
40
|
* const plotDataCents = curve.plotCents();
|
|
41
|
+
*
|
|
42
|
+
* // Normalize dissonance to 0–1 range (destructive, mutates in place)
|
|
43
|
+
* curve.normalize();
|
|
40
44
|
* ```
|
|
41
45
|
*/
|
|
42
46
|
export declare class DissonanceCurve {
|
|
43
47
|
private _data;
|
|
44
48
|
start: Fraction;
|
|
45
49
|
end: Fraction;
|
|
46
|
-
context:
|
|
47
|
-
complement:
|
|
50
|
+
context: SpectrumWithLoudness;
|
|
51
|
+
complement: SpectrumWithLoudness;
|
|
52
|
+
maxGapCents: number;
|
|
48
53
|
maxDissonance: number;
|
|
49
54
|
constructor(opts: DissonanceCurveOptions);
|
|
50
55
|
/**
|
|
@@ -52,6 +57,7 @@ export declare class DissonanceCurve {
|
|
|
52
57
|
* Updates the curve in place.
|
|
53
58
|
*/
|
|
54
59
|
recalculate(opts: DissonanceCurveOptions): void;
|
|
60
|
+
private getIntervals;
|
|
55
61
|
/**
|
|
56
62
|
* Build _data from public props (context, complement, start, end).
|
|
57
63
|
*/
|
|
@@ -67,6 +73,19 @@ export declare class DissonanceCurve {
|
|
|
67
73
|
* @returns The DissonanceCurvePoint for the given ratio, or undefined if not found
|
|
68
74
|
*/
|
|
69
75
|
get(ratio: FractionInput): DissonanceCurvePoint | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Normalize dissonance values in place to the range [0, 1].
|
|
78
|
+
*
|
|
79
|
+
* **Destructive:** This method mutates the curve permanently. All dissonance values
|
|
80
|
+
* are divided by the maximum dissonance, and the original raw values cannot be
|
|
81
|
+
* recovered. Use {@link recalculate} to rebuild the curve with raw values.
|
|
82
|
+
*
|
|
83
|
+
* After normalization, `points`, `get`, `plot`, `plotCents`, `toJSON`, and
|
|
84
|
+
* `toString` will all return normalized dissonance values (0–1 range).
|
|
85
|
+
*
|
|
86
|
+
* @returns `this` for method chaining
|
|
87
|
+
*/
|
|
88
|
+
normalize(): this;
|
|
70
89
|
/**
|
|
71
90
|
* Get plot points with intervals as ratio values (decimal numbers).
|
|
72
91
|
* @returns Array of [ratio, dissonance] tuples suitable for plotting
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Fraction } from "fraction.js";
|
|
2
2
|
import { ratioToCents, Spectrum, IntervalSet } from "tuning-core";
|
|
3
|
-
import { getSetharesDissonance } from "../lib";
|
|
3
|
+
import { getSetharesDissonance, getIntrinsicDissonance } from "../lib";
|
|
4
|
+
import { DEFAULT_PHANTOM_HARMONICS_NUMBER } from "../lib/const";
|
|
5
|
+
import { SpectrumWithLoudness } from "./private/SpectrumWithLoudness";
|
|
4
6
|
const DEFAULT_COLUMN_DELIMITER = ",";
|
|
5
7
|
const DEFAULT_ROW_DELIMITER = "\n";
|
|
6
8
|
/**
|
|
@@ -32,6 +34,9 @@ const DEFAULT_ROW_DELIMITER = "\n";
|
|
|
32
34
|
*
|
|
33
35
|
* // Get plot data with cents
|
|
34
36
|
* const plotDataCents = curve.plotCents();
|
|
37
|
+
*
|
|
38
|
+
* // Normalize dissonance to 0–1 range (destructive, mutates in place)
|
|
39
|
+
* curve.normalize();
|
|
35
40
|
* ```
|
|
36
41
|
*/
|
|
37
42
|
export class DissonanceCurve {
|
|
@@ -40,13 +45,17 @@ export class DissonanceCurve {
|
|
|
40
45
|
end;
|
|
41
46
|
context;
|
|
42
47
|
complement;
|
|
48
|
+
maxGapCents;
|
|
43
49
|
maxDissonance = 0;
|
|
44
50
|
constructor(opts) {
|
|
45
|
-
const { context, complement, start, end,
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
const { context, complement, start, end, maxGapCents = 20, ...dissonanceParams } = opts;
|
|
52
|
+
const phantomCount = (dissonanceParams.phantomHarmonicsNumber ?? DEFAULT_PHANTOM_HARMONICS_NUMBER) + 1;
|
|
53
|
+
const phantomHarmonics = SpectrumWithLoudness.harmonic(phantomCount, 1, true);
|
|
54
|
+
this.context = new SpectrumWithLoudness(context).mul(phantomHarmonics);
|
|
55
|
+
this.complement = new SpectrumWithLoudness(complement).mul(phantomHarmonics);
|
|
48
56
|
this.start = new Fraction(start ?? 1);
|
|
49
57
|
this.end = new Fraction(end ?? 2);
|
|
58
|
+
this.maxGapCents = maxGapCents;
|
|
50
59
|
if (this.start.compare(this.end) > 0)
|
|
51
60
|
throw Error("startCents should be less or equal to endCents");
|
|
52
61
|
this.build(dissonanceParams);
|
|
@@ -56,26 +65,37 @@ export class DissonanceCurve {
|
|
|
56
65
|
* Updates the curve in place.
|
|
57
66
|
*/
|
|
58
67
|
recalculate(opts) {
|
|
59
|
-
const { context, complement, start, end,
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
const { context, complement, start, end, maxGapCents = 20, ...dissonanceParams } = opts;
|
|
69
|
+
const phantomCount = (dissonanceParams.phantomHarmonicsNumber ?? DEFAULT_PHANTOM_HARMONICS_NUMBER) + 1;
|
|
70
|
+
const phantomHarmonics = SpectrumWithLoudness.harmonic(phantomCount, 1, true);
|
|
71
|
+
this.context = new SpectrumWithLoudness(context).mul(phantomHarmonics);
|
|
72
|
+
this.complement = new SpectrumWithLoudness(complement).mul(phantomHarmonics);
|
|
62
73
|
this.start = new Fraction(start ?? 1);
|
|
63
74
|
this.end = new Fraction(end ?? 2);
|
|
75
|
+
this.maxGapCents = maxGapCents;
|
|
64
76
|
if (this.start.compare(this.end) > 0)
|
|
65
77
|
throw Error("startCents should be less or equal to endCents");
|
|
66
78
|
this.build(dissonanceParams);
|
|
67
79
|
}
|
|
80
|
+
getIntervals() {
|
|
81
|
+
return IntervalSet.affinitive(this.context, this.complement)
|
|
82
|
+
.minMax(this.start, this.end)
|
|
83
|
+
.add(this.start)
|
|
84
|
+
.add(this.end)
|
|
85
|
+
.interpolateLog(this.maxGapCents);
|
|
86
|
+
}
|
|
68
87
|
/**
|
|
69
88
|
* Build _data from public props (context, complement, start, end).
|
|
70
89
|
*/
|
|
71
90
|
build(dissonanceParams) {
|
|
72
91
|
this._data.clear();
|
|
73
92
|
let maxDissonance = 0;
|
|
74
|
-
const intervals =
|
|
93
|
+
const intervals = this.getIntervals();
|
|
75
94
|
const ratios = intervals.getRatios();
|
|
95
|
+
const contextIntrinsic = getIntrinsicDissonance(this.context, dissonanceParams);
|
|
76
96
|
for (let i = 0; i < ratios.length; i++) {
|
|
77
97
|
const interval = ratios[i];
|
|
78
|
-
const dissonance = getSetharesDissonance(this.context, this.complement.toTransposed(interval), dissonanceParams);
|
|
98
|
+
const dissonance = getSetharesDissonance(this.context, this.complement.toTransposed(interval), dissonanceParams, contextIntrinsic);
|
|
79
99
|
if (dissonance > maxDissonance)
|
|
80
100
|
maxDissonance = dissonance;
|
|
81
101
|
this._data.set(interval.toFraction(), { interval, dissonance });
|
|
@@ -97,6 +117,30 @@ export class DissonanceCurve {
|
|
|
97
117
|
get(ratio) {
|
|
98
118
|
return this._data.get(new Fraction(ratio).toFraction());
|
|
99
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Normalize dissonance values in place to the range [0, 1].
|
|
122
|
+
*
|
|
123
|
+
* **Destructive:** This method mutates the curve permanently. All dissonance values
|
|
124
|
+
* are divided by the maximum dissonance, and the original raw values cannot be
|
|
125
|
+
* recovered. Use {@link recalculate} to rebuild the curve with raw values.
|
|
126
|
+
*
|
|
127
|
+
* After normalization, `points`, `get`, `plot`, `plotCents`, `toJSON`, and
|
|
128
|
+
* `toString` will all return normalized dissonance values (0–1 range).
|
|
129
|
+
*
|
|
130
|
+
* @returns `this` for method chaining
|
|
131
|
+
*/
|
|
132
|
+
normalize() {
|
|
133
|
+
if (this.maxDissonance === 0)
|
|
134
|
+
return this;
|
|
135
|
+
for (const [key, point] of this._data) {
|
|
136
|
+
this._data.set(key, {
|
|
137
|
+
interval: point.interval,
|
|
138
|
+
dissonance: point.dissonance / this.maxDissonance,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
this.maxDissonance = 1;
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
100
144
|
/**
|
|
101
145
|
* Get plot points with intervals as ratio values (decimal numbers).
|
|
102
146
|
* @returns Array of [ratio, dissonance] tuples suitable for plotting
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FractionInput } from "fraction.js";
|
|
2
|
+
import { Harmonic } from "tuning-core";
|
|
3
|
+
import type { HarmonicData } from "tuning-core";
|
|
4
|
+
type HarmonicWithLoudnessOptions = {
|
|
5
|
+
harmonic: Harmonic;
|
|
6
|
+
phantom: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type HarmonicWithLoudnessData = HarmonicData & {
|
|
9
|
+
phantom?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare class HarmonicWithLoudness extends Harmonic {
|
|
12
|
+
phantom: boolean;
|
|
13
|
+
loudness: number;
|
|
14
|
+
constructor(options: HarmonicWithLoudnessOptions);
|
|
15
|
+
constructor(data: HarmonicWithLoudnessData);
|
|
16
|
+
constructor(frequency: FractionInput, amplitude?: number, phase?: number, phantom?: boolean);
|
|
17
|
+
clone(): HarmonicWithLoudness;
|
|
18
|
+
toTransposed(ratio: FractionInput): HarmonicWithLoudness;
|
|
19
|
+
toJSON(): HarmonicWithLoudnessData;
|
|
20
|
+
}
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Harmonic, isHarmonicData } from "tuning-core";
|
|
2
|
+
import { getLoudness } from "../../lib/loudness";
|
|
3
|
+
export class HarmonicWithLoudness extends Harmonic {
|
|
4
|
+
phantom = false;
|
|
5
|
+
loudness;
|
|
6
|
+
constructor(optionsOrDataOrFreq, amplitudeOrPhantom, phase, phantom) {
|
|
7
|
+
if (typeof optionsOrDataOrFreq === "object" &&
|
|
8
|
+
"harmonic" in optionsOrDataOrFreq &&
|
|
9
|
+
optionsOrDataOrFreq.harmonic instanceof Harmonic) {
|
|
10
|
+
const opts = optionsOrDataOrFreq;
|
|
11
|
+
super(opts.harmonic.frequency, opts.harmonic.amplitude, opts.harmonic.phase);
|
|
12
|
+
this.phantom = opts.phantom;
|
|
13
|
+
this.loudness = getLoudness(opts.harmonic.amplitude);
|
|
14
|
+
}
|
|
15
|
+
else if (isHarmonicData(optionsOrDataOrFreq)) {
|
|
16
|
+
const data = optionsOrDataOrFreq;
|
|
17
|
+
super(data);
|
|
18
|
+
this.phantom = data.phantom ?? false;
|
|
19
|
+
this.loudness = getLoudness(data.amplitude ?? 1);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
super(optionsOrDataOrFreq, amplitudeOrPhantom ?? 1, phase ?? 0);
|
|
23
|
+
this.phantom = phantom ?? false;
|
|
24
|
+
this.loudness = getLoudness(amplitudeOrPhantom ?? 1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
clone() {
|
|
28
|
+
return new HarmonicWithLoudness({
|
|
29
|
+
harmonic: new Harmonic(this.frequency, this.amplitude, this.phase),
|
|
30
|
+
phantom: this.phantom,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
toTransposed(ratio) {
|
|
34
|
+
return new HarmonicWithLoudness({
|
|
35
|
+
harmonic: super.toTransposed(ratio),
|
|
36
|
+
phantom: this.phantom,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
toJSON() {
|
|
40
|
+
return { ...super.toJSON(), phantom: this.phantom };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { FractionInput } from "fraction.js";
|
|
2
|
+
import { Spectrum } from "tuning-core";
|
|
3
|
+
import { HarmonicWithLoudness, type HarmonicWithLoudnessData } from "./HarmonicWithLoudness";
|
|
4
|
+
export type SpectrumWithLoudnessData = HarmonicWithLoudnessData[];
|
|
5
|
+
export declare class SpectrumWithLoudness extends Spectrum {
|
|
6
|
+
constructor(data?: SpectrumWithLoudnessData | Spectrum);
|
|
7
|
+
add(harmonic: HarmonicWithLoudness): this;
|
|
8
|
+
add(data: HarmonicWithLoudnessData): this;
|
|
9
|
+
add(frequency: FractionInput, amplitude?: number, phase?: number, phantom?: boolean): this;
|
|
10
|
+
getHarmonics(): HarmonicWithLoudness[];
|
|
11
|
+
getLowestHarmonic(): HarmonicWithLoudness | undefined;
|
|
12
|
+
getHighestHarmonic(): HarmonicWithLoudness | undefined;
|
|
13
|
+
clone(): SpectrumWithLoudness;
|
|
14
|
+
toTransposed(ratio: FractionInput): SpectrumWithLoudness;
|
|
15
|
+
toJSON(): SpectrumWithLoudnessData;
|
|
16
|
+
get(frequency: FractionInput): HarmonicWithLoudness | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Multiply this spectrum by another spectrum.
|
|
19
|
+
* Each harmonic of this spectrum is multiplied by each harmonic of the other
|
|
20
|
+
* (as interval relative to the other's fundamental), and added to the output.
|
|
21
|
+
*
|
|
22
|
+
* Phantom logic:
|
|
23
|
+
* - Result is phantom if either operand harmonic is phantom.
|
|
24
|
+
* - Exception: when multiplying by the fundamental (interval 1), preserve
|
|
25
|
+
* harmonic_1's phantom status—a real harmonic from "this" is never
|
|
26
|
+
* overridden by phantom.
|
|
27
|
+
* - When frequencies collide: non-phantom wins (replaces phantom).
|
|
28
|
+
*/
|
|
29
|
+
mul(other: SpectrumWithLoudness): SpectrumWithLoudness;
|
|
30
|
+
static harmonic(count: number, fundamentalHz: FractionInput, phantom?: boolean): SpectrumWithLoudness;
|
|
31
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Spectrum, isHarmonicData } from "tuning-core";
|
|
2
|
+
import { HarmonicWithLoudness } from "./HarmonicWithLoudness";
|
|
3
|
+
export class SpectrumWithLoudness extends Spectrum {
|
|
4
|
+
constructor(data) {
|
|
5
|
+
super();
|
|
6
|
+
if (data instanceof Spectrum) {
|
|
7
|
+
for (const harmonic of data.getHarmonics()) {
|
|
8
|
+
this.add(new HarmonicWithLoudness({ harmonic, phantom: false }));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
else if (data) {
|
|
12
|
+
for (const harmonicData of data) {
|
|
13
|
+
this.add(harmonicData);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
add(harmonicOrDataOrFreq, amplitude, phase, phantom) {
|
|
18
|
+
if (harmonicOrDataOrFreq instanceof HarmonicWithLoudness) {
|
|
19
|
+
super.add(harmonicOrDataOrFreq);
|
|
20
|
+
}
|
|
21
|
+
else if (isHarmonicData(harmonicOrDataOrFreq)) {
|
|
22
|
+
super.add(new HarmonicWithLoudness(harmonicOrDataOrFreq));
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
super.add(new HarmonicWithLoudness(harmonicOrDataOrFreq, amplitude ?? 1, phase ?? 0, phantom ?? false));
|
|
26
|
+
}
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
getHarmonics() {
|
|
30
|
+
return super.getHarmonics();
|
|
31
|
+
}
|
|
32
|
+
getLowestHarmonic() {
|
|
33
|
+
return super.getLowestHarmonic();
|
|
34
|
+
}
|
|
35
|
+
getHighestHarmonic() {
|
|
36
|
+
return super.getHighestHarmonic();
|
|
37
|
+
}
|
|
38
|
+
clone() {
|
|
39
|
+
const copy = new SpectrumWithLoudness();
|
|
40
|
+
for (const harmonic of this.getHarmonics()) {
|
|
41
|
+
copy.add(harmonic.clone());
|
|
42
|
+
}
|
|
43
|
+
return copy;
|
|
44
|
+
}
|
|
45
|
+
toTransposed(ratio) {
|
|
46
|
+
return this.clone().transpose(ratio);
|
|
47
|
+
}
|
|
48
|
+
toJSON() {
|
|
49
|
+
return this.getHarmonics().map((h) => h.toJSON());
|
|
50
|
+
}
|
|
51
|
+
get(frequency) {
|
|
52
|
+
return super.get(frequency);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Multiply this spectrum by another spectrum.
|
|
56
|
+
* Each harmonic of this spectrum is multiplied by each harmonic of the other
|
|
57
|
+
* (as interval relative to the other's fundamental), and added to the output.
|
|
58
|
+
*
|
|
59
|
+
* Phantom logic:
|
|
60
|
+
* - Result is phantom if either operand harmonic is phantom.
|
|
61
|
+
* - Exception: when multiplying by the fundamental (interval 1), preserve
|
|
62
|
+
* harmonic_1's phantom status—a real harmonic from "this" is never
|
|
63
|
+
* overridden by phantom.
|
|
64
|
+
* - When frequencies collide: non-phantom wins (replaces phantom).
|
|
65
|
+
*/
|
|
66
|
+
mul(other) {
|
|
67
|
+
const fundamental = other.getLowestHarmonic();
|
|
68
|
+
if (!fundamental) {
|
|
69
|
+
throw new Error("Other spectrum has no fundamental (empty or not computable). Provide spectrum with harmonics.");
|
|
70
|
+
}
|
|
71
|
+
const result = new SpectrumWithLoudness();
|
|
72
|
+
for (const harmonic_1 of this.getHarmonics()) {
|
|
73
|
+
for (const harmonic_2 of other.getHarmonics()) {
|
|
74
|
+
const interval = harmonic_2.frequency.div(fundamental.frequency);
|
|
75
|
+
const ampRatio = harmonic_2.amplitude / fundamental.amplitude;
|
|
76
|
+
const newFreq = harmonic_1.frequency.mul(interval);
|
|
77
|
+
const newAmp = harmonic_1.amplitude * ampRatio;
|
|
78
|
+
const newPhase = harmonic_2.phase;
|
|
79
|
+
const isFundamental = harmonic_2 === fundamental;
|
|
80
|
+
const newPhantom = isFundamental
|
|
81
|
+
? harmonic_1.phantom
|
|
82
|
+
: harmonic_1.phantom || harmonic_2.phantom;
|
|
83
|
+
const existing = result.get(newFreq);
|
|
84
|
+
if (existing !== undefined) {
|
|
85
|
+
const existingPhantom = existing.phantom;
|
|
86
|
+
if (existingPhantom && !newPhantom) {
|
|
87
|
+
result.remove(newFreq);
|
|
88
|
+
result.add(newFreq, newAmp, newPhase, newPhantom);
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
result.add(newFreq, newAmp, newPhase, newPhantom);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
static harmonic(count, fundamentalHz, phantom) {
|
|
98
|
+
const spectrum = super.harmonic(count, fundamentalHz);
|
|
99
|
+
const result = new SpectrumWithLoudness();
|
|
100
|
+
for (const harmonic of spectrum.getHarmonics()) {
|
|
101
|
+
result.add(new HarmonicWithLoudness({ harmonic, phantom: phantom ?? false }));
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/lib/const.d.ts
CHANGED
|
@@ -1,29 +1,35 @@
|
|
|
1
|
-
import type { SecondOrderBeatingTerm } from "./types";
|
|
2
1
|
export declare const NORMALISATION_PRESSURE_UNIT = 2.8284271247461903;
|
|
3
|
-
export declare const SECOND_ORDER_BEATING_PARAMS: {
|
|
4
|
-
terms: SecondOrderBeatingTerm[];
|
|
5
|
-
widthMagnitudeRelationship: number;
|
|
6
|
-
totalContribution: number;
|
|
7
|
-
};
|
|
8
2
|
/** Fitting parameters proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
|
|
9
3
|
export declare const SETHARES_DISSONANCE_PARAMS: {
|
|
10
|
-
s1:
|
|
11
|
-
s2:
|
|
12
|
-
b1:
|
|
13
|
-
b2:
|
|
14
|
-
x_star:
|
|
15
|
-
|
|
4
|
+
readonly s1: 0.021;
|
|
5
|
+
readonly s2: 19;
|
|
6
|
+
readonly b1: 3.5;
|
|
7
|
+
readonly b2: 5.75;
|
|
8
|
+
readonly x_star: 0.24;
|
|
9
|
+
readonly magnitude: 1;
|
|
10
|
+
};
|
|
11
|
+
export declare const DEFAULT_FIRST_ORDER_DISSONANCE_PARAMS: {
|
|
12
|
+
s1: 0.021;
|
|
13
|
+
s2: 19;
|
|
14
|
+
b1: 3.5;
|
|
15
|
+
b2: 5.75;
|
|
16
|
+
x_star: 0.24;
|
|
17
|
+
magnitude: 1;
|
|
18
|
+
};
|
|
19
|
+
export declare const DEFAULT_SECOND_ORDER_DISSONANCE_PARAMS: {
|
|
20
|
+
magnitude: number;
|
|
21
|
+
s1: 0.021;
|
|
22
|
+
s2: 19;
|
|
23
|
+
b1: 3.5;
|
|
24
|
+
b2: 5.75;
|
|
25
|
+
x_star: 0.24;
|
|
16
26
|
};
|
|
17
|
-
export declare const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
s2: number;
|
|
25
|
-
b1: number;
|
|
26
|
-
b2: number;
|
|
27
|
-
x_star: number;
|
|
28
|
-
totalContribution: number;
|
|
27
|
+
export declare const DEFAULT_THIRD_ORDER_DISSONANCE_PARAMS: {
|
|
28
|
+
magnitude: number;
|
|
29
|
+
s1: 0.021;
|
|
30
|
+
s2: 19;
|
|
31
|
+
b1: 3.5;
|
|
32
|
+
b2: 5.75;
|
|
33
|
+
x_star: 0.24;
|
|
29
34
|
};
|
|
35
|
+
export declare const DEFAULT_PHANTOM_HARMONICS_NUMBER = 0;
|
package/dist/lib/const.js
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
export const NORMALISATION_PRESSURE_UNIT = 2.8284271247461905;
|
|
2
|
-
export const SECOND_ORDER_BEATING_PARAMS = {
|
|
3
|
-
terms: [],
|
|
4
|
-
widthMagnitudeRelationship: 0,
|
|
5
|
-
totalContribution: 1,
|
|
6
|
-
};
|
|
7
2
|
/** Fitting parameters proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
|
|
8
3
|
export const SETHARES_DISSONANCE_PARAMS = {
|
|
9
4
|
s1: 0.021,
|
|
@@ -11,9 +6,9 @@ export const SETHARES_DISSONANCE_PARAMS = {
|
|
|
11
6
|
b1: 3.5,
|
|
12
7
|
b2: 5.75,
|
|
13
8
|
x_star: 0.24,
|
|
14
|
-
|
|
15
|
-
};
|
|
16
|
-
export const DEFAULT_DISSONANCE_PARAMS = {
|
|
17
|
-
...SETHARES_DISSONANCE_PARAMS,
|
|
18
|
-
secondOrderBeating: SECOND_ORDER_BEATING_PARAMS,
|
|
9
|
+
magnitude: 1,
|
|
19
10
|
};
|
|
11
|
+
export const DEFAULT_FIRST_ORDER_DISSONANCE_PARAMS = { ...SETHARES_DISSONANCE_PARAMS };
|
|
12
|
+
export const DEFAULT_SECOND_ORDER_DISSONANCE_PARAMS = { ...SETHARES_DISSONANCE_PARAMS, magnitude: 0.25 };
|
|
13
|
+
export const DEFAULT_THIRD_ORDER_DISSONANCE_PARAMS = { ...SETHARES_DISSONANCE_PARAMS, magnitude: 0.1 };
|
|
14
|
+
export const DEFAULT_PHANTOM_HARMONICS_NUMBER = 0;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The formula to calculate loudness from amplitude. The converstion to SPL (phons) is done according to Sethares in the appendix "How to Draw Dissonance Curves". The converstion to loudness is done according to https://sengpielaudio.com/calculatorSonephon.htm
|
|
3
|
+
* @param {number} amplitude - the normalized peak value where 0.001 corresponds to SPL of 40 db or 1 sone and 1 to SPL of 100db and 64 sones. Normalisation is done for the simplicity of providing harmonic spectrum with amplitudes 1, 1/2, 1/3, ...
|
|
4
|
+
*/
|
|
5
|
+
export declare function getLoudness(amplitude: number): number;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { NORMALISATION_PRESSURE_UNIT } from "./const";
|
|
2
|
+
/**
|
|
3
|
+
* The formula to calculate loudness from amplitude. The converstion to SPL (phons) is done according to Sethares in the appendix "How to Draw Dissonance Curves". The converstion to loudness is done according to https://sengpielaudio.com/calculatorSonephon.htm
|
|
4
|
+
* @param {number} amplitude - the normalized peak value where 0.001 corresponds to SPL of 40 db or 1 sone and 1 to SPL of 100db and 64 sones. Normalisation is done for the simplicity of providing harmonic spectrum with amplitudes 1, 1/2, 1/3, ...
|
|
5
|
+
*/
|
|
6
|
+
export function getLoudness(amplitude) {
|
|
7
|
+
const rms = amplitude / Math.SQRT2;
|
|
8
|
+
const pressure = rms * NORMALISATION_PRESSURE_UNIT;
|
|
9
|
+
const referencePressure = 2e-5;
|
|
10
|
+
const phons = 20 * Math.log10(pressure / referencePressure);
|
|
11
|
+
if (phons < 8)
|
|
12
|
+
return 0;
|
|
13
|
+
if (phons < 40)
|
|
14
|
+
return Math.pow(phons / 40, 2.86) - 0.005;
|
|
15
|
+
return Math.pow(2, (phons - 40) / 10);
|
|
16
|
+
}
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type Fraction from "fraction.js";
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
export type
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
import type { SETHARES_DISSONANCE_PARAMS } from "./const";
|
|
3
|
+
/** Params for Plomp-Levelt formula; all numeric to allow overrides */
|
|
4
|
+
export type SetharesDissonanceParams = Partial<{
|
|
5
|
+
[K in keyof typeof SETHARES_DISSONANCE_PARAMS]: number;
|
|
6
|
+
}>;
|
|
7
|
+
export type DissonanceParams = {
|
|
8
|
+
firstOrderDissonance?: SetharesDissonanceParams;
|
|
9
|
+
secondOrderDissonance?: SetharesDissonanceParams;
|
|
10
|
+
thirdOrderDissonance?: SetharesDissonanceParams;
|
|
11
|
+
phantomHarmonicsNumber?: number;
|
|
11
12
|
};
|
|
12
13
|
export type DissonanceCurvePoint = {
|
|
13
14
|
interval: Fraction;
|
package/dist/lib/utils.d.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import type { Harmonic
|
|
2
|
-
import type { DissonanceParams } from "./types";
|
|
1
|
+
import type { Harmonic } from "tuning-core";
|
|
2
|
+
import type { DissonanceParams, SetharesDissonanceParams } from "./types";
|
|
3
|
+
import type { HarmonicWithLoudness } from "../classes/private/HarmonicWithLoudness";
|
|
4
|
+
import { SpectrumWithLoudness } from "../classes/private/SpectrumWithLoudness";
|
|
5
|
+
export { getLoudness } from "./loudness";
|
|
6
|
+
/** The formula to calculate sensory dissoannce proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
|
|
7
|
+
export declare function getPlompLeveltDissonance(h1: Harmonic, h2: Harmonic, params: SetharesDissonanceParams): number;
|
|
3
8
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
9
|
+
* Find dissonance between two harmonics based on phantom status.
|
|
10
|
+
* - Both non-phantom: first order
|
|
11
|
+
* - One phantom, one non-phantom: second order
|
|
12
|
+
* - Both phantom: third order
|
|
6
13
|
*/
|
|
7
|
-
export declare function
|
|
8
|
-
/** The formula to calculate sensory dissoannce proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
|
|
9
|
-
export declare function getPlompLeveltDissonance(h1: Harmonic, h2: Harmonic, params?: DissonanceParams): number;
|
|
14
|
+
export declare function getSensoryDissonance(h1: HarmonicWithLoudness, h2: HarmonicWithLoudness, params?: DissonanceParams): number;
|
|
10
15
|
/**
|
|
11
16
|
* Calculate the intrinsic dissonance of a spectrum.
|
|
12
17
|
*/
|
|
13
|
-
export declare function getIntrinsicDissonance(spectrum:
|
|
18
|
+
export declare function getIntrinsicDissonance(spectrum: SpectrumWithLoudness, params?: DissonanceParams): number;
|
|
14
19
|
/**
|
|
15
20
|
* Calculate the dissonance between two spectra.
|
|
16
21
|
* This is the sum of the intrinsic dissonance of each spectrum
|
|
@@ -18,9 +23,7 @@ export declare function getIntrinsicDissonance(spectrum: Spectrum, params?: Diss
|
|
|
18
23
|
*
|
|
19
24
|
* Note: If not proviing secondOrderBeating params is yield the same result as Sethares' TTSS formula.
|
|
20
25
|
* However secondOrderBeating params can be used to finetune the dissoannce perception and account for harmonicity.
|
|
26
|
+
*
|
|
27
|
+
* @param precomputedSpectrum1Intrinsic - When provided, use this instead of computing spectrum1's intrinsic dissonance (for performance when spectrum1 is constant across many calls).
|
|
21
28
|
*/
|
|
22
|
-
export declare function getSetharesDissonance(spectrum1:
|
|
23
|
-
/**
|
|
24
|
-
* Helper function to calculate the second order beating dissonance between two harmonics.
|
|
25
|
-
*/
|
|
26
|
-
export declare function getSecondOrderBeatingDissonance(h1: Harmonic, h2: Harmonic, params?: DissonanceParams): number;
|
|
29
|
+
export declare function getSetharesDissonance(spectrum1: SpectrumWithLoudness, spectrum2: SpectrumWithLoudness, params?: DissonanceParams, precomputedSpectrum1Intrinsic?: number): number;
|
package/dist/lib/utils.js
CHANGED
|
@@ -1,29 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/
|
|
6
|
-
export function getLoudness(amplitude) {
|
|
7
|
-
const rms = amplitude / Math.SQRT2;
|
|
8
|
-
const pressure = rms * NORMALISATION_PRESSURE_UNIT;
|
|
9
|
-
const referencePressure = 2e-5;
|
|
10
|
-
const phons = 20 * Math.log10(pressure / referencePressure);
|
|
11
|
-
if (phons < 8)
|
|
12
|
-
return 0;
|
|
13
|
-
if (phons < 40)
|
|
14
|
-
return Math.pow(phons / 40, 2.86) - 0.005;
|
|
15
|
-
return Math.pow(2, (phons - 40) / 10);
|
|
16
|
-
}
|
|
1
|
+
import { DEFAULT_FIRST_ORDER_DISSONANCE_PARAMS, DEFAULT_SECOND_ORDER_DISSONANCE_PARAMS, DEFAULT_THIRD_ORDER_DISSONANCE_PARAMS, SETHARES_DISSONANCE_PARAMS, } from "./const";
|
|
2
|
+
import { getLoudness } from "./loudness";
|
|
3
|
+
import { SpectrumWithLoudness } from "../classes/private/SpectrumWithLoudness";
|
|
4
|
+
export { getLoudness } from "./loudness";
|
|
17
5
|
/** The formula to calculate sensory dissoannce proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
|
|
18
6
|
export function getPlompLeveltDissonance(h1, h2, params) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
7
|
+
const magnitude = params?.magnitude ?? SETHARES_DISSONANCE_PARAMS.magnitude;
|
|
8
|
+
if (magnitude <= 0)
|
|
9
|
+
return 0;
|
|
10
|
+
const x_star = params?.x_star ?? SETHARES_DISSONANCE_PARAMS.x_star;
|
|
11
|
+
const s1 = params?.s1 ?? SETHARES_DISSONANCE_PARAMS.s1;
|
|
12
|
+
const s2 = params?.s2 ?? SETHARES_DISSONANCE_PARAMS.s2;
|
|
13
|
+
const b1 = params?.b1 ?? SETHARES_DISSONANCE_PARAMS.b1;
|
|
14
|
+
const b2 = params?.b2 ?? SETHARES_DISSONANCE_PARAMS.b2;
|
|
24
15
|
if (h1.frequency.equals(h2.frequency))
|
|
25
16
|
return 0;
|
|
26
|
-
const minLoudness = Math.min(getLoudness(h1.amplitude), getLoudness(h2.amplitude));
|
|
17
|
+
const minLoudness = Math.min("loudness" in h1 ? h1.loudness : getLoudness(h1.amplitude), "loudness" in h2 ? h2.loudness : getLoudness(h2.amplitude));
|
|
27
18
|
if (minLoudness <= 0)
|
|
28
19
|
return 0;
|
|
29
20
|
const minFrequency = Math.min(h1.frequencyNum, h2.frequencyNum);
|
|
@@ -31,35 +22,47 @@ export function getPlompLeveltDissonance(h1, h2, params) {
|
|
|
31
22
|
return 0;
|
|
32
23
|
const frequencyDifference = Math.abs(h1.frequencyNum - h2.frequencyNum);
|
|
33
24
|
const s = x_star / (s1 * minFrequency + s2);
|
|
34
|
-
return (
|
|
25
|
+
return (magnitude *
|
|
26
|
+
minLoudness *
|
|
35
27
|
(Math.exp(-1 * b1 * s * frequencyDifference) -
|
|
36
28
|
Math.exp(-1 * b2 * s * frequencyDifference)));
|
|
37
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Find dissonance between two harmonics based on phantom status.
|
|
32
|
+
* - Both non-phantom: first order
|
|
33
|
+
* - One phantom, one non-phantom: second order
|
|
34
|
+
* - Both phantom: third order
|
|
35
|
+
*/
|
|
36
|
+
export function getSensoryDissonance(h1, h2, params) {
|
|
37
|
+
if (!h1.phantom && !h2.phantom) {
|
|
38
|
+
return getPlompLeveltDissonance(h1, h2, {
|
|
39
|
+
...DEFAULT_FIRST_ORDER_DISSONANCE_PARAMS,
|
|
40
|
+
...params?.firstOrderDissonance,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (h1.phantom !== h2.phantom) {
|
|
44
|
+
return getPlompLeveltDissonance(h1, h2, {
|
|
45
|
+
...DEFAULT_SECOND_ORDER_DISSONANCE_PARAMS,
|
|
46
|
+
...params?.secondOrderDissonance,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return getPlompLeveltDissonance(h1, h2, {
|
|
50
|
+
...DEFAULT_THIRD_ORDER_DISSONANCE_PARAMS,
|
|
51
|
+
...params?.thirdOrderDissonance,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
38
54
|
/**
|
|
39
55
|
* Calculate the intrinsic dissonance of a spectrum.
|
|
40
56
|
*/
|
|
41
57
|
export function getIntrinsicDissonance(spectrum, params) {
|
|
42
|
-
const totalContribution = params?.totalContribution ??
|
|
43
|
-
DEFAULT_DISSONANCE_PARAMS.totalContribution;
|
|
44
|
-
const secondOrderBeatingContribution = params?.secondOrderBeating?.totalContribution ??
|
|
45
|
-
DEFAULT_DISSONANCE_PARAMS.secondOrderBeating?.totalContribution;
|
|
46
58
|
let dissonance = 0;
|
|
47
|
-
const
|
|
59
|
+
const harmonics = spectrum.getHarmonics();
|
|
48
60
|
// loop over all pairs of harmonics within the spectrum (not including reversed pairs)
|
|
49
|
-
for (let i = 0; i <
|
|
50
|
-
for (let j = i + 1; j <
|
|
51
|
-
const h1 =
|
|
52
|
-
const h2 =
|
|
53
|
-
|
|
54
|
-
dissonance +=
|
|
55
|
-
totalContribution *
|
|
56
|
-
getPlompLeveltDissonance(h1, h2, params);
|
|
57
|
-
}
|
|
58
|
-
if (secondOrderBeatingContribution) {
|
|
59
|
-
dissonance +=
|
|
60
|
-
secondOrderBeatingContribution *
|
|
61
|
-
getSecondOrderBeatingDissonance(h1, h2, params);
|
|
62
|
-
}
|
|
61
|
+
for (let i = 0; i < harmonics.length; i++) {
|
|
62
|
+
for (let j = i + 1; j < harmonics.length; j++) {
|
|
63
|
+
const h1 = harmonics[i];
|
|
64
|
+
const h2 = harmonics[j];
|
|
65
|
+
dissonance += getSensoryDissonance(h1, h2, params);
|
|
63
66
|
}
|
|
64
67
|
}
|
|
65
68
|
return dissonance;
|
|
@@ -71,65 +74,17 @@ export function getIntrinsicDissonance(spectrum, params) {
|
|
|
71
74
|
*
|
|
72
75
|
* Note: If not proviing secondOrderBeating params is yield the same result as Sethares' TTSS formula.
|
|
73
76
|
* However secondOrderBeating params can be used to finetune the dissoannce perception and account for harmonicity.
|
|
77
|
+
*
|
|
78
|
+
* @param precomputedSpectrum1Intrinsic - When provided, use this instead of computing spectrum1's intrinsic dissonance (for performance when spectrum1 is constant across many calls).
|
|
74
79
|
*/
|
|
75
|
-
export function getSetharesDissonance(spectrum1, spectrum2, params) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
const secondOrderBeatingContribution = params?.secondOrderBeating?.totalContribution ??
|
|
79
|
-
DEFAULT_DISSONANCE_PARAMS.secondOrderBeating?.totalContribution;
|
|
80
|
-
let dissonance = getIntrinsicDissonance(spectrum1, params) +
|
|
81
|
-
getIntrinsicDissonance(spectrum2, params);
|
|
82
|
-
const frequencies1 = spectrum1.getKeys();
|
|
83
|
-
const frequencies2 = spectrum2.getKeys();
|
|
80
|
+
export function getSetharesDissonance(spectrum1, spectrum2, params, precomputedSpectrum1Intrinsic) {
|
|
81
|
+
const spectrum1Intrinsic = precomputedSpectrum1Intrinsic ?? getIntrinsicDissonance(spectrum1, params);
|
|
82
|
+
let dissonance = spectrum1Intrinsic + getIntrinsicDissonance(spectrum2, params);
|
|
84
83
|
// loop over all pairs of harmonics between the two spectra (not including reversed pairs)
|
|
85
|
-
for (
|
|
86
|
-
for (
|
|
87
|
-
|
|
88
|
-
const h2 = spectrum2.get(frequencies2[j]);
|
|
89
|
-
if (totalContribution) {
|
|
90
|
-
dissonance +=
|
|
91
|
-
totalContribution *
|
|
92
|
-
getPlompLeveltDissonance(h1, h2, params);
|
|
93
|
-
}
|
|
94
|
-
if (secondOrderBeatingContribution) {
|
|
95
|
-
dissonance +=
|
|
96
|
-
secondOrderBeatingContribution *
|
|
97
|
-
getSecondOrderBeatingDissonance(h1, h2, params);
|
|
98
|
-
}
|
|
84
|
+
for (const h1 of spectrum1.getHarmonics()) {
|
|
85
|
+
for (const h2 of spectrum2.getHarmonics()) {
|
|
86
|
+
dissonance += getSensoryDissonance(h1, h2, params);
|
|
99
87
|
}
|
|
100
88
|
}
|
|
101
89
|
return dissonance;
|
|
102
90
|
}
|
|
103
|
-
/**
|
|
104
|
-
* Helper function to calculate the second order beating dissonance between two harmonics.
|
|
105
|
-
*/
|
|
106
|
-
export function getSecondOrderBeatingDissonance(h1, h2, params) {
|
|
107
|
-
const x_star = params?.x_star ?? DEFAULT_DISSONANCE_PARAMS.x_star;
|
|
108
|
-
const s1 = params?.s1 ?? DEFAULT_DISSONANCE_PARAMS.s1;
|
|
109
|
-
const s2 = params?.s2 ?? DEFAULT_DISSONANCE_PARAMS.s2;
|
|
110
|
-
const b1 = params?.b1 ?? DEFAULT_DISSONANCE_PARAMS.b1;
|
|
111
|
-
const b2 = params?.b2 ?? DEFAULT_DISSONANCE_PARAMS.b2;
|
|
112
|
-
const widthMagnitudeRelationship = params?.secondOrderBeating?.widthMagnitudeRelationship ??
|
|
113
|
-
DEFAULT_DISSONANCE_PARAMS.secondOrderBeating.widthMagnitudeRelationship;
|
|
114
|
-
const terms = params?.secondOrderBeating?.terms ??
|
|
115
|
-
DEFAULT_DISSONANCE_PARAMS.secondOrderBeating.terms;
|
|
116
|
-
let dissonance = 0;
|
|
117
|
-
const minLoudness = Math.min(getLoudness(h1.amplitude), getLoudness(h2.amplitude));
|
|
118
|
-
const minFrequency = Math.min(h1.frequencyNum, h2.frequencyNum);
|
|
119
|
-
if (minFrequency <= 0)
|
|
120
|
-
return 0;
|
|
121
|
-
const maxFrequency = Math.max(h1.frequencyNum, h2.frequencyNum);
|
|
122
|
-
for (const term of terms) {
|
|
123
|
-
if (term.ratio === 1 || term.ratio <= 0)
|
|
124
|
-
continue;
|
|
125
|
-
const difference = Math.abs(minFrequency * term.ratio - maxFrequency);
|
|
126
|
-
const s = x_star / (s1 * minFrequency + s2);
|
|
127
|
-
const widthModifier = 1 - widthMagnitudeRelationship * term.magnitude;
|
|
128
|
-
dissonance +=
|
|
129
|
-
term.magnitude *
|
|
130
|
-
minLoudness *
|
|
131
|
-
(Math.exp((-1 * b1 * s * difference) / widthModifier) -
|
|
132
|
-
Math.exp((-1 * b2 * s * difference) / widthModifier));
|
|
133
|
-
}
|
|
134
|
-
return dissonance;
|
|
135
|
-
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Spectrum } from "tuning-core";
|
|
2
|
+
import { DissonanceCurve } from "../classes";
|
|
3
|
+
const context = Spectrum.harmonic(1, 440);
|
|
4
|
+
const complement = Spectrum.harmonic(1, 440);
|
|
5
|
+
const curve = new DissonanceCurve({
|
|
6
|
+
context,
|
|
7
|
+
complement,
|
|
8
|
+
start: 1,
|
|
9
|
+
end: 2,
|
|
10
|
+
firstOrderDissonance: { magnitude: 1 },
|
|
11
|
+
secondOrderDissonance: { magnitude: 0.3 },
|
|
12
|
+
thirdOrderDissonance: { magnitude: 0.1 },
|
|
13
|
+
phantomHarmonicsNumber: 2,
|
|
14
|
+
maxGapCents: 20,
|
|
15
|
+
});
|
|
16
|
+
Bun.write("./manual-testing/curve.csv", curve.normalize().toCsvFileBuffer());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sethares-dissonance",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"devDependencies": {
|
|
6
6
|
"@types/bun": "latest"
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "npx tsc",
|
|
19
|
-
"release": "./release.sh"
|
|
19
|
+
"release": "./release.sh",
|
|
20
|
+
"bench": "bun run benches/dissonance-curve.bench.ts"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
23
|
"fraction.js": "^5.3.4",
|
|
23
|
-
"tuning-core": "^1.
|
|
24
|
+
"tuning-core": "^1.2.0"
|
|
24
25
|
}
|
|
25
26
|
}
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import Fraction from "fraction.js";
|
|
3
|
-
import { DissonanceCurve } from "../classes";
|
|
4
|
-
import { Spectrum } from "tuning-core";
|
|
5
|
-
function createTestCurve() {
|
|
6
|
-
const context = Spectrum.harmonic(6, 440);
|
|
7
|
-
const complement = Spectrum.harmonic(6, 440);
|
|
8
|
-
return new DissonanceCurve({
|
|
9
|
-
context,
|
|
10
|
-
complement,
|
|
11
|
-
start: 1,
|
|
12
|
-
end: 2,
|
|
13
|
-
maxDenominator: 60,
|
|
14
|
-
});
|
|
15
|
-
}
|
|
16
|
-
describe("DissonanceCurve", () => {
|
|
17
|
-
describe("findNearestPoint", () => {
|
|
18
|
-
describe("ratios that exist in the curve", () => {
|
|
19
|
-
test("returns exact point when ratio is in curve (number)", () => {
|
|
20
|
-
const curve = createTestCurve();
|
|
21
|
-
const points = curve.points;
|
|
22
|
-
const firstPoint = points[0];
|
|
23
|
-
const result = curve.findNearestPoint(firstPoint.interval.valueOf());
|
|
24
|
-
expect(result).toBeDefined();
|
|
25
|
-
expect(result?.interval.compare(firstPoint.interval)).toBe(0);
|
|
26
|
-
expect(result?.dissonance).toBe(firstPoint.dissonance);
|
|
27
|
-
});
|
|
28
|
-
test("returns exact point when ratio is in curve (string)", () => {
|
|
29
|
-
const curve = createTestCurve();
|
|
30
|
-
const points = curve.points;
|
|
31
|
-
const midPoint = points[Math.floor(points.length / 2)];
|
|
32
|
-
const result = curve.findNearestPoint(midPoint.interval.toFraction());
|
|
33
|
-
expect(result).toBeDefined();
|
|
34
|
-
expect(result?.interval.compare(midPoint.interval)).toBe(0);
|
|
35
|
-
});
|
|
36
|
-
test("returns exact point when ratio is in curve (Fraction)", () => {
|
|
37
|
-
const curve = createTestCurve();
|
|
38
|
-
const points = curve.points;
|
|
39
|
-
const lastPoint = points[points.length - 1];
|
|
40
|
-
const result = curve.findNearestPoint(new Fraction(lastPoint.interval));
|
|
41
|
-
expect(result).toBeDefined();
|
|
42
|
-
expect(result?.interval.compare(lastPoint.interval)).toBe(0);
|
|
43
|
-
});
|
|
44
|
-
test("returns exact point for ratio 1 (unison)", () => {
|
|
45
|
-
const curve = createTestCurve();
|
|
46
|
-
const result = curve.findNearestPoint(1);
|
|
47
|
-
expect(result).toBeDefined();
|
|
48
|
-
expect(result?.interval.compare(1)).toBe(0);
|
|
49
|
-
});
|
|
50
|
-
test("returns exact point for ratio 2 (octave)", () => {
|
|
51
|
-
const curve = createTestCurve();
|
|
52
|
-
const result = curve.findNearestPoint(2);
|
|
53
|
-
expect(result).toBeDefined();
|
|
54
|
-
expect(result?.interval.compare(2)).toBe(0);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
describe("ratios that do not exist in the curve", () => {
|
|
58
|
-
test("returns ceiling point when ratio is between two calculated points", () => {
|
|
59
|
-
const curve = createTestCurve();
|
|
60
|
-
const points = curve.points;
|
|
61
|
-
if (points.length < 2)
|
|
62
|
-
return;
|
|
63
|
-
const left = points[0];
|
|
64
|
-
const right = points[1];
|
|
65
|
-
const between = Math.sqrt(left.interval.valueOf() * right.interval.valueOf());
|
|
66
|
-
const result = curve.findNearestPoint(between);
|
|
67
|
-
expect(result).toBeDefined();
|
|
68
|
-
expect(result?.interval.compare(right.interval)).toBe(0);
|
|
69
|
-
});
|
|
70
|
-
test("returns first point when ratio is below curve range", () => {
|
|
71
|
-
const curve = createTestCurve();
|
|
72
|
-
const points = curve.points;
|
|
73
|
-
const minRatio = points[0].interval.valueOf();
|
|
74
|
-
const result = curve.findNearestPoint(minRatio * 0.5);
|
|
75
|
-
expect(result).toBeDefined();
|
|
76
|
-
expect(result?.interval.compare(points[0].interval)).toBe(0);
|
|
77
|
-
});
|
|
78
|
-
test("returns last point when ratio is above curve range", () => {
|
|
79
|
-
const curve = createTestCurve();
|
|
80
|
-
const points = curve.points;
|
|
81
|
-
const lastPoint = points[points.length - 1];
|
|
82
|
-
const result = curve.findNearestPoint(lastPoint.interval.valueOf() * 2);
|
|
83
|
-
expect(result).toBeDefined();
|
|
84
|
-
expect(result?.interval.compare(lastPoint.interval)).toBe(0);
|
|
85
|
-
});
|
|
86
|
-
test("returns correct point for ratio just below an existing point", () => {
|
|
87
|
-
const curve = createTestCurve();
|
|
88
|
-
const points = curve.points;
|
|
89
|
-
if (points.length < 2)
|
|
90
|
-
return;
|
|
91
|
-
const target = points[1];
|
|
92
|
-
const justBelow = target.interval.valueOf() - 0.001;
|
|
93
|
-
const result = curve.findNearestPoint(justBelow);
|
|
94
|
-
expect(result).toBeDefined();
|
|
95
|
-
expect(result?.interval.compare(target.interval)).toBe(0);
|
|
96
|
-
});
|
|
97
|
-
test("returns ceiling point when ratio is just above an existing point", () => {
|
|
98
|
-
const curve = createTestCurve();
|
|
99
|
-
const points = curve.points;
|
|
100
|
-
if (points.length < 2)
|
|
101
|
-
return;
|
|
102
|
-
const current = points[1];
|
|
103
|
-
const justAboveCurrent = current.interval.valueOf() + 0.001;
|
|
104
|
-
const result = curve.findNearestPoint(justAboveCurrent);
|
|
105
|
-
expect(result).toBeDefined();
|
|
106
|
-
expect(result.interval.compare(justAboveCurrent)).toBeGreaterThanOrEqual(0);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
describe("FractionInput types", () => {
|
|
110
|
-
test("accepts number input", () => {
|
|
111
|
-
const curve = createTestCurve();
|
|
112
|
-
const result = curve.findNearestPoint(1.5);
|
|
113
|
-
expect(result).toBeDefined();
|
|
114
|
-
});
|
|
115
|
-
test("accepts string fraction input", () => {
|
|
116
|
-
const curve = createTestCurve();
|
|
117
|
-
const result = curve.findNearestPoint("3/2");
|
|
118
|
-
expect(result).toBeDefined();
|
|
119
|
-
});
|
|
120
|
-
test("accepts Fraction object input", () => {
|
|
121
|
-
const curve = createTestCurve();
|
|
122
|
-
const result = curve.findNearestPoint(new Fraction(5, 4));
|
|
123
|
-
expect(result).toBeDefined();
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
});
|
package/dist/tests/utils.test.js
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { expect, test, describe } from "bun:test";
|
|
2
|
-
import * as Utils from "../lib/utils";
|
|
3
|
-
describe("lib/utils", () => {
|
|
4
|
-
test("Should calculate loudness", () => {
|
|
5
|
-
const amplitudes = [1, 0.001, 0.000025];
|
|
6
|
-
const expectedOutcome = [64, 1, 0];
|
|
7
|
-
expect(amplitudes.map(Utils.getLoudness)).toEqual(expectedOutcome);
|
|
8
|
-
});
|
|
9
|
-
});
|
|
File without changes
|
|
File without changes
|