tuning-core 1.0.1 → 1.0.2
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/dist/classes/Harmonic.d.ts +92 -0
- package/dist/classes/Harmonic.js +164 -0
- package/dist/classes/IntervalSet.d.ts +157 -0
- package/dist/classes/IntervalSet.js +304 -0
- package/dist/classes/Spectrum.d.ts +127 -0
- package/dist/classes/Spectrum.js +279 -0
- package/dist/classes/index.js +3 -0
- package/dist/index.js +2 -0
- package/dist/lib/index.js +2 -0
- package/dist/lib/types.d.ts +12 -0
- package/dist/lib/types.js +0 -0
- package/dist/lib/utils.d.ts +20 -0
- package/dist/lib/utils.js +38 -0
- package/package.json +5 -2
- package/benches/fraction-performance.bench.ts +0 -141
- package/benches/spectrum-serialization.bench.ts +0 -167
- package/classes/Harmonic.ts +0 -183
- package/classes/IntervalSet.ts +0 -361
- package/classes/README.md +0 -239
- package/classes/Spectrum.ts +0 -333
- package/lib/types.ts +0 -13
- package/lib/utils.ts +0 -47
- package/tests/Harmonic.test.ts +0 -516
- package/tests/IntervalSet.test.ts +0 -728
- package/tests/Spectrum.test.ts +0 -710
- package/tsconfig.json +0 -26
- /package/{classes/index.ts → dist/classes/index.d.ts} +0 -0
- /package/{index.ts → dist/index.d.ts} +0 -0
- /package/{lib/index.ts → dist/lib/index.d.ts} +0 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import Fraction, {} from 'fraction.js';
|
|
2
|
+
import { Spectrum } from './Spectrum';
|
|
3
|
+
/**
|
|
4
|
+
* Represents a set of frequency ratios (intervals).
|
|
5
|
+
* Uses Map to ensure no duplicate ratios.
|
|
6
|
+
* Useful for building tuning systems, scales, and analyzing interval collections.
|
|
7
|
+
*/
|
|
8
|
+
export class IntervalSet {
|
|
9
|
+
ratios;
|
|
10
|
+
constructor(ratios) {
|
|
11
|
+
this.ratios = new Map();
|
|
12
|
+
if (!ratios)
|
|
13
|
+
return;
|
|
14
|
+
for (const r of ratios) {
|
|
15
|
+
this.add(r);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Add a ratio to the set.
|
|
20
|
+
* If ratio already exists, it replaces the existing one.
|
|
21
|
+
*/
|
|
22
|
+
add(ratio) {
|
|
23
|
+
const f = new Fraction(ratio);
|
|
24
|
+
this.ratios.set(f.toFraction(), f);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check if the set contains this ratio
|
|
29
|
+
*/
|
|
30
|
+
has(ratio) {
|
|
31
|
+
const key = new Fraction(ratio).toFraction();
|
|
32
|
+
return this.ratios.has(key);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Remove a ratio from the set
|
|
36
|
+
* @returns true if ratio was removed, false if it didn't exist
|
|
37
|
+
*/
|
|
38
|
+
delete(ratio) {
|
|
39
|
+
const key = new Fraction(ratio).toFraction();
|
|
40
|
+
this.ratios.delete(key);
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Get a ratio from the set
|
|
45
|
+
* @returns Fraction object or undefined if not found
|
|
46
|
+
*/
|
|
47
|
+
get(ratio) {
|
|
48
|
+
const key = new Fraction(ratio).toFraction();
|
|
49
|
+
return this.ratios.get(key);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Number of ratios in the set
|
|
53
|
+
*/
|
|
54
|
+
get size() {
|
|
55
|
+
return this.ratios.size;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Check if the set is empty
|
|
59
|
+
*/
|
|
60
|
+
isEmpty() {
|
|
61
|
+
return this.ratios.size === 0;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get all ratios as an array
|
|
65
|
+
*/
|
|
66
|
+
getRatios() {
|
|
67
|
+
return Array.from(this.ratios.values()).sort((a, b) => a.compare(b));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get the smallest ratio in the set
|
|
71
|
+
*/
|
|
72
|
+
min() {
|
|
73
|
+
return this.getRatios().at(0);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Get the largest ratio in the set
|
|
77
|
+
*/
|
|
78
|
+
max() {
|
|
79
|
+
return this.getRatios().at(-1);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Clear all ratios from the set
|
|
83
|
+
*/
|
|
84
|
+
clear() {
|
|
85
|
+
this.ratios.clear();
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Create a copy of this interval set
|
|
90
|
+
*/
|
|
91
|
+
clone() {
|
|
92
|
+
return new IntervalSet(Array.from(this.ratios.values()));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Iterate over all ratios
|
|
96
|
+
*/
|
|
97
|
+
forEach(callback) {
|
|
98
|
+
this.ratios.forEach(callback);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Convert to Spectrum with equal amplitudes
|
|
102
|
+
* @param fundamentalHz - Optional fundamental frequency in Hz
|
|
103
|
+
* @param amplitude - Amplitude for all harmonics (default 1)
|
|
104
|
+
*/
|
|
105
|
+
toSpectrum(amp) {
|
|
106
|
+
const spectrum = new Spectrum();
|
|
107
|
+
this.getRatios().forEach((ratio, index) => {
|
|
108
|
+
spectrum.add(ratio, amp(ratio, index));
|
|
109
|
+
});
|
|
110
|
+
return spectrum;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get ratios as array of strings
|
|
114
|
+
*/
|
|
115
|
+
toStrings() {
|
|
116
|
+
return this.getRatios().map(r => r.toFraction());
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get ratios as array of decimal numbers
|
|
120
|
+
*/
|
|
121
|
+
toNumbers() {
|
|
122
|
+
return this.getRatios().map(r => r.valueOf());
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Check if this set equals another set
|
|
126
|
+
*/
|
|
127
|
+
equals(other) {
|
|
128
|
+
if (this.size !== other.size)
|
|
129
|
+
return false;
|
|
130
|
+
for (const ratio of this.getRatios()) {
|
|
131
|
+
if (!other.has(ratio))
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Calculate GCD of all ratios
|
|
138
|
+
*/
|
|
139
|
+
getGCD() {
|
|
140
|
+
const ratios = this.getRatios();
|
|
141
|
+
if (ratios.length === 0)
|
|
142
|
+
return undefined;
|
|
143
|
+
let gcd = ratios[0];
|
|
144
|
+
for (let i = 1; i < ratios.length; i++) {
|
|
145
|
+
gcd = gcd.gcd(ratios[i]);
|
|
146
|
+
}
|
|
147
|
+
return gcd;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Convert an array of ratios to an array of intervals (differences between consecutive ratios).
|
|
151
|
+
* The first interval is always 1, and each subsequent interval is the current ratio divided by the previous ratio.
|
|
152
|
+
*
|
|
153
|
+
* @param ratios - Array of Fraction objects representing ratios
|
|
154
|
+
* @returns Array of Fraction objects representing intervals, starting with 1
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* // If ratios are [1, 5/4, 3/2, 2], intervals will be [1, 5/4, 6/5, 4/3]
|
|
158
|
+
* const ratios = [new Fraction(1), new Fraction(5, 4), new Fraction(3, 2), new Fraction(2)];
|
|
159
|
+
* const intervals = getSweepIntervals(ratios);
|
|
160
|
+
*/
|
|
161
|
+
toSweepIntervals() {
|
|
162
|
+
const ratios = this.getRatios();
|
|
163
|
+
const intervals = [];
|
|
164
|
+
if (ratios.length === 0)
|
|
165
|
+
return intervals;
|
|
166
|
+
intervals.push(new Fraction(1));
|
|
167
|
+
for (let i = 1; i < ratios.length; i++) {
|
|
168
|
+
intervals.push(ratios[i].div(ratios[i - 1]));
|
|
169
|
+
}
|
|
170
|
+
return intervals;
|
|
171
|
+
}
|
|
172
|
+
minMax(min, max) {
|
|
173
|
+
const minFraction = new Fraction(min);
|
|
174
|
+
const maxFraction = new Fraction(max);
|
|
175
|
+
if (minFraction.compare(maxFraction) > 0) {
|
|
176
|
+
throw new Error('min must be less than or equal to max');
|
|
177
|
+
}
|
|
178
|
+
for (const ratio of this.ratios.values()) {
|
|
179
|
+
if (ratio.compare(minFraction) < 0 || ratio.compare(maxFraction) > 0) {
|
|
180
|
+
this.ratios.delete(ratio.toFraction());
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return this;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Generate all unique ratios between min and max with denominators up to maxDenominator.
|
|
187
|
+
*
|
|
188
|
+
* This creates all possible fractions n/d where:
|
|
189
|
+
* - min <= n/d <= max
|
|
190
|
+
* - 1 <= d <= maxDenominator
|
|
191
|
+
* - 1 <= n
|
|
192
|
+
* - n and d are coprime (simplified fractions only)
|
|
193
|
+
*
|
|
194
|
+
* The Map in IntervalSet automatically handles deduplication of equivalent fractions.
|
|
195
|
+
*
|
|
196
|
+
* @param min - Minimum ratio (inclusive)
|
|
197
|
+
* @param max - Maximum ratio (inclusive)
|
|
198
|
+
* @param maxDenominator - Maximum denominator for generated fractions
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* // All simple ratios in one octave
|
|
202
|
+
* IntervalSetFactory.range(1, 2, 5)
|
|
203
|
+
* // Returns: 1/1, 6/5, 5/4, 4/3, 3/2, 8/5, 5/3, 2/1
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* // Fine divisions for analysis (100 steps per octave)
|
|
207
|
+
* IntervalSetFactory.range(1, 2, 100)
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* // Ratios across 1.5 octaves
|
|
211
|
+
* IntervalSetFactory.range(1, 3, 12)
|
|
212
|
+
*/
|
|
213
|
+
static range(min, max, maxDenominator) {
|
|
214
|
+
if (maxDenominator < 1) {
|
|
215
|
+
throw new Error('maxDenominator must be at least 1');
|
|
216
|
+
}
|
|
217
|
+
const minFraction = new Fraction(min);
|
|
218
|
+
const maxFraction = new Fraction(max);
|
|
219
|
+
if (minFraction.compare(maxFraction) > 0) {
|
|
220
|
+
throw new Error('min must be less than or equal to max');
|
|
221
|
+
}
|
|
222
|
+
const intervalSet = new IntervalSet();
|
|
223
|
+
for (let denominator = 1; denominator <= maxDenominator; denominator++) {
|
|
224
|
+
const minNumerator = Math.max(1, Math.ceil(minFraction.valueOf() * denominator));
|
|
225
|
+
const maxNumerator = Math.floor(maxFraction.valueOf() * denominator);
|
|
226
|
+
for (let numerator = minNumerator; numerator <= maxNumerator; numerator++) {
|
|
227
|
+
const fraction = new Fraction(numerator, denominator);
|
|
228
|
+
if (fraction.compare(minFraction) >= 0 && fraction.compare(maxFraction) <= 0) {
|
|
229
|
+
intervalSet.add(fraction);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return intervalSet;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Generate affinitive tuning intervals that contain all ratios between harmonics of two spectra.
|
|
237
|
+
* For each pair of harmonics (one from each spectrum), calculates the ratio between them.
|
|
238
|
+
* Useful for analyzing harmonic relationships and consonance between two sounds.
|
|
239
|
+
*
|
|
240
|
+
* @param spectrum1 - First spectrum
|
|
241
|
+
* @param spectrum2 - Second spectrum
|
|
242
|
+
* @returns IntervalSet containing all unique ratios between the spectra's harmonics
|
|
243
|
+
*/
|
|
244
|
+
static affinitive(context, complement) {
|
|
245
|
+
const intervalSet = new IntervalSet();
|
|
246
|
+
const harmonics1 = context.getHarmonics();
|
|
247
|
+
const harmonics2 = complement.getHarmonics();
|
|
248
|
+
// Calculate ratio between every pair of harmonics
|
|
249
|
+
for (const h1 of harmonics1) {
|
|
250
|
+
for (const h2 of harmonics2) {
|
|
251
|
+
const ratio = h2.frequency.div(h1.frequency);
|
|
252
|
+
intervalSet.add(ratio);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return intervalSet;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Add intermediate intervals between existing ratios to create a smooth curve.
|
|
259
|
+
* Uses logarithmic spacing to ensure perceptually uniform distribution.
|
|
260
|
+
* A gap of 1→2 gets the same density as 2→4 (both are octaves).
|
|
261
|
+
*
|
|
262
|
+
* @param maxGapCents - Maximum allowed gap in cents (e.g., 10 for 10-cent steps)
|
|
263
|
+
* 1200 cents = 1 octave, 100 cents ≈ 1 semitone
|
|
264
|
+
* @returns this (for chaining)
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* const intervals = new IntervalSet([1, 2, 4]);
|
|
268
|
+
* intervals.densify(100); // Max 100-cent (1 semitone) gaps
|
|
269
|
+
* // Adds same number of points between 1→2 as between 2→4
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* // For dissonance curves - logarithmic spacing
|
|
273
|
+
* const extrema = IntervalSet.affinitive(spectrum1, spectrum2);
|
|
274
|
+
* extrema.densify(10); // ~10-cent resolution for smooth curve
|
|
275
|
+
*/
|
|
276
|
+
densify(maxGapCents) {
|
|
277
|
+
if (maxGapCents <= 0) {
|
|
278
|
+
throw new Error('maxGapCents must be positive');
|
|
279
|
+
}
|
|
280
|
+
// Convert cents to ratio (1 cent = 2^(1/1200))
|
|
281
|
+
const maxGapRatio = Math.pow(2, maxGapCents / 1200);
|
|
282
|
+
// Keep adding intervals until all gaps are small enough
|
|
283
|
+
let needsMorePoints = true;
|
|
284
|
+
while (needsMorePoints) {
|
|
285
|
+
const sorted = this.getRatios();
|
|
286
|
+
needsMorePoints = false;
|
|
287
|
+
// Check each adjacent pair
|
|
288
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
289
|
+
const left = sorted[i];
|
|
290
|
+
const right = sorted[i + 1];
|
|
291
|
+
// Calculate logarithmic gap (ratio of ratios)
|
|
292
|
+
const gapRatio = right.div(left).valueOf();
|
|
293
|
+
// If gap is too large, add the geometric mean
|
|
294
|
+
if (gapRatio > maxGapRatio) {
|
|
295
|
+
// Geometric mean: sqrt(a * b) - the logarithmic midpoint
|
|
296
|
+
const geometricMean = Math.sqrt(left.valueOf() * right.valueOf());
|
|
297
|
+
this.add(geometricMean);
|
|
298
|
+
needsMorePoints = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return this;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import Fraction, { type FractionInput } from 'fraction.js';
|
|
2
|
+
import { Harmonic } from './Harmonic';
|
|
3
|
+
import { type HarmonicData, type SpectrumData } from '../lib';
|
|
4
|
+
/**
|
|
5
|
+
* Represents a spectrum as a collection of harmonics.
|
|
6
|
+
* Uses Map to ensure no duplicate frequencies.
|
|
7
|
+
* Key is the frequency as a string (e.g., "3/2") for exact matching.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // Empty spectrum
|
|
11
|
+
* const s1 = new Spectrum();
|
|
12
|
+
*
|
|
13
|
+
* // Add harmonics using various representations
|
|
14
|
+
* s1.add(new Harmonic(3, 0.5, 0));
|
|
15
|
+
* s1.add(2, 0.3, Math.PI);
|
|
16
|
+
* s1.add({ frequency: '440', amplitude: 0.2, phase: Math.PI / 2 });
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* // From SpectrumData
|
|
20
|
+
* const s2 = new Spectrum([
|
|
21
|
+
* { frequency: '110', amplitude: 0.5, phase: 0 },
|
|
22
|
+
* { frequency: '220', amplitude: 0.3, phase: Math.PI }
|
|
23
|
+
* ]);
|
|
24
|
+
*/
|
|
25
|
+
export declare class Spectrum {
|
|
26
|
+
private harmonics;
|
|
27
|
+
constructor(data?: SpectrumData);
|
|
28
|
+
/**
|
|
29
|
+
* Number of harmonics in spectrum
|
|
30
|
+
*/
|
|
31
|
+
get size(): number;
|
|
32
|
+
/**
|
|
33
|
+
* Add a harmonic to the spectrum.
|
|
34
|
+
* If harmonic with same frequency exists, replaces it.
|
|
35
|
+
*/
|
|
36
|
+
private addHarmonic;
|
|
37
|
+
/**
|
|
38
|
+
* Convenience method to add harmonic from various input types
|
|
39
|
+
*/
|
|
40
|
+
add(harmonic: Harmonic): this;
|
|
41
|
+
add(data: HarmonicData): this;
|
|
42
|
+
add(frequency: FractionInput, amplitude?: number, phase?: number): this;
|
|
43
|
+
/**
|
|
44
|
+
* Get harmonic by frequency (FractionInput) or Harmonic class
|
|
45
|
+
*/
|
|
46
|
+
get(harmonicOrFrequency: Harmonic | FractionInput): Harmonic | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Check if spectrum contains a harmonic with by frequency (FractionInput) or Harmonic class
|
|
49
|
+
*/
|
|
50
|
+
has(harmonicOrFrequency: Harmonic | FractionInput): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Remove harmonic by frequency (FractionInput) or Harmonic class
|
|
53
|
+
*/
|
|
54
|
+
remove(harmonicOrFrequency: Harmonic | FractionInput): this;
|
|
55
|
+
/**
|
|
56
|
+
* Get harmonics sorted by frequency (ascending)
|
|
57
|
+
*/
|
|
58
|
+
getHarmonics(): Harmonic[];
|
|
59
|
+
/**
|
|
60
|
+
* Get all frequencies as an array
|
|
61
|
+
*/
|
|
62
|
+
getFrequenciesAsNumbers(): number[];
|
|
63
|
+
/**
|
|
64
|
+
* TODO: UNTESTED
|
|
65
|
+
* Get all keys (frequency strings) as an unsorted array.
|
|
66
|
+
* Useful for iterating over harmonics.
|
|
67
|
+
*/
|
|
68
|
+
getKeys(): string[];
|
|
69
|
+
/**
|
|
70
|
+
* Check if spectrum is empty
|
|
71
|
+
*/
|
|
72
|
+
isEmpty(): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Clear all harmonics
|
|
75
|
+
*/
|
|
76
|
+
clear(): this;
|
|
77
|
+
/**
|
|
78
|
+
* Method to transpose a single harmonic in place
|
|
79
|
+
* Removes the old harmonic and adds the transposed one
|
|
80
|
+
*/
|
|
81
|
+
transposeHarmonic(harmonicOrFrequency: Harmonic | FractionInput, ratio: FractionInput): this;
|
|
82
|
+
/**
|
|
83
|
+
* Transpose entire spectrum by a ratio in place
|
|
84
|
+
*/
|
|
85
|
+
transpose(ratio: FractionInput): this;
|
|
86
|
+
toTransposed(ratio: FractionInput): Spectrum;
|
|
87
|
+
/**
|
|
88
|
+
* Scale all amplitudes by a factor
|
|
89
|
+
*/
|
|
90
|
+
scaleAmplitudes(factor: number): this;
|
|
91
|
+
/**
|
|
92
|
+
* Get the lowest frequency harmonic
|
|
93
|
+
*/
|
|
94
|
+
getLowestHarmonic(): Harmonic | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* Get the highest frequency harmonic
|
|
97
|
+
*/
|
|
98
|
+
getHighestHarmonic(): Harmonic | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* Calculate GCD of all frequency ratios
|
|
101
|
+
*/
|
|
102
|
+
private getGCD;
|
|
103
|
+
/**
|
|
104
|
+
* Calculate period of the resulting wave
|
|
105
|
+
*/
|
|
106
|
+
getPeriod(): Fraction | undefined;
|
|
107
|
+
/**
|
|
108
|
+
* Create a copy of this spectrum
|
|
109
|
+
*/
|
|
110
|
+
clone(): Spectrum;
|
|
111
|
+
/**
|
|
112
|
+
* Iterate over harmonics
|
|
113
|
+
*/
|
|
114
|
+
forEach(callback: (harmonic: Harmonic, key: string) => void): void;
|
|
115
|
+
/**
|
|
116
|
+
* Serialize spectrum to JSON
|
|
117
|
+
*/
|
|
118
|
+
toJSON(): SpectrumData;
|
|
119
|
+
/**
|
|
120
|
+
* Create harmonic spectrum (110 Hz, 220 Hz, 330 Hz, 440 Hz, ...)
|
|
121
|
+
*/
|
|
122
|
+
static harmonic(count: number, fundamentalHz: FractionInput): Spectrum;
|
|
123
|
+
/**
|
|
124
|
+
* Create spectrum from arrays of arbitrary frequencies, amplitudes and phases
|
|
125
|
+
*/
|
|
126
|
+
static fromFrequencies(frequencies: FractionInput[], amplitudes?: number[], phases?: number[]): Spectrum;
|
|
127
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import Fraction, {} from 'fraction.js';
|
|
2
|
+
import { Harmonic } from './Harmonic';
|
|
3
|
+
import { isHarmonicData } from '../lib';
|
|
4
|
+
/**
|
|
5
|
+
* Represents a spectrum as a collection of harmonics.
|
|
6
|
+
* Uses Map to ensure no duplicate frequencies.
|
|
7
|
+
* Key is the frequency as a string (e.g., "3/2") for exact matching.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // Empty spectrum
|
|
11
|
+
* const s1 = new Spectrum();
|
|
12
|
+
*
|
|
13
|
+
* // Add harmonics using various representations
|
|
14
|
+
* s1.add(new Harmonic(3, 0.5, 0));
|
|
15
|
+
* s1.add(2, 0.3, Math.PI);
|
|
16
|
+
* s1.add({ frequency: '440', amplitude: 0.2, phase: Math.PI / 2 });
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* // From SpectrumData
|
|
20
|
+
* const s2 = new Spectrum([
|
|
21
|
+
* { frequency: '110', amplitude: 0.5, phase: 0 },
|
|
22
|
+
* { frequency: '220', amplitude: 0.3, phase: Math.PI }
|
|
23
|
+
* ]);
|
|
24
|
+
*/
|
|
25
|
+
export class Spectrum {
|
|
26
|
+
harmonics;
|
|
27
|
+
constructor(data) {
|
|
28
|
+
this.harmonics = new Map();
|
|
29
|
+
if (data) {
|
|
30
|
+
for (const harmonicData of data) {
|
|
31
|
+
const harmonic = new Harmonic(harmonicData);
|
|
32
|
+
this.addHarmonic(harmonic);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Number of harmonics in spectrum
|
|
38
|
+
*/
|
|
39
|
+
get size() {
|
|
40
|
+
return this.harmonics.size;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Add a harmonic to the spectrum.
|
|
44
|
+
* If harmonic with same frequency exists, replaces it.
|
|
45
|
+
*/
|
|
46
|
+
addHarmonic(harmonic) {
|
|
47
|
+
const key = harmonic.frequencyStr;
|
|
48
|
+
this.harmonics.set(key, harmonic);
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
add(harmonicOrDataOrFreq, amplitude, phase) {
|
|
52
|
+
if (harmonicOrDataOrFreq instanceof Harmonic) {
|
|
53
|
+
// Harmonic case
|
|
54
|
+
this.addHarmonic(harmonicOrDataOrFreq);
|
|
55
|
+
}
|
|
56
|
+
else if (isHarmonicData(harmonicOrDataOrFreq)) {
|
|
57
|
+
// HarmonicData case
|
|
58
|
+
const harmonic = new Harmonic(harmonicOrDataOrFreq);
|
|
59
|
+
this.addHarmonic(harmonic);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// FractionInput case: frequency, amplitude, phase
|
|
63
|
+
const harmonic = new Harmonic(harmonicOrDataOrFreq, amplitude ?? 1, phase ?? 0);
|
|
64
|
+
this.addHarmonic(harmonic);
|
|
65
|
+
}
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get harmonic by frequency (FractionInput) or Harmonic class
|
|
70
|
+
*/
|
|
71
|
+
get(harmonicOrFrequency) {
|
|
72
|
+
if (harmonicOrFrequency instanceof Harmonic) {
|
|
73
|
+
const key = harmonicOrFrequency.frequencyStr;
|
|
74
|
+
return this.harmonics.get(key);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const key = new Fraction(harmonicOrFrequency).toFraction();
|
|
78
|
+
return this.harmonics.get(key);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Check if spectrum contains a harmonic with by frequency (FractionInput) or Harmonic class
|
|
83
|
+
*/
|
|
84
|
+
has(harmonicOrFrequency) {
|
|
85
|
+
if (harmonicOrFrequency instanceof Harmonic) {
|
|
86
|
+
const key = harmonicOrFrequency.frequencyStr;
|
|
87
|
+
return this.harmonics.has(key);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const key = new Fraction(harmonicOrFrequency).toFraction();
|
|
91
|
+
return this.harmonics.has(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Remove harmonic by frequency (FractionInput) or Harmonic class
|
|
96
|
+
*/
|
|
97
|
+
remove(harmonicOrFrequency) {
|
|
98
|
+
if (harmonicOrFrequency instanceof Harmonic) {
|
|
99
|
+
const key = harmonicOrFrequency.frequencyStr;
|
|
100
|
+
this.harmonics.delete(key);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const key = new Fraction(harmonicOrFrequency).toFraction();
|
|
104
|
+
this.harmonics.delete(key);
|
|
105
|
+
}
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Get harmonics sorted by frequency (ascending)
|
|
110
|
+
*/
|
|
111
|
+
getHarmonics() {
|
|
112
|
+
return Array.from(this.harmonics.values()).sort((a, b) => a.compareFrequency(b));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Get all frequencies as an array
|
|
116
|
+
*/
|
|
117
|
+
getFrequenciesAsNumbers() {
|
|
118
|
+
return this.getHarmonics().map(h => h.frequencyNum);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* TODO: UNTESTED
|
|
122
|
+
* Get all keys (frequency strings) as an unsorted array.
|
|
123
|
+
* Useful for iterating over harmonics.
|
|
124
|
+
*/
|
|
125
|
+
getKeys() {
|
|
126
|
+
return Array.from(this.harmonics.keys());
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Check if spectrum is empty
|
|
130
|
+
*/
|
|
131
|
+
isEmpty() {
|
|
132
|
+
return this.size === 0;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Clear all harmonics
|
|
136
|
+
*/
|
|
137
|
+
clear() {
|
|
138
|
+
this.harmonics.clear();
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Method to transpose a single harmonic in place
|
|
143
|
+
* Removes the old harmonic and adds the transposed one
|
|
144
|
+
*/
|
|
145
|
+
transposeHarmonic(harmonicOrFrequency, ratio) {
|
|
146
|
+
const oldHarmonic = this.get(harmonicOrFrequency);
|
|
147
|
+
if (!oldHarmonic)
|
|
148
|
+
return this;
|
|
149
|
+
const oldKey = oldHarmonic.frequencyStr;
|
|
150
|
+
const newHarmonic = oldHarmonic.toTransposed(ratio);
|
|
151
|
+
const newKey = newHarmonic.frequencyStr;
|
|
152
|
+
if (oldKey === newKey)
|
|
153
|
+
return this;
|
|
154
|
+
this.harmonics.delete(oldKey);
|
|
155
|
+
this.harmonics.set(newKey, newHarmonic);
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Transpose entire spectrum by a ratio in place
|
|
160
|
+
*/
|
|
161
|
+
transpose(ratio) {
|
|
162
|
+
const r = new Fraction(ratio);
|
|
163
|
+
const ratioValue = r.valueOf();
|
|
164
|
+
if (ratioValue === 1)
|
|
165
|
+
return this;
|
|
166
|
+
if (ratioValue <= 0)
|
|
167
|
+
throw new Error('Ratio must be greater than 0');
|
|
168
|
+
const allHarmonics = this.getHarmonics();
|
|
169
|
+
// If transposing up, start from highest frequency and go down
|
|
170
|
+
if (ratioValue > 1) {
|
|
171
|
+
for (let i = allHarmonics.length - 1; i >= 0; i--) {
|
|
172
|
+
this.transposeHarmonic(allHarmonics[i], r);
|
|
173
|
+
}
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
for (let i = 0; i < allHarmonics.length; i++) {
|
|
177
|
+
this.transposeHarmonic(allHarmonics[i], r);
|
|
178
|
+
}
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
toTransposed(ratio) {
|
|
182
|
+
return this.clone().transpose(ratio);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Scale all amplitudes by a factor
|
|
186
|
+
*/
|
|
187
|
+
scaleAmplitudes(factor) {
|
|
188
|
+
for (const harmonic of this.harmonics.values()) {
|
|
189
|
+
harmonic.scale(factor);
|
|
190
|
+
}
|
|
191
|
+
return this;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Get the lowest frequency harmonic
|
|
195
|
+
*/
|
|
196
|
+
getLowestHarmonic() {
|
|
197
|
+
const sorted = this.getHarmonics();
|
|
198
|
+
return sorted[0];
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Get the highest frequency harmonic
|
|
202
|
+
*/
|
|
203
|
+
getHighestHarmonic() {
|
|
204
|
+
const sorted = this.getHarmonics();
|
|
205
|
+
return sorted[sorted.length - 1];
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Calculate GCD of all frequency ratios
|
|
209
|
+
*/
|
|
210
|
+
getGCD() {
|
|
211
|
+
const frequencies = this.getHarmonics().map(h => h.frequency);
|
|
212
|
+
if (frequencies.length === 0)
|
|
213
|
+
return undefined;
|
|
214
|
+
let gcd = frequencies[0];
|
|
215
|
+
for (let i = 1; i < frequencies.length; i++) {
|
|
216
|
+
const freq = frequencies[i];
|
|
217
|
+
if (freq)
|
|
218
|
+
gcd = gcd.gcd(freq);
|
|
219
|
+
}
|
|
220
|
+
return gcd;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Calculate period of the resulting wave
|
|
224
|
+
*/
|
|
225
|
+
getPeriod() {
|
|
226
|
+
const gcd = this.getGCD();
|
|
227
|
+
if (!gcd)
|
|
228
|
+
return undefined;
|
|
229
|
+
return gcd.inverse();
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Create a copy of this spectrum
|
|
233
|
+
*/
|
|
234
|
+
clone() {
|
|
235
|
+
const copy = new Spectrum();
|
|
236
|
+
for (const harmonic of this.harmonics.values()) {
|
|
237
|
+
copy.addHarmonic(harmonic.clone());
|
|
238
|
+
}
|
|
239
|
+
return copy;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Iterate over harmonics
|
|
243
|
+
*/
|
|
244
|
+
forEach(callback) {
|
|
245
|
+
this.harmonics.forEach(callback);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Serialize spectrum to JSON
|
|
249
|
+
*/
|
|
250
|
+
toJSON() {
|
|
251
|
+
const data = [];
|
|
252
|
+
for (const harmonic of this.harmonics.values()) {
|
|
253
|
+
data.push(harmonic.toJSON());
|
|
254
|
+
}
|
|
255
|
+
return data;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Create harmonic spectrum (110 Hz, 220 Hz, 330 Hz, 440 Hz, ...)
|
|
259
|
+
*/
|
|
260
|
+
static harmonic(count, fundamentalHz) {
|
|
261
|
+
const spectrum = new Spectrum();
|
|
262
|
+
for (let i = 1; i <= count; i++) {
|
|
263
|
+
spectrum.add(new Fraction(fundamentalHz).mul(i), 1 / i);
|
|
264
|
+
}
|
|
265
|
+
return spectrum;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Create spectrum from arrays of arbitrary frequencies, amplitudes and phases
|
|
269
|
+
*/
|
|
270
|
+
static fromFrequencies(frequencies, amplitudes, phases) {
|
|
271
|
+
const spectrum = new Spectrum();
|
|
272
|
+
frequencies.forEach((frequency, i) => {
|
|
273
|
+
const amp = amplitudes?.[i] ?? 1;
|
|
274
|
+
const phase = phases?.[i] ?? 0;
|
|
275
|
+
spectrum.add(frequency, amp, phase);
|
|
276
|
+
});
|
|
277
|
+
return spectrum;
|
|
278
|
+
}
|
|
279
|
+
}
|