sethares-dissonance 2.1.0 → 3.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 CHANGED
@@ -15,34 +15,40 @@ npm install sethares-dissonance
15
15
  ## Usage
16
16
 
17
17
  ```ts
18
- import { DissonanceCurve, Spectrum } from "sethares-dissonance";
18
+ import { DissonanceCurve } from "sethares-dissonance";
19
+ import { Spectrum } from "tuning-core";
19
20
 
20
- const context = Spectrum.harmonicSeries(10, 440);
21
- const complement = Spectrum.harmonicSeries(10, 440);
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
- maxDenominator: 60,
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
- ### 1. Create new instances
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, Spectrum } from "sethares-dissonance";
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
- maxDenominator: 60,
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,53 +72,4 @@ function DissonanceChart({ context, complement, start = 1, end = 2 }) {
62
72
  }
63
73
  ```
64
74
 
65
- This solution ok, but there is a downside that `curve` object contains methods that can mutate the internal state which will not be registered by React, thus leading to bugs.
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 recalculate method.
83
- * Use this type when exposing the curve from hooks to prevent external mutation.
84
- */
85
- export type ReadOnlyDissonanceCurve = Partial<Omit<
86
- DissonanceCurve,
87
- "recalculate"
88
- >>;
89
-
90
- function createReadOnlyWrapper(
91
- curve: DissonanceCurve,
92
- ): ReadOnlyDissonanceCurve {
93
-
94
- return {
95
- get maxDissonance() {
96
- return curve.maxDissonance;
97
- },
98
- plotCents: () => curve.plotCents(),
99
- findNearestPoint: (ratio) => curve.findNearestPoint(ratio),
100
- // .. can add more methods if needed, aboid those that cause mutations
101
- };
102
- }
103
-
104
- export function useDissonanceCurve(
105
- options: DissonanceCurveOptions,
106
- ): ReadOnlyDissonanceCurve {
107
- const [curve] = useState(() => new DissonanceCurve(options));
108
-
109
- return useMemo(() => {
110
- curve.recalculate(options);
111
- return createReadOnlyWrapper(curve);
112
- }, [options, curve]);
113
- }
114
- ```
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.
@@ -43,8 +44,9 @@ export declare class DissonanceCurve {
43
44
  private _data;
44
45
  start: Fraction;
45
46
  end: Fraction;
46
- context: Spectrum;
47
- complement: Spectrum;
47
+ context: SpectrumWithLoudness;
48
+ complement: SpectrumWithLoudness;
49
+ maxGapCents: number;
48
50
  maxDissonance: number;
49
51
  constructor(opts: DissonanceCurveOptions);
50
52
  /**
@@ -52,6 +54,7 @@ export declare class DissonanceCurve {
52
54
  * Updates the curve in place.
53
55
  */
54
56
  recalculate(opts: DissonanceCurveOptions): void;
57
+ private getIntervals;
55
58
  /**
56
59
  * Build _data from public props (context, complement, start, end).
57
60
  */
@@ -1,6 +1,7 @@
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 { SpectrumWithLoudness } from "./private/SpectrumWithLoudness";
4
5
  const DEFAULT_COLUMN_DELIMITER = ",";
5
6
  const DEFAULT_ROW_DELIMITER = "\n";
6
7
  /**
@@ -40,13 +41,16 @@ export class DissonanceCurve {
40
41
  end;
41
42
  context;
42
43
  complement;
44
+ maxGapCents;
43
45
  maxDissonance = 0;
44
46
  constructor(opts) {
45
- const { context, complement, start, end, maxDenominator, ...dissonanceParams } = opts;
46
- this.context = context;
47
- this.complement = complement;
47
+ const { context, complement, start, end, maxGapCents = 20, ...dissonanceParams } = opts;
48
+ const phantomHarmonics = SpectrumWithLoudness.harmonic(dissonanceParams.phantomHarmonicsNumber + 1, 1, true);
49
+ this.context = new SpectrumWithLoudness(context).mul(phantomHarmonics);
50
+ this.complement = new SpectrumWithLoudness(complement).mul(phantomHarmonics);
48
51
  this.start = new Fraction(start ?? 1);
49
52
  this.end = new Fraction(end ?? 2);
53
+ this.maxGapCents = maxGapCents;
50
54
  if (this.start.compare(this.end) > 0)
51
55
  throw Error("startCents should be less or equal to endCents");
52
56
  this.build(dissonanceParams);
@@ -56,26 +60,36 @@ export class DissonanceCurve {
56
60
  * Updates the curve in place.
57
61
  */
58
62
  recalculate(opts) {
59
- const { context, complement, start, end, maxDenominator, ...dissonanceParams } = opts;
60
- this.context = context;
61
- this.complement = complement;
63
+ const { context, complement, start, end, maxGapCents = 20, ...dissonanceParams } = opts;
64
+ const phantomHarmonics = SpectrumWithLoudness.harmonic(dissonanceParams.phantomHarmonicsNumber + 1, 1, true);
65
+ this.context = new SpectrumWithLoudness(context).mul(phantomHarmonics);
66
+ this.complement = new SpectrumWithLoudness(complement).mul(phantomHarmonics);
62
67
  this.start = new Fraction(start ?? 1);
63
68
  this.end = new Fraction(end ?? 2);
69
+ this.maxGapCents = maxGapCents;
64
70
  if (this.start.compare(this.end) > 0)
65
71
  throw Error("startCents should be less or equal to endCents");
66
72
  this.build(dissonanceParams);
67
73
  }
74
+ getIntervals() {
75
+ return IntervalSet.affinitive(this.context, this.complement)
76
+ .minMax(this.start, this.end)
77
+ .add(this.start)
78
+ .add(this.end)
79
+ .interpolateLog(this.maxGapCents);
80
+ }
68
81
  /**
69
82
  * Build _data from public props (context, complement, start, end).
70
83
  */
71
84
  build(dissonanceParams) {
72
85
  this._data.clear();
73
86
  let maxDissonance = 0;
74
- const intervals = IntervalSet.affinitive(this.context.size > 1 ? this.context : Spectrum.harmonic(2, 1), this.complement.size > 1 ? this.complement : Spectrum.harmonic(2, 1)).densify(20);
87
+ const intervals = this.getIntervals();
75
88
  const ratios = intervals.getRatios();
89
+ const contextIntrinsic = getIntrinsicDissonance(this.context, dissonanceParams);
76
90
  for (let i = 0; i < ratios.length; i++) {
77
91
  const interval = ratios[i];
78
- const dissonance = getSetharesDissonance(this.context, this.complement.toTransposed(interval), dissonanceParams);
92
+ const dissonance = getSetharesDissonance(this.context, this.complement.toTransposed(interval), dissonanceParams, contextIntrinsic);
79
93
  if (dissonance > maxDissonance)
80
94
  maxDissonance = dissonance;
81
95
  this._data.set(interval.toFraction(), { interval, dissonance });
@@ -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
+ }
@@ -1,10 +1,4 @@
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
4
  s1: number;
@@ -12,18 +6,4 @@ export declare const SETHARES_DISSONANCE_PARAMS: {
12
6
  b1: number;
13
7
  b2: number;
14
8
  x_star: number;
15
- totalContribution: number;
16
- };
17
- export declare const DEFAULT_DISSONANCE_PARAMS: {
18
- secondOrderBeating: {
19
- terms: SecondOrderBeatingTerm[];
20
- widthMagnitudeRelationship: number;
21
- totalContribution: number;
22
- };
23
- s1: number;
24
- s2: number;
25
- b1: number;
26
- b2: number;
27
- x_star: number;
28
- totalContribution: number;
29
9
  };
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,4 @@ export const SETHARES_DISSONANCE_PARAMS = {
11
6
  b1: 3.5,
12
7
  b2: 5.75,
13
8
  x_star: 0.24,
14
- totalContribution: 1,
15
- };
16
- export const DEFAULT_DISSONANCE_PARAMS = {
17
- ...SETHARES_DISSONANCE_PARAMS,
18
- secondOrderBeating: SECOND_ORDER_BEATING_PARAMS,
19
9
  };
@@ -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
+ }
@@ -1,13 +1,11 @@
1
1
  import type Fraction from "fraction.js";
2
- import type { SECOND_ORDER_BEATING_PARAMS, SETHARES_DISSONANCE_PARAMS } from "./const";
3
- export type SecondOrderBeatingTerm = {
4
- ratio: number;
5
- magnitude: number;
6
- };
2
+ import type { SETHARES_DISSONANCE_PARAMS } from "./const";
7
3
  export type SetharesDissonanceParams = Partial<typeof SETHARES_DISSONANCE_PARAMS>;
8
- export type SecondOrderBeatingParams = Partial<typeof SECOND_ORDER_BEATING_PARAMS>;
9
4
  export type DissonanceParams = SetharesDissonanceParams & {
10
- secondOrderBeating?: SecondOrderBeatingParams;
5
+ firstOrderContribution: number;
6
+ secondOrderContribution: number;
7
+ thirdOrderContribution: number;
8
+ phantomHarmonicsNumber: number;
11
9
  };
12
10
  export type DissonanceCurvePoint = {
13
11
  interval: Fraction;
@@ -1,16 +1,23 @@
1
- import type { Harmonic, Spectrum } from "tuning-core";
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 & {
8
+ contribution?: number;
9
+ }): number;
3
10
  /**
4
- * 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
5
- * @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, ...
11
+ * Find dissonance between two harmonics based on phantom status.
12
+ * - Both non-phantom: firstOrderDissonance
13
+ * - One phantom, one non-phantom: secondOrderDissonance
14
+ * - Both phantom: thirdOrderDissonance
6
15
  */
7
- export declare function getLoudness(amplitude: number): number;
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;
16
+ export declare function getSensoryDissonance(h1: HarmonicWithLoudness, h2: HarmonicWithLoudness, params?: DissonanceParams): number;
10
17
  /**
11
18
  * Calculate the intrinsic dissonance of a spectrum.
12
19
  */
13
- export declare function getIntrinsicDissonance(spectrum: Spectrum, params?: DissonanceParams): number;
20
+ export declare function getIntrinsicDissonance(spectrum: SpectrumWithLoudness, params?: DissonanceParams): number;
14
21
  /**
15
22
  * Calculate the dissonance between two spectra.
16
23
  * This is the sum of the intrinsic dissonance of each spectrum
@@ -18,9 +25,7 @@ export declare function getIntrinsicDissonance(spectrum: Spectrum, params?: Diss
18
25
  *
19
26
  * Note: If not proviing secondOrderBeating params is yield the same result as Sethares' TTSS formula.
20
27
  * However secondOrderBeating params can be used to finetune the dissoannce perception and account for harmonicity.
28
+ *
29
+ * @param precomputedSpectrum1Intrinsic - When provided, use this instead of computing spectrum1's intrinsic dissonance (for performance when spectrum1 is constant across many calls).
21
30
  */
22
- export declare function getSetharesDissonance(spectrum1: Spectrum, spectrum2: Spectrum, params?: DissonanceParams): number;
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;
31
+ 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 { DEFAULT_DISSONANCE_PARAMS, 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
- }
1
+ import { 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 x_star = params?.x_star ?? DEFAULT_DISSONANCE_PARAMS.x_star;
20
- const s1 = params?.s1 ?? DEFAULT_DISSONANCE_PARAMS.s1;
21
- const s2 = params?.s2 ?? DEFAULT_DISSONANCE_PARAMS.s2;
22
- const b1 = params?.b1 ?? DEFAULT_DISSONANCE_PARAMS.b1;
23
- const b2 = params?.b2 ?? DEFAULT_DISSONANCE_PARAMS.b2;
7
+ const contribution = params?.contribution ?? 1;
8
+ if (contribution <= 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,50 @@ 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 (minLoudness *
25
+ return (contribution *
26
+ minLoudness *
35
27
  (Math.exp(-1 * b1 * s * frequencyDifference) -
36
28
  Math.exp(-1 * b2 * s * frequencyDifference)));
37
29
  }
30
+ /** For now identical to getPlompLeveltDissonance */
31
+ function firstOrderDissonance(h1, h2, params) {
32
+ return getPlompLeveltDissonance(h1, h2, { ...params, contribution: params?.firstOrderContribution });
33
+ }
34
+ /** For now identical to getPlompLeveltDissonance */
35
+ function secondOrderDissonance(h1, h2, params) {
36
+ return getPlompLeveltDissonance(h1, h2, { ...params, contribution: params?.secondOrderContribution });
37
+ }
38
+ /** For now identical to getPlompLeveltDissonance */
39
+ function thirdOrderDissonance(h1, h2, params) {
40
+ return getPlompLeveltDissonance(h1, h2, { ...params, contribution: params?.thirdOrderContribution });
41
+ }
42
+ /**
43
+ * Find dissonance between two harmonics based on phantom status.
44
+ * - Both non-phantom: firstOrderDissonance
45
+ * - One phantom, one non-phantom: secondOrderDissonance
46
+ * - Both phantom: thirdOrderDissonance
47
+ */
48
+ export function getSensoryDissonance(h1, h2, params) {
49
+ if (!h1.phantom && !h2.phantom) {
50
+ return firstOrderDissonance(h1, h2, params);
51
+ }
52
+ if (h1.phantom !== h2.phantom) {
53
+ return secondOrderDissonance(h1, h2, params);
54
+ }
55
+ return thirdOrderDissonance(h1, h2, params);
56
+ }
38
57
  /**
39
58
  * Calculate the intrinsic dissonance of a spectrum.
40
59
  */
41
60
  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
61
  let dissonance = 0;
47
- const frequencies = spectrum.getKeys();
62
+ const harmonics = spectrum.getHarmonics();
48
63
  // loop over all pairs of harmonics within the spectrum (not including reversed pairs)
49
- for (let i = 0; i < frequencies.length; i++) {
50
- for (let j = i + 1; j < frequencies.length; j++) {
51
- const h1 = spectrum.get(frequencies[i]);
52
- const h2 = spectrum.get(frequencies[j]);
53
- if (totalContribution) {
54
- dissonance +=
55
- totalContribution *
56
- getPlompLeveltDissonance(h1, h2, params);
57
- }
58
- if (secondOrderBeatingContribution) {
59
- dissonance +=
60
- secondOrderBeatingContribution *
61
- getSecondOrderBeatingDissonance(h1, h2, params);
62
- }
64
+ for (let i = 0; i < harmonics.length; i++) {
65
+ for (let j = i + 1; j < harmonics.length; j++) {
66
+ const h1 = harmonics[i];
67
+ const h2 = harmonics[j];
68
+ dissonance += getSensoryDissonance(h1, h2, params);
63
69
  }
64
70
  }
65
71
  return dissonance;
@@ -71,65 +77,17 @@ export function getIntrinsicDissonance(spectrum, params) {
71
77
  *
72
78
  * Note: If not proviing secondOrderBeating params is yield the same result as Sethares' TTSS formula.
73
79
  * However secondOrderBeating params can be used to finetune the dissoannce perception and account for harmonicity.
80
+ *
81
+ * @param precomputedSpectrum1Intrinsic - When provided, use this instead of computing spectrum1's intrinsic dissonance (for performance when spectrum1 is constant across many calls).
74
82
  */
75
- export function getSetharesDissonance(spectrum1, spectrum2, params) {
76
- const totalContribution = params?.totalContribution ??
77
- DEFAULT_DISSONANCE_PARAMS.totalContribution;
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();
83
+ export function getSetharesDissonance(spectrum1, spectrum2, params, precomputedSpectrum1Intrinsic) {
84
+ const spectrum1Intrinsic = precomputedSpectrum1Intrinsic ?? getIntrinsicDissonance(spectrum1, params);
85
+ let dissonance = spectrum1Intrinsic + getIntrinsicDissonance(spectrum2, params);
84
86
  // loop over all pairs of harmonics between the two spectra (not including reversed pairs)
85
- for (let i = 0; i < frequencies1.length; i++) {
86
- for (let j = 0; j < frequencies2.length; j++) {
87
- const h1 = spectrum1.get(frequencies1[i]);
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
- }
87
+ for (const h1 of spectrum1.getHarmonics()) {
88
+ for (const h2 of spectrum2.getHarmonics()) {
89
+ dissonance += getSensoryDissonance(h1, h2, params);
99
90
  }
100
91
  }
101
92
  return dissonance;
102
93
  }
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(6, 440);
4
+ const complement = Spectrum.harmonic(6, 440);
5
+ const curve = new DissonanceCurve({
6
+ context,
7
+ complement,
8
+ start: 1,
9
+ end: 2,
10
+ firstOrderContribution: 1,
11
+ secondOrderContribution: 0.3,
12
+ thirdOrderContribution: 0.1,
13
+ phantomHarmonicsNumber: 2,
14
+ maxGapCents: 20,
15
+ });
16
+ Bun.write("./manual-testing/curve.csv", curve.toCsvFileBuffer());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sethares-dissonance",
3
- "version": "2.1.0",
3
+ "version": "3.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.1.0"
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
- });
@@ -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
- });