tuning-core 0.1.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/LICENSE +21 -0
- package/README.md +15 -0
- package/benches/fraction-performance.bench.ts +141 -0
- package/benches/spectrum-serialization.bench.ts +167 -0
- package/classes/Harmonic.ts +183 -0
- package/classes/IntervalSet.ts +361 -0
- package/classes/README.md +239 -0
- package/classes/Spectrum.ts +333 -0
- package/classes/index.ts +3 -0
- package/index.ts +2 -0
- package/lib/index.ts +2 -0
- package/lib/types.ts +13 -0
- package/lib/utils.ts +47 -0
- package/package.json +21 -0
- package/tests/Harmonic.test.ts +516 -0
- package/tests/IntervalSet.test.ts +728 -0
- package/tests/Spectrum.test.ts +710 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import Fraction, { type FractionInput } from 'fraction.js';
|
|
2
|
+
import { Spectrum } from './Spectrum';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents a set of frequency ratios (intervals).
|
|
6
|
+
* Uses Map to ensure no duplicate ratios.
|
|
7
|
+
* Useful for building tuning systems, scales, and analyzing interval collections.
|
|
8
|
+
*/
|
|
9
|
+
export class IntervalSet {
|
|
10
|
+
private ratios: Map<string, Fraction>;
|
|
11
|
+
|
|
12
|
+
constructor(ratios?: FractionInput[]) {
|
|
13
|
+
this.ratios = new Map();
|
|
14
|
+
|
|
15
|
+
if (!ratios) return
|
|
16
|
+
|
|
17
|
+
for (const r of ratios) {
|
|
18
|
+
this.add(r);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Add a ratio to the set.
|
|
24
|
+
* If ratio already exists, it replaces the existing one.
|
|
25
|
+
*/
|
|
26
|
+
add(ratio: FractionInput): this {
|
|
27
|
+
const f = new Fraction(ratio);
|
|
28
|
+
this.ratios.set(f.toFraction(), f);
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if the set contains this ratio
|
|
34
|
+
*/
|
|
35
|
+
has(ratio: FractionInput): boolean {
|
|
36
|
+
const key = new Fraction(ratio).toFraction();
|
|
37
|
+
return this.ratios.has(key);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Remove a ratio from the set
|
|
42
|
+
* @returns true if ratio was removed, false if it didn't exist
|
|
43
|
+
*/
|
|
44
|
+
delete(ratio: FractionInput): this {
|
|
45
|
+
const key = new Fraction(ratio).toFraction();
|
|
46
|
+
this.ratios.delete(key);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get a ratio from the set
|
|
52
|
+
* @returns Fraction object or undefined if not found
|
|
53
|
+
*/
|
|
54
|
+
get(ratio: FractionInput): Fraction | undefined {
|
|
55
|
+
const key = new Fraction(ratio).toFraction();
|
|
56
|
+
return this.ratios.get(key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Number of ratios in the set
|
|
61
|
+
*/
|
|
62
|
+
get size(): number {
|
|
63
|
+
return this.ratios.size;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if the set is empty
|
|
68
|
+
*/
|
|
69
|
+
isEmpty(): boolean {
|
|
70
|
+
return this.ratios.size === 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get all ratios as an array
|
|
75
|
+
*/
|
|
76
|
+
getRatios(): Fraction[] {
|
|
77
|
+
return Array.from(this.ratios.values()).sort((a, b) => a.compare(b));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get the smallest ratio in the set
|
|
82
|
+
*/
|
|
83
|
+
min(): Fraction | undefined {
|
|
84
|
+
return this.getRatios().at(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the largest ratio in the set
|
|
89
|
+
*/
|
|
90
|
+
max(): Fraction | undefined {
|
|
91
|
+
return this.getRatios().at(-1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Clear all ratios from the set
|
|
96
|
+
*/
|
|
97
|
+
clear(): this {
|
|
98
|
+
this.ratios.clear();
|
|
99
|
+
return this
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create a copy of this interval set
|
|
104
|
+
*/
|
|
105
|
+
clone(): IntervalSet {
|
|
106
|
+
return new IntervalSet(Array.from(this.ratios.values()));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Iterate over all ratios
|
|
111
|
+
*/
|
|
112
|
+
forEach(callback: (ratio: Fraction, key: string) => void): void {
|
|
113
|
+
this.ratios.forEach(callback);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Convert to Spectrum with equal amplitudes
|
|
118
|
+
* @param fundamentalHz - Optional fundamental frequency in Hz
|
|
119
|
+
* @param amplitude - Amplitude for all harmonics (default 1)
|
|
120
|
+
*/
|
|
121
|
+
toSpectrum(amp: (ratio: Fraction, index: number) => number): Spectrum {
|
|
122
|
+
const spectrum = new Spectrum();
|
|
123
|
+
|
|
124
|
+
this.getRatios().forEach((ratio, index) => {
|
|
125
|
+
spectrum.add(ratio, amp(ratio, index));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return spectrum;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get ratios as array of strings
|
|
133
|
+
*/
|
|
134
|
+
toStrings(): string[] {
|
|
135
|
+
return this.getRatios().map(r => r.toFraction());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get ratios as array of decimal numbers
|
|
140
|
+
*/
|
|
141
|
+
toNumbers(): number[] {
|
|
142
|
+
return this.getRatios().map(r => r.valueOf());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check if this set equals another set
|
|
147
|
+
*/
|
|
148
|
+
equals(other: IntervalSet): boolean {
|
|
149
|
+
if (this.size !== other.size) return false;
|
|
150
|
+
|
|
151
|
+
for (const ratio of this.getRatios()) {
|
|
152
|
+
if (!other.has(ratio)) return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Calculate GCD of all ratios
|
|
160
|
+
*/
|
|
161
|
+
private getGCD(): Fraction | undefined {
|
|
162
|
+
const ratios = this.getRatios();
|
|
163
|
+
|
|
164
|
+
if (ratios.length === 0) return undefined;
|
|
165
|
+
|
|
166
|
+
let gcd = ratios[0]!;
|
|
167
|
+
for (let i = 1; i < ratios.length; i++) {
|
|
168
|
+
gcd = gcd.gcd(ratios[i]!);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return gcd;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Convert an array of ratios to an array of intervals (differences between consecutive ratios).
|
|
176
|
+
* The first interval is always 1, and each subsequent interval is the current ratio divided by the previous ratio.
|
|
177
|
+
*
|
|
178
|
+
* @param ratios - Array of Fraction objects representing ratios
|
|
179
|
+
* @returns Array of Fraction objects representing intervals, starting with 1
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* // If ratios are [1, 5/4, 3/2, 2], intervals will be [1, 5/4, 6/5, 4/3]
|
|
183
|
+
* const ratios = [new Fraction(1), new Fraction(5, 4), new Fraction(3, 2), new Fraction(2)];
|
|
184
|
+
* const intervals = getSweepIntervals(ratios);
|
|
185
|
+
*/
|
|
186
|
+
toSweepIntervals(): Fraction[] {
|
|
187
|
+
const ratios = this.getRatios();
|
|
188
|
+
const intervals: Fraction[] = [];
|
|
189
|
+
|
|
190
|
+
if (ratios.length === 0) return intervals
|
|
191
|
+
|
|
192
|
+
intervals.push(new Fraction(1));
|
|
193
|
+
|
|
194
|
+
for (let i = 1; i < ratios.length; i++) {
|
|
195
|
+
intervals.push(ratios[i]!.div(ratios[i - 1]!));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return intervals;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
minMax(min: FractionInput, max: FractionInput): this {
|
|
202
|
+
const minFraction = new Fraction(min);
|
|
203
|
+
const maxFraction = new Fraction(max);
|
|
204
|
+
|
|
205
|
+
if (minFraction.compare(maxFraction) > 0) {
|
|
206
|
+
throw new Error('min must be less than or equal to max');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const ratio of this.ratios.values()) {
|
|
210
|
+
if (ratio.compare(minFraction) < 0 || ratio.compare(maxFraction) > 0) {
|
|
211
|
+
this.ratios.delete(ratio.toFraction());
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return this;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Generate all unique ratios between min and max with denominators up to maxDenominator.
|
|
220
|
+
*
|
|
221
|
+
* This creates all possible fractions n/d where:
|
|
222
|
+
* - min <= n/d <= max
|
|
223
|
+
* - 1 <= d <= maxDenominator
|
|
224
|
+
* - 1 <= n
|
|
225
|
+
* - n and d are coprime (simplified fractions only)
|
|
226
|
+
*
|
|
227
|
+
* The Map in IntervalSet automatically handles deduplication of equivalent fractions.
|
|
228
|
+
*
|
|
229
|
+
* @param min - Minimum ratio (inclusive)
|
|
230
|
+
* @param max - Maximum ratio (inclusive)
|
|
231
|
+
* @param maxDenominator - Maximum denominator for generated fractions
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* // All simple ratios in one octave
|
|
235
|
+
* IntervalSetFactory.range(1, 2, 5)
|
|
236
|
+
* // Returns: 1/1, 6/5, 5/4, 4/3, 3/2, 8/5, 5/3, 2/1
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* // Fine divisions for analysis (100 steps per octave)
|
|
240
|
+
* IntervalSetFactory.range(1, 2, 100)
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* // Ratios across 1.5 octaves
|
|
244
|
+
* IntervalSetFactory.range(1, 3, 12)
|
|
245
|
+
*/
|
|
246
|
+
static range(
|
|
247
|
+
min: FractionInput,
|
|
248
|
+
max: FractionInput,
|
|
249
|
+
maxDenominator: number
|
|
250
|
+
): IntervalSet {
|
|
251
|
+
if (maxDenominator < 1) {
|
|
252
|
+
throw new Error('maxDenominator must be at least 1');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const minFraction = new Fraction(min);
|
|
256
|
+
const maxFraction = new Fraction(max);
|
|
257
|
+
|
|
258
|
+
if (minFraction.compare(maxFraction) > 0) {
|
|
259
|
+
throw new Error('min must be less than or equal to max');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const intervalSet = new IntervalSet();
|
|
263
|
+
|
|
264
|
+
for (let denominator = 1; denominator <= maxDenominator; denominator++) {
|
|
265
|
+
const minNumerator = Math.max(1, Math.ceil(minFraction.valueOf() * denominator));
|
|
266
|
+
const maxNumerator = Math.floor(maxFraction.valueOf() * denominator);
|
|
267
|
+
|
|
268
|
+
for (let numerator = minNumerator; numerator <= maxNumerator; numerator++) {
|
|
269
|
+
const fraction = new Fraction(numerator, denominator);
|
|
270
|
+
|
|
271
|
+
if (fraction.compare(minFraction) >= 0 && fraction.compare(maxFraction) <= 0) {
|
|
272
|
+
intervalSet.add(fraction);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return intervalSet;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Generate affinitive tuning intervals that contain all ratios between harmonics of two spectra.
|
|
282
|
+
* For each pair of harmonics (one from each spectrum), calculates the ratio between them.
|
|
283
|
+
* Useful for analyzing harmonic relationships and consonance between two sounds.
|
|
284
|
+
*
|
|
285
|
+
* @param spectrum1 - First spectrum
|
|
286
|
+
* @param spectrum2 - Second spectrum
|
|
287
|
+
* @returns IntervalSet containing all unique ratios between the spectra's harmonics
|
|
288
|
+
*/
|
|
289
|
+
static affinitive(context: Spectrum, complement: Spectrum): IntervalSet {
|
|
290
|
+
const intervalSet = new IntervalSet();
|
|
291
|
+
|
|
292
|
+
const harmonics1 = context.getHarmonics();
|
|
293
|
+
const harmonics2 = complement.getHarmonics();
|
|
294
|
+
|
|
295
|
+
// Calculate ratio between every pair of harmonics
|
|
296
|
+
for (const h1 of harmonics1) {
|
|
297
|
+
for (const h2 of harmonics2) {
|
|
298
|
+
const ratio = h2.frequency.div(h1.frequency);
|
|
299
|
+
intervalSet.add(ratio);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return intervalSet;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Add intermediate intervals between existing ratios to create a smooth curve.
|
|
308
|
+
* Uses logarithmic spacing to ensure perceptually uniform distribution.
|
|
309
|
+
* A gap of 1→2 gets the same density as 2→4 (both are octaves).
|
|
310
|
+
*
|
|
311
|
+
* @param maxGapCents - Maximum allowed gap in cents (e.g., 10 for 10-cent steps)
|
|
312
|
+
* 1200 cents = 1 octave, 100 cents ≈ 1 semitone
|
|
313
|
+
* @returns this (for chaining)
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* const intervals = new IntervalSet([1, 2, 4]);
|
|
317
|
+
* intervals.densify(100); // Max 100-cent (1 semitone) gaps
|
|
318
|
+
* // Adds same number of points between 1→2 as between 2→4
|
|
319
|
+
*
|
|
320
|
+
* @example
|
|
321
|
+
* // For dissonance curves - logarithmic spacing
|
|
322
|
+
* const extrema = IntervalSet.affinitive(spectrum1, spectrum2);
|
|
323
|
+
* extrema.densify(10); // ~10-cent resolution for smooth curve
|
|
324
|
+
*/
|
|
325
|
+
densify(maxGapCents: number): this {
|
|
326
|
+
if (maxGapCents <= 0) {
|
|
327
|
+
throw new Error('maxGapCents must be positive');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Convert cents to ratio (1 cent = 2^(1/1200))
|
|
331
|
+
const maxGapRatio = Math.pow(2, maxGapCents / 1200);
|
|
332
|
+
|
|
333
|
+
// Keep adding intervals until all gaps are small enough
|
|
334
|
+
let needsMorePoints = true;
|
|
335
|
+
|
|
336
|
+
while (needsMorePoints) {
|
|
337
|
+
const sorted = this.getRatios();
|
|
338
|
+
needsMorePoints = false;
|
|
339
|
+
|
|
340
|
+
// Check each adjacent pair
|
|
341
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
342
|
+
const left = sorted[i]!;
|
|
343
|
+
const right = sorted[i + 1]!;
|
|
344
|
+
|
|
345
|
+
// Calculate logarithmic gap (ratio of ratios)
|
|
346
|
+
const gapRatio = right.div(left).valueOf();
|
|
347
|
+
|
|
348
|
+
// If gap is too large, add the geometric mean
|
|
349
|
+
if (gapRatio > maxGapRatio) {
|
|
350
|
+
// Geometric mean: sqrt(a * b) - the logarithmic midpoint
|
|
351
|
+
const geometricMean = Math.sqrt(left.valueOf() * right.valueOf());
|
|
352
|
+
|
|
353
|
+
this.add(geometricMean);
|
|
354
|
+
needsMorePoints = true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return this;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# New Tonality Tuning Core
|
|
2
|
+
|
|
3
|
+
A set of primitive classes for representing harmonics, spectrum, and frequency ratios using exact rational number arithmetic. These classes are used across the New Tonality project ecosystem, including:
|
|
4
|
+
|
|
5
|
+
- **[new-tonality-website-v3](https://github.com/new-tonality-project/new-tonality-website-v3)** - Main website and web tools
|
|
6
|
+
- **[sethares-dissonance](https://github.com/new-tonality-project/sethares-dissonance)** - Dissonance curve calculations
|
|
7
|
+
- **[set-consonance](https://github.com/new-tonality-project/set-consonance)** - Set-theorietic consonance analysis tools
|
|
8
|
+
|
|
9
|
+
The primitives provide a foundation for precise tuning mathematics, avoiding floating-point errors through the use of `Fraction.js` for exact rational number representation.
|
|
10
|
+
|
|
11
|
+
## Contributing
|
|
12
|
+
|
|
13
|
+
Found a bug or have a feature request? Please don't hesitate to reach out!
|
|
14
|
+
|
|
15
|
+
- **GitHub Issues**: Report bugs or request features by opening an issue on the [GitHub repository](https://github.com/new-tonality-project/tuning-core)
|
|
16
|
+
- **Email**: Send feedback directly to [support@newtonality.net](mailto:support@newtonality.net)
|
|
17
|
+
|
|
18
|
+
Your feedback helps improve the New Tonality Tuning Core for everyone!
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Usage Examples
|
|
22
|
+
|
|
23
|
+
### Frequency and Interval Representation
|
|
24
|
+
|
|
25
|
+
All frequencies and intervals are handled as (Fraction.js)[https://www.npmjs.com/package/fraction.js?activeTab=readme] objects internally, which allows for exact representation of rational numbers. This is crucial for precise tuning mathematics and avoids floating-point errors. However that may lead to performance issues due to conversion of very percise floating point numbers like 1.0001230576.
|
|
26
|
+
|
|
27
|
+
You can provide frequencies and intervals using any of the following `FractionInput` types,
|
|
28
|
+
it will automatically be converted to Fraction object interanlly.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
new Harmonic(100.25) // 100.25 Hz
|
|
32
|
+
new Harmonic("100.25") // 100.25 Hz
|
|
33
|
+
new Harmonic("100 1/4") // 100.25 Hz
|
|
34
|
+
new Harmonic(new Fraction(100.25)) // 100.25 Hz
|
|
35
|
+
|
|
36
|
+
// working with intervals:
|
|
37
|
+
|
|
38
|
+
const h = new Harmonic(100)
|
|
39
|
+
|
|
40
|
+
const perfectFifth = h.toTransposed("3/2") // 150 Hz
|
|
41
|
+
const perfectFourth = h.toTransposed([4, 3]) // 133.33.. Hz
|
|
42
|
+
const majorThird = h.toTransposed({ n: 5, d: 4 }) // 125 Hz
|
|
43
|
+
const perfectOctave = h.toTransposed(2) // 200 Hz
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If you already have a Fraction object, it is also a valid input:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import Fraction from 'fraction.js';
|
|
50
|
+
|
|
51
|
+
const f = new Fraction("4/3")
|
|
52
|
+
const h = new Harmonic(440).transpose(f) // OK
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Note**: When using string fractions (e.g., `"3/2"`), the fraction is automatically simplified. For example, `"6/4"` becomes `"3/2"`.
|
|
56
|
+
|
|
57
|
+
### Harmonic class
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { Harmonic } from 'tuning-core';
|
|
61
|
+
|
|
62
|
+
// Create harmonic with individual parameters
|
|
63
|
+
// Frequencies represent partials (e.g., from harmonic series: 110, 220, 330 Hz)
|
|
64
|
+
const h1 = new Harmonic(220, 0.5, 0); // 220 Hz, amplitude 0.5
|
|
65
|
+
const h2 = new Harmonic("330", 0.8); // 330 Hz, phase defaults to 0
|
|
66
|
+
const h3 = new Harmonic("110 1/4"); // 110.25 Hz, amplitude defaults to 1
|
|
67
|
+
|
|
68
|
+
// Access properties (read-only)
|
|
69
|
+
console.log(h1.frequency); // Fraction obj
|
|
70
|
+
console.log(h1.frequencyStr); // "220"
|
|
71
|
+
console.log(h1.frequencyNum); // 220
|
|
72
|
+
console.log(h1.amplitude); // 0.5
|
|
73
|
+
console.log(h1.phase); // 0
|
|
74
|
+
|
|
75
|
+
// Modify harmonic (methods can be chained)
|
|
76
|
+
h1.setAmplitude(0.7).setPhase(Math.PI);
|
|
77
|
+
h1.scale(0.5); // Scale amplitude by 0.5
|
|
78
|
+
|
|
79
|
+
// Transpose using musical intervals (ratios) in place
|
|
80
|
+
h1.transpose('3/2'); // Transpose by perfect fifth (multiply frequency by 3/2)
|
|
81
|
+
h1.transpose('4/3'); // Transpose by perfect fourth (multiply frequency by 4/3)
|
|
82
|
+
|
|
83
|
+
// Create several harmonics using transposition
|
|
84
|
+
const h2 = h1.toTransposed("3/2") // perfect fifth
|
|
85
|
+
const copy = h1.clone()
|
|
86
|
+
|
|
87
|
+
// Serialization
|
|
88
|
+
const h = new Harmonic(100, 0.5, Math.PI)
|
|
89
|
+
const harmonicData: HarmonicData = h.toJSON()
|
|
90
|
+
console.log(harmonicData)
|
|
91
|
+
// Output: { frequency: '100', amplitude: 0.5, phase: 3.141592653589793 }
|
|
92
|
+
|
|
93
|
+
// serialized data can be used to counstruct a new Harmonic
|
|
94
|
+
const deserializedharmonic = new Harmonic(harmonicData)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Spectrum class
|
|
98
|
+
|
|
99
|
+
Spectrum is a collection of individual Harmonics. Under the hood it is a Map with keys that are frequency strings, that is done to avoid duplicates. Adding new harmonic with existing frequency will override the existing harmonic.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { Spectrum } from 'tuning-core';
|
|
103
|
+
|
|
104
|
+
// Create empty spectrum
|
|
105
|
+
const s1 = new Spectrum();
|
|
106
|
+
|
|
107
|
+
// Add harmonics using various methods
|
|
108
|
+
// Frequencies represent partials (e.g., from harmonic series: 110, 220, 330 Hz)
|
|
109
|
+
s1.add(110, 1.0); // Add by frequency (Hz)
|
|
110
|
+
s1.add(220, 0.5, Math.PI); // Add with phase
|
|
111
|
+
s1.add(new Harmonic(330, 0.33)); // Add Harmonic object
|
|
112
|
+
s1.add({ frequency: '440', amplitude: 0.6, phase: 0 }); // Add HarmonicData
|
|
113
|
+
|
|
114
|
+
// Query spectrum
|
|
115
|
+
s1.has(220); // Check if harmonic exists
|
|
116
|
+
s1.get(220); // Get harmonic (returns Harmonic | undefined)
|
|
117
|
+
s1.size; // Number of harmonics
|
|
118
|
+
s1.isEmpty(); // Check if empty
|
|
119
|
+
|
|
120
|
+
// Modify spectrum
|
|
121
|
+
s1.remove(220); // Remove harmonic
|
|
122
|
+
s1.scaleAmplitudes(0.5); // Scale all amplitudes
|
|
123
|
+
s1.clear(); // Remove all harmonics
|
|
124
|
+
s1.transposeHarmonic(220, "3/2"); // Transposes a single harmonic a fifth up
|
|
125
|
+
|
|
126
|
+
// Transpose using musical intervals (ratios)
|
|
127
|
+
s1.transpose('3/2'); // Transpose all harmonics by perfect fifth (mutable)
|
|
128
|
+
s1.transpose('4/3'); // Transpose all harmonics by perfect fourth
|
|
129
|
+
|
|
130
|
+
// Get harmonics
|
|
131
|
+
const harmonics = s1.getHarmonics(); // Returns sorted array
|
|
132
|
+
const lowest = s1.getLowestHarmonic();
|
|
133
|
+
const highest = s1.getHighestHarmonic();
|
|
134
|
+
|
|
135
|
+
// Clone and immutable operations
|
|
136
|
+
const s3 = s1.clone(); // Create independent copy
|
|
137
|
+
const s4 = s1.toTransposed('3/2'); // Create transposed copy (immutable) - perfect fifth
|
|
138
|
+
|
|
139
|
+
// Serialization
|
|
140
|
+
const spectrum = new Speacrum()
|
|
141
|
+
|
|
142
|
+
spectrum.add(110, 1, 0)
|
|
143
|
+
spectrum.add(220, 0.5, 1)
|
|
144
|
+
|
|
145
|
+
const spectrumData: SpectrumData = spectrum.toJSON()
|
|
146
|
+
console.log(spectrumData)
|
|
147
|
+
// output: [
|
|
148
|
+
// { frequency: 110, amplitude: 1, phase: 0 },
|
|
149
|
+
// { frequency: 220, amplitude: 0.5, phase: 1 }
|
|
150
|
+
// ]
|
|
151
|
+
|
|
152
|
+
// Deserialisation
|
|
153
|
+
const s2 = new Spectrum(spectrumData); // OK
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Spectrum static methods
|
|
157
|
+
|
|
158
|
+
A set of convenience factory methods to create common spectra such as harmonic series and others
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { Spectrum } from 'tuning-core';
|
|
162
|
+
|
|
163
|
+
// Create harmonic series (frequency ratios: 1, 2, 3, 4, ...)
|
|
164
|
+
// These represent the harmonic series relative to a fundamental
|
|
165
|
+
const harmonicSeries = Spectrum.harmonicSeries(8);
|
|
166
|
+
// Creates spectrum with ratios 1, 2, 3, 4, 5, 6, 7, 8
|
|
167
|
+
// Amplitudes follow natural decay: 1, 0.5, 0.333, 0.25, ...
|
|
168
|
+
|
|
169
|
+
// Create spectrum from absolute frequencies (Hz)
|
|
170
|
+
const spectrum = Spectrum.fromFrequencies(
|
|
171
|
+
[110, 220, 330, 440], // Frequencies in Hz (harmonic series)
|
|
172
|
+
[1.0, 0.5, 0.33, 0.25], // Amplitudes
|
|
173
|
+
[0, 0, 0, 0] // Phases
|
|
174
|
+
);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Performance Considerations
|
|
178
|
+
|
|
179
|
+
The performance of `Fraction.js` constructor varies significantly depending on the input type and ratio complexity. Benchmarks show different characteristics for integers vs. simple vs. complex ratios:
|
|
180
|
+
|
|
181
|
+
**Integers (e.g., `440`) measured for 100_000_000 iterations:**
|
|
182
|
+
| Input Type | Time per Operation | Relative Speed |
|
|
183
|
+
|------------|-------------------|----------------|
|
|
184
|
+
| **Float** `440` | 88 ns | 1.00x (fastest) |
|
|
185
|
+
| **Array** `[440, 1]` | 114 ns | 1.30x |
|
|
186
|
+
| **Object** `{n: 440, d: 1}` | 114 ns | 1.30x |
|
|
187
|
+
| **Fraction (copying)** | 143 ns | 1.63x |
|
|
188
|
+
| **String** `"440"` | 237 ns | 2.70x (slowest) |
|
|
189
|
+
|
|
190
|
+
**Simple Ratios (e.g., `3/2`) measured for 100_000_000 iterations:**
|
|
191
|
+
| Input Type | Time per Operation | Relative Speed |
|
|
192
|
+
|------------|-------------------|----------------|
|
|
193
|
+
| **Object** `{n: 3, d: 2}` | 131 ns | 1.00x (fastest) |
|
|
194
|
+
| **Array** `[3, 2]` | 132 ns | 1.01x |
|
|
195
|
+
| **Fraction (copying)** | 160 ns | 1.22x |
|
|
196
|
+
| **Float** `1.5` | 231 ns | 1.77x |
|
|
197
|
+
| **String** `"3/2"` | 321 ns | 2.45x (slowest) |
|
|
198
|
+
|
|
199
|
+
**Complex Ratios (e.g., `12345/6789`) measured for 10_000_000 iterations:**
|
|
200
|
+
| Input Type | Time per Operation | Relative Speed |
|
|
201
|
+
|------------|-------------------|----------------|
|
|
202
|
+
| **Fraction (copying)** | 279 ns | 1.00x (fastest) |
|
|
203
|
+
| **Object** `{n, d}` | 286 ns | 1.02x |
|
|
204
|
+
| **Array** `[n, d]` | 287 ns | 1.03x |
|
|
205
|
+
| **String** `"n/d"` | 506 ns | 1.81x |
|
|
206
|
+
| **Float** `1.818...` | 2,826 ns | **10.12x** (slowest) |
|
|
207
|
+
|
|
208
|
+
**Key Recommendations:**
|
|
209
|
+
|
|
210
|
+
1. **For integers, floats are fastest**: When working with whole numbers (e.g., `440` Hz), using a float/number directly is the fastest option (~1.3x faster than array/object). However, arrays/objects are still very fast and provide consistency.
|
|
211
|
+
|
|
212
|
+
2. **Use arrays or objects for ratios**: For both simple and complex ratios, array `[numerator, denominator]` or object `{n: numerator, d: denominator}` formats consistently provide the best performance. They are the fastest option for simple ratios and nearly as fast as copying for complex ratios.
|
|
213
|
+
|
|
214
|
+
3. **Avoid floats for complex intervals**: Float input becomes **~10x slower** for complex ratios because `Fraction.js` must convert the decimal to a rational approximation. For simple ratios, floats are better than strings but still slower than array/object.
|
|
215
|
+
|
|
216
|
+
4. **String parsing overhead**: String fractions add parsing overhead (~2.7x slower for integers, ~2.5x for simple ratios, ~1.8x for complex ratios). The overhead is not that great so they can be used for improved readability, but avoid them for performance-critical code.
|
|
217
|
+
|
|
218
|
+
5. **Reuse Fraction objects when possible**: Copying an existing `Fraction` object has comparable performance to construction from array or object
|
|
219
|
+
|
|
220
|
+
**Example - Performance Best Practices:**
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
// ✅ FASTEST FOR INTEGERS: Floats are fastest for whole numbers
|
|
224
|
+
const fastestInt = new Harmonic(440); // Float input is fastest for integers
|
|
225
|
+
|
|
226
|
+
// ✅ FASTEST FOR RATIOS: Use array/object for best performance
|
|
227
|
+
const fastest1 = new Harmonic(440).transpose([3, 2]); // Simple ratio
|
|
228
|
+
const fastest2 = new Harmonic(440).transpose({ n: 3, d: 2 }); // Simple ratio
|
|
229
|
+
const fastest3 = new Harmonic(440).transpose([12345, 6789]); // Complex ratio
|
|
230
|
+
|
|
231
|
+
// ✅ GOOD: String fractions for readability (acceptable performance)
|
|
232
|
+
const readable = new Harmonic(440).transpose("3/2"); // ~2.5x slower than array
|
|
233
|
+
|
|
234
|
+
// ⚠️ AVOID: Floats for complex ratios (very slow)
|
|
235
|
+
const slowComplex = new Harmonic(440).transpose(1.8181818181818182); // ~10x slower
|
|
236
|
+
|
|
237
|
+
// ⚠️ ACCEPTABLE: Floats for simple ratios (but array/object still faster)
|
|
238
|
+
const acceptable = new Harmonic(440).transpose(1.5); // ~1.8x slower than array
|
|
239
|
+
```
|