sethares-dissonance 0.2.0 → 2.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.
@@ -1,23 +1,87 @@
1
- import { SETHARES_DISSONANCE_PARAMS, type SpectrumPartial } from "../lib";
2
- export type DissonanceCurveOptions = Partial<typeof SETHARES_DISSONANCE_PARAMS> & {
3
- context: SpectrumPartial[];
4
- complement: SpectrumPartial[];
5
- step?: number;
6
- rangeMin?: number;
7
- rangeMax?: number;
1
+ import { Fraction, type FractionInput } from "fraction.js";
2
+ import { type DissonanceParams, type DissonanceCurvePoint, type DissonanceCurveData } from "../lib";
3
+ import { Spectrum } from "tuning-core";
4
+ export type DissonanceCurveOptions = DissonanceParams & {
5
+ context: Spectrum;
6
+ complement: Spectrum;
7
+ maxDenominator?: number;
8
+ start?: FractionInput;
9
+ end?: FractionInput;
8
10
  };
11
+ /**
12
+ * Represents a dissonance curve calculated using Sethares' sensory dissonance model.
13
+ *
14
+ * A dissonance curve shows how sensory dissonance varies across a range of intervals.
15
+ * The curve is generated by:
16
+ * 1. Creating a set of intervals between start and end ratios using IntervalSet.range()
17
+ * 2. For each interval calculates dissonance between the context spectrum and complement spectrum transposed by that interval
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const context = Spectrum.harmonicSeries(10, 440);
22
+ * const complement = Spectrum.harmonicSeries(10, 440);
23
+ *
24
+ * const curve = new DissonanceCurve({
25
+ * context,
26
+ * complement,
27
+ * start: 1,
28
+ * end: 2,
29
+ * maxDenominator: 60
30
+ * });
31
+ *
32
+ * // Get points sorted by interval
33
+ * const points = curve.points;
34
+ *
35
+ * // Get plot data with ratios
36
+ * const plotData = curve.plot();
37
+ *
38
+ * // Get plot data with cents
39
+ * const plotDataCents = curve.plotCents();
40
+ * ```
41
+ */
9
42
  export declare class DissonanceCurve {
10
43
  private _data;
11
- readonly rangeMin: NonNullable<DissonanceCurveOptions["rangeMin"]>;
12
- readonly rangeMax: NonNullable<DissonanceCurveOptions["rangeMax"]>;
13
- readonly step: NonNullable<DissonanceCurveOptions["step"]>;
14
- readonly context: DissonanceCurveOptions["context"];
15
- readonly complement: DissonanceCurveOptions["complement"];
44
+ readonly start: Fraction;
45
+ readonly end: Fraction;
46
+ readonly context: Spectrum;
47
+ readonly complement: Spectrum;
16
48
  readonly maxDissonance: number;
17
- readonly change: number;
18
49
  constructor(opts: DissonanceCurveOptions);
19
- get points(): [number, number][];
20
- get(cent: number): number | undefined;
50
+ /**
51
+ * Get all dissonance curve points sorted by interval ratio.
52
+ * @returns Array of DissonanceCurvePoint objects sorted in ascending order by interval
53
+ */
54
+ get points(): DissonanceCurvePoint[];
55
+ /**
56
+ * Get a dissonance curve point for a specific ratio.
57
+ * @param ratio - The interval ratio as FractionInput (number, string, array, object, or Fraction)
58
+ * @returns The DissonanceCurvePoint for the given ratio, or undefined if not found
59
+ */
60
+ get(ratio: FractionInput): DissonanceCurvePoint | undefined;
61
+ /**
62
+ * Get plot points with intervals as ratio values (decimal numbers).
63
+ * @returns Array of [ratio, dissonance] tuples suitable for plotting
64
+ */
65
+ plot(): [number, number][];
66
+ /**
67
+ * Get plot points with intervals as cents values.
68
+ * @returns Array of [cents, dissonance] tuples suitable for plotting
69
+ */
70
+ plotCents(): [number, number][];
71
+ /**
72
+ * Serialize the dissonance curve to a JSON object.
73
+ * @returns DissonanceCurveData object
74
+ */
75
+ toJSON(): DissonanceCurveData;
21
76
  private getRowString;
22
- toFileString(): string;
77
+ toString(columnDelimiter?: string, rowDelimiter?: string): string;
78
+ toCsvFileBuffer(columnDelimiter?: string, rowDelimiter?: string): Buffer<ArrayBuffer>;
79
+ /**
80
+ * Find the nearest calculated point for a given ratio.
81
+ * Uses binary search to find the first point where given ratio <= current ratio.
82
+ * If given ratio is greater than all calculated ratios, returns the last point.
83
+ * @param ratio - The ratio to find
84
+ * @returns The nearest DissonanceCurvePoint, or undefined if curve has no points
85
+ */
86
+ findNearestPoint(ratio: FractionInput): DissonanceCurvePoint | undefined;
23
87
  }
@@ -1,57 +1,168 @@
1
- import { getSetharesDissonance, SETHARES_DISSONANCE_PARAMS, transpose } from "../lib";
1
+ import { Fraction } from "fraction.js";
2
+ import { getSetharesDissonance, } from "../lib";
3
+ import { ratioToCents, Spectrum, IntervalSet } from "tuning-core";
4
+ const DEFAULT_COLUMN_DELIMITER = ",";
5
+ const DEFAULT_ROW_DELIMITER = "\n";
6
+ /**
7
+ * Represents a dissonance curve calculated using Sethares' sensory dissonance model.
8
+ *
9
+ * A dissonance curve shows how sensory dissonance varies across a range of intervals.
10
+ * The curve is generated by:
11
+ * 1. Creating a set of intervals between start and end ratios using IntervalSet.range()
12
+ * 2. For each interval calculates dissonance between the context spectrum and complement spectrum transposed by that interval
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const context = Spectrum.harmonicSeries(10, 440);
17
+ * const complement = Spectrum.harmonicSeries(10, 440);
18
+ *
19
+ * const curve = new DissonanceCurve({
20
+ * context,
21
+ * complement,
22
+ * start: 1,
23
+ * end: 2,
24
+ * maxDenominator: 60
25
+ * });
26
+ *
27
+ * // Get points sorted by interval
28
+ * const points = curve.points;
29
+ *
30
+ * // Get plot data with ratios
31
+ * const plotData = curve.plot();
32
+ *
33
+ * // Get plot data with cents
34
+ * const plotDataCents = curve.plotCents();
35
+ * ```
36
+ */
2
37
  export class DissonanceCurve {
3
38
  _data = new Map();
4
- rangeMin;
5
- rangeMax;
6
- step;
39
+ start;
40
+ end;
7
41
  context;
8
42
  complement;
9
43
  maxDissonance = 0;
10
- change = 0;
11
44
  constructor(opts) {
12
- const { context, complement, rangeMin, rangeMax, step, ...dissonanceParams } = opts;
45
+ const { context, complement, start, end, maxDenominator, ...dissonanceParams } = opts;
13
46
  this.context = context;
14
47
  this.complement = complement;
15
- this.rangeMin = rangeMin ?? 0;
16
- this.rangeMax = rangeMax ?? 1200;
17
- this.step = step ?? 1;
18
- if (this.rangeMin > this.rangeMax)
19
- throw Error("rangeMin should be less or equal to rangeMax");
20
- if (this.step <= 0)
21
- throw Error("precision should be greater than zero");
22
- for (let cent = this.rangeMin; cent <= this.rangeMax; cent += this.step) {
23
- const dissonance = getSetharesDissonance(this.context, transpose(this.complement, cent), dissonanceParams);
48
+ this.start = new Fraction(start ?? 1);
49
+ this.end = new Fraction(end ?? 2);
50
+ if (this.start.compare(this.end) > 0)
51
+ throw Error("startCents should be less or equal to endCents");
52
+ const intervals = IntervalSet.affinitive(this.context, this.complement).densify(20);
53
+ const ratios = intervals.getRatios();
54
+ for (let i = 0; i < ratios.length; i++) {
55
+ const interval = ratios[i];
56
+ const dissonance = getSetharesDissonance(this.context, this.complement.toTransposed(interval), dissonanceParams);
24
57
  if (dissonance > this.maxDissonance)
25
58
  this.maxDissonance = dissonance;
26
- this._data.set(cent, dissonance);
59
+ this._data.set(interval.toFraction(), { interval, dissonance });
27
60
  }
28
61
  }
62
+ /**
63
+ * Get all dissonance curve points sorted by interval ratio.
64
+ * @returns Array of DissonanceCurvePoint objects sorted in ascending order by interval
65
+ */
29
66
  get points() {
30
- return Array.from(this._data.entries()).sort((a, b) => a[0] - b[0]);
67
+ return Array.from(this._data.values()).sort((a, b) => a.interval.compare(b.interval));
31
68
  }
32
- get(cent) {
33
- return this._data.get(cent);
69
+ /**
70
+ * Get a dissonance curve point for a specific ratio.
71
+ * @param ratio - The interval ratio as FractionInput (number, string, array, object, or Fraction)
72
+ * @returns The DissonanceCurvePoint for the given ratio, or undefined if not found
73
+ */
74
+ get(ratio) {
75
+ return this._data.get(new Fraction(ratio).toFraction());
34
76
  }
35
- getRowString(row) {
77
+ /**
78
+ * Get plot points with intervals as ratio values (decimal numbers).
79
+ * @returns Array of [ratio, dissonance] tuples suitable for plotting
80
+ */
81
+ plot() {
82
+ return this.points.map(({ interval, dissonance }) => [
83
+ interval.valueOf(),
84
+ dissonance,
85
+ ]);
86
+ }
87
+ /**
88
+ * Get plot points with intervals as cents values.
89
+ * @returns Array of [cents, dissonance] tuples suitable for plotting
90
+ */
91
+ plotCents() {
92
+ return this.points.map(({ interval, dissonance }) => [
93
+ ratioToCents(interval).valueOf(),
94
+ dissonance,
95
+ ]);
96
+ }
97
+ /**
98
+ * Serialize the dissonance curve to a JSON object.
99
+ * @returns DissonanceCurveData object
100
+ */
101
+ toJSON() {
102
+ return this.points.map(({ interval, dissonance }) => ({
103
+ interval: {
104
+ n: Number(interval.n),
105
+ d: Number(interval.d),
106
+ },
107
+ dissonance,
108
+ }));
109
+ }
110
+ getRowString(row, delimiter = DEFAULT_COLUMN_DELIMITER) {
36
111
  if (row.length === 0)
37
112
  return "";
38
113
  let result = `${row[0]}`;
39
114
  for (let i = 1; i < row.length; i += 1) {
40
- result += `\t${row[i]}`;
115
+ result += `${delimiter}${row[i]}`;
41
116
  }
42
117
  return result;
43
118
  }
44
- toFileString() {
119
+ toString(columnDelimiter = DEFAULT_COLUMN_DELIMITER, rowDelimiter = DEFAULT_ROW_DELIMITER) {
45
120
  if (this._data.size === 0)
46
121
  return "";
47
- const headerRow = this.getRowString([
48
- "Interval (cents)",
49
- "Sensory dissonance",
50
- ]);
51
- let result = headerRow + "\n";
122
+ const headerRow = this.getRowString(["Ratio", "Interval", "Interval (cents)", "Sensory dissonance"], columnDelimiter);
123
+ let result = headerRow + rowDelimiter;
52
124
  for (const point of this.points) {
53
- result += this.getRowString(point);
125
+ result += this.getRowString([
126
+ point.interval.toFraction(),
127
+ point.interval.valueOf(),
128
+ ratioToCents(point.interval).valueOf(),
129
+ point.dissonance,
130
+ ], columnDelimiter) + rowDelimiter;
54
131
  }
55
132
  return result;
56
133
  }
134
+ toCsvFileBuffer(columnDelimiter = DEFAULT_COLUMN_DELIMITER, rowDelimiter = DEFAULT_ROW_DELIMITER) {
135
+ const content = this.toString(columnDelimiter, rowDelimiter);
136
+ // Return UTF-8 BOM + content as Buffer for Node.js writeFileSync
137
+ return Buffer.concat([
138
+ Buffer.from([0xef, 0xbb, 0xbf]),
139
+ Buffer.from(content, "utf-8"),
140
+ ]);
141
+ }
142
+ /**
143
+ * Find the nearest calculated point for a given ratio.
144
+ * Uses binary search to find the first point where given ratio <= current ratio.
145
+ * If given ratio is greater than all calculated ratios, returns the last point.
146
+ * @param ratio - The ratio to find
147
+ * @returns The nearest DissonanceCurvePoint, or undefined if curve has no points
148
+ */
149
+ findNearestPoint(ratio) {
150
+ const points = this.points;
151
+ if (points.length === 0)
152
+ return undefined;
153
+ const given = new Fraction(ratio);
154
+ let left = 0;
155
+ let right = points.length;
156
+ while (left < right) {
157
+ const mid = (left + right) >>> 1;
158
+ const point = points[mid];
159
+ if (given.compare(point.interval) <= 0) {
160
+ right = mid;
161
+ }
162
+ else {
163
+ left = mid + 1;
164
+ }
165
+ }
166
+ return points[left] ?? points[points.length - 1];
167
+ }
57
168
  }
@@ -0,0 +1,29 @@
1
+ import type { SecondOrderBeatingTerm } from "./types";
2
+ 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
+ /** Fitting parameters proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
9
+ export declare const SETHARES_DISSONANCE_PARAMS: {
10
+ s1: number;
11
+ s2: number;
12
+ b1: number;
13
+ b2: number;
14
+ 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
+ };
@@ -0,0 +1,19 @@
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
+ /** Fitting parameters proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
8
+ export const SETHARES_DISSONANCE_PARAMS = {
9
+ s1: 0.021,
10
+ s2: 19,
11
+ b1: 3.5,
12
+ b2: 5.75,
13
+ 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
+ };
@@ -1 +1,3 @@
1
1
  export * from "./utils";
2
+ export * from "./const";
3
+ export * from "./types";
package/dist/lib/index.js CHANGED
@@ -1 +1,3 @@
1
1
  export * from "./utils";
2
+ export * from "./const";
3
+ export * from "./types";
@@ -0,0 +1,22 @@
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
+ };
7
+ export type SetharesDissonanceParams = Partial<typeof SETHARES_DISSONANCE_PARAMS>;
8
+ export type SecondOrderBeatingParams = Partial<typeof SECOND_ORDER_BEATING_PARAMS>;
9
+ export type DissonanceParams = SetharesDissonanceParams & {
10
+ secondOrderBeating?: SecondOrderBeatingParams;
11
+ };
12
+ export type DissonanceCurvePoint = {
13
+ interval: Fraction;
14
+ dissonance: number;
15
+ };
16
+ export type DissonanceCurveData = {
17
+ interval: {
18
+ n: number;
19
+ d: number;
20
+ };
21
+ dissonance: number;
22
+ }[];
File without changes
@@ -1,32 +1,26 @@
1
- export type SpectrumPartial = {
2
- rate: number;
3
- amplitude: number;
4
- phase?: number;
5
- };
1
+ import type { Harmonic, Spectrum } from "tuning-core";
2
+ import type { DissonanceParams } from "./types";
6
3
  /**
7
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
8
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, ...
9
6
  */
10
7
  export declare function getLoudness(amplitude: number): number;
11
- /** Fitting parameters proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
12
- export declare const SETHARES_DISSONANCE_PARAMS: {
13
- s1: number;
14
- s2: number;
15
- b1: number;
16
- b2: number;
17
- x_star: number;
18
- };
19
- export type SetharesDissonanceParams = Partial<typeof SETHARES_DISSONANCE_PARAMS>;
20
8
  /** The formula to calculate sensory dissoannce proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
21
- export declare function getPlompLeveltDissonance(partial1: SpectrumPartial, partial2: SpectrumPartial, params?: {
22
- s1: number;
23
- s2: number;
24
- b1: number;
25
- b2: number;
26
- x_star: number;
27
- }): number;
28
- export declare function getIntrinsicDissonance(spectrum: SpectrumPartial[], params?: SetharesDissonanceParams): number;
29
- export declare function getSetharesDissonance(spectrum1: SpectrumPartial[], spectrum2: SpectrumPartial[], params?: SetharesDissonanceParams): number;
30
- export declare function ratioToCents(ratio: number): number;
31
- export declare function centsToRatio(cents: number): number;
32
- export declare function transpose(partials: SpectrumPartial[], cents: number): SpectrumPartial[];
9
+ export declare function getPlompLeveltDissonance(h1: Harmonic, h2: Harmonic, params?: DissonanceParams): number;
10
+ /**
11
+ * Calculate the intrinsic dissonance of a spectrum.
12
+ */
13
+ export declare function getIntrinsicDissonance(spectrum: Spectrum, params?: DissonanceParams): number;
14
+ /**
15
+ * Calculate the dissonance between two spectra.
16
+ * This is the sum of the intrinsic dissonance of each spectrum
17
+ * plus the dissonance between the harmonics of the two spectra.
18
+ *
19
+ * Note: If not proviing secondOrderBeating params is yield the same result as Sethares' TTSS formula.
20
+ * However secondOrderBeating params can be used to finetune the dissoannce perception and account for harmonicity.
21
+ */
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;
package/dist/lib/utils.js CHANGED
@@ -1,4 +1,4 @@
1
- const NORMALISATION_PRESSURE_UNIT = 2.8284271247461905;
1
+ import { DEFAULT_DISSONANCE_PARAMS, NORMALISATION_PRESSURE_UNIT } from "./const";
2
2
  /**
3
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
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, ...
@@ -14,72 +14,122 @@ export function getLoudness(amplitude) {
14
14
  return Math.pow(phons / 40, 2.86) - 0.005;
15
15
  return Math.pow(2, (phons - 40) / 10);
16
16
  }
17
- /** Fitting parameters proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
18
- export const SETHARES_DISSONANCE_PARAMS = {
19
- s1: 0.021,
20
- s2: 19,
21
- b1: 3.5,
22
- b2: 5.75,
23
- x_star: 0.24,
24
- };
25
17
  /** The formula to calculate sensory dissoannce proposed by Sethares in the appendix "How to Draw Dissonance Curves" */
26
- export function getPlompLeveltDissonance(partial1, partial2, params = SETHARES_DISSONANCE_PARAMS) {
27
- if (partial1.rate === partial2.rate)
18
+ 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;
24
+ if (h1.frequency.equals(h2.frequency))
28
25
  return 0;
29
- const minLoudness = Math.min(getLoudness(partial1.amplitude), getLoudness(partial2.amplitude));
26
+ const minLoudness = Math.min(getLoudness(h1.amplitude), getLoudness(h2.amplitude));
30
27
  if (minLoudness <= 0)
31
28
  return 0;
32
- const minFrequency = Math.min(partial1.rate, partial2.rate);
33
- const frequencyDifference = Math.abs(partial1.rate - partial2.rate);
29
+ const minFrequency = Math.min(h1.frequencyNum, h2.frequencyNum);
34
30
  if (minFrequency <= 0)
35
31
  return 0;
36
- const s = params.x_star / (params.s1 * minFrequency + params.s2);
32
+ const frequencyDifference = Math.abs(h1.frequencyNum - h2.frequencyNum);
33
+ const s = x_star / (s1 * minFrequency + s2);
37
34
  return (minLoudness *
38
- (Math.exp(-1 * params.b1 * s * frequencyDifference) -
39
- Math.exp(-1 * params.b2 * s * frequencyDifference)));
35
+ (Math.exp(-1 * b1 * s * frequencyDifference) -
36
+ Math.exp(-1 * b2 * s * frequencyDifference)));
40
37
  }
38
+ /**
39
+ * Calculate the intrinsic dissonance of a spectrum.
40
+ */
41
41
  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;
42
46
  let dissonance = 0;
43
- for (let i = 0; i < spectrum.length; i++) {
44
- for (let j = i + 1; j < spectrum.length; j++) {
45
- const partial1 = spectrum[i];
46
- const partial2 = spectrum[j];
47
- dissonance += getPlompLeveltDissonance(partial1, partial2, {
48
- ...SETHARES_DISSONANCE_PARAMS,
49
- ...params,
50
- });
47
+ const frequencies = spectrum.getKeys();
48
+ // 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
+ }
51
63
  }
52
64
  }
53
65
  return dissonance;
54
66
  }
67
+ /**
68
+ * Calculate the dissonance between two spectra.
69
+ * This is the sum of the intrinsic dissonance of each spectrum
70
+ * plus the dissonance between the harmonics of the two spectra.
71
+ *
72
+ * Note: If not proviing secondOrderBeating params is yield the same result as Sethares' TTSS formula.
73
+ * However secondOrderBeating params can be used to finetune the dissoannce perception and account for harmonicity.
74
+ */
55
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;
56
80
  let dissonance = getIntrinsicDissonance(spectrum1, params) +
57
81
  getIntrinsicDissonance(spectrum2, params);
58
- for (let i = 0; i < spectrum1.length; i++) {
59
- for (let j = 0; j < spectrum2.length; j++) {
60
- const partial1 = spectrum1[i];
61
- const partial2 = spectrum2[j];
62
- dissonance += getPlompLeveltDissonance(partial1, partial2, {
63
- ...SETHARES_DISSONANCE_PARAMS,
64
- ...params,
65
- });
82
+ const frequencies1 = spectrum1.getKeys();
83
+ const frequencies2 = spectrum2.getKeys();
84
+ // 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
+ }
66
99
  }
67
100
  }
68
101
  return dissonance;
69
102
  }
70
- export function ratioToCents(ratio) {
71
- return ratio > 0 ? 1200 * Math.log2(ratio) : 0;
72
- }
73
- export function centsToRatio(cents) {
74
- return Math.pow(2, cents / 1200);
75
- }
76
- export function transpose(partials, cents) {
77
- const result = [];
78
- for (const partial of partials) {
79
- result.push({
80
- rate: partial.rate * centsToRatio(cents),
81
- amplitude: partial.amplitude,
82
- });
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));
83
133
  }
84
- return result;
134
+ return dissonance;
85
135
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,127 @@
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,6 +1,5 @@
1
1
  import { expect, test, describe } from "bun:test";
2
2
  import * as Utils from "../lib/utils";
3
- import { DissonanceCurve } from "../classes";
4
3
  describe("lib/utils", () => {
5
4
  test("Should calculate loudness", () => {
6
5
  const amplitudes = [1, 0.001, 0.000025];
@@ -8,9 +7,3 @@ describe("lib/utils", () => {
8
7
  expect(amplitudes.map(Utils.getLoudness)).toEqual(expectedOutcome);
9
8
  });
10
9
  });
11
- describe("classes/DissoannceCurve", () => {
12
- test("Should construct Dissoancne curve", () => {
13
- const curve = new DissonanceCurve({ context: [], complement: [] });
14
- expect(curve).toBeInstanceOf(DissonanceCurve);
15
- });
16
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sethares-dissonance",
3
- "version": "0.2.0",
3
+ "version": "2.0.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -11,8 +11,15 @@
11
11
  },
12
12
  "main": "dist/index.js",
13
13
  "types": "dist/index.d.ts",
14
- "files": ["./dist"],
14
+ "files": [
15
+ "./dist"
16
+ ],
15
17
  "scripts": {
16
- "build": "npx tsc"
18
+ "build": "npx tsc",
19
+ "publish": "npx tsc && bun publish --access public"
20
+ },
21
+ "dependencies": {
22
+ "fraction.js": "^5.3.4",
23
+ "tuning-core": "^1.0.0"
17
24
  }
18
25
  }