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,333 @@
|
|
|
1
|
+
import Fraction, { type FractionInput } from 'fraction.js';
|
|
2
|
+
import { Harmonic } from './Harmonic';
|
|
3
|
+
import { type HarmonicData, type SpectrumData, isHarmonicData } from '../lib';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Represents a spectrum as a collection of harmonics.
|
|
7
|
+
* Uses Map to ensure no duplicate frequencies.
|
|
8
|
+
* Key is the frequency as a string (e.g., "3/2") for exact matching.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* // Empty spectrum
|
|
12
|
+
* const s1 = new Spectrum();
|
|
13
|
+
*
|
|
14
|
+
* // Add harmonics using various representations
|
|
15
|
+
* s1.add(new Harmonic(3, 0.5, 0));
|
|
16
|
+
* s1.add(2, 0.3, Math.PI);
|
|
17
|
+
* s1.add({ frequency: '440', amplitude: 0.2, phase: Math.PI / 2 });
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* // From SpectrumData
|
|
21
|
+
* const s2 = new Spectrum([
|
|
22
|
+
* { frequency: '110', amplitude: 0.5, phase: 0 },
|
|
23
|
+
* { frequency: '220', amplitude: 0.3, phase: Math.PI }
|
|
24
|
+
* ]);
|
|
25
|
+
*/
|
|
26
|
+
export class Spectrum {
|
|
27
|
+
private harmonics: Map<string, Harmonic>;
|
|
28
|
+
|
|
29
|
+
constructor(data?: SpectrumData) {
|
|
30
|
+
this.harmonics = new Map();
|
|
31
|
+
|
|
32
|
+
if (data) {
|
|
33
|
+
for (const harmonicData of data) {
|
|
34
|
+
const harmonic = new Harmonic(harmonicData);
|
|
35
|
+
this.addHarmonic(harmonic);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Number of harmonics in spectrum
|
|
42
|
+
*/
|
|
43
|
+
get size(): number {
|
|
44
|
+
return this.harmonics.size;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Add a harmonic to the spectrum.
|
|
49
|
+
* If harmonic with same frequency exists, replaces it.
|
|
50
|
+
*/
|
|
51
|
+
private addHarmonic(harmonic: Harmonic): this {
|
|
52
|
+
const key = harmonic.frequencyStr;
|
|
53
|
+
this.harmonics.set(key, harmonic);
|
|
54
|
+
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Convenience method to add harmonic from various input types
|
|
60
|
+
*/
|
|
61
|
+
add(harmonic: Harmonic): this;
|
|
62
|
+
add(data: HarmonicData): this;
|
|
63
|
+
add(frequency: FractionInput, amplitude?: number, phase?: number): this;
|
|
64
|
+
add(harmonicOrDataOrFreq: Harmonic | HarmonicData | FractionInput, amplitude?: number, phase?: number): this {
|
|
65
|
+
if (harmonicOrDataOrFreq instanceof Harmonic) {
|
|
66
|
+
// Harmonic case
|
|
67
|
+
this.addHarmonic(harmonicOrDataOrFreq);
|
|
68
|
+
} else if (isHarmonicData(harmonicOrDataOrFreq)) {
|
|
69
|
+
// HarmonicData case
|
|
70
|
+
const harmonic = new Harmonic(harmonicOrDataOrFreq);
|
|
71
|
+
this.addHarmonic(harmonic);
|
|
72
|
+
} else {
|
|
73
|
+
// FractionInput case: frequency, amplitude, phase
|
|
74
|
+
const harmonic = new Harmonic(harmonicOrDataOrFreq as FractionInput, amplitude ?? 1, phase ?? 0);
|
|
75
|
+
this.addHarmonic(harmonic);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get harmonic by frequency (FractionInput) or Harmonic class
|
|
83
|
+
*/
|
|
84
|
+
get(harmonicOrFrequency: Harmonic | FractionInput): Harmonic | undefined {
|
|
85
|
+
if (harmonicOrFrequency instanceof Harmonic) {
|
|
86
|
+
const key = harmonicOrFrequency.frequencyStr;
|
|
87
|
+
return this.harmonics.get(key);
|
|
88
|
+
} else {
|
|
89
|
+
const key = new Fraction(harmonicOrFrequency).toFraction();
|
|
90
|
+
return this.harmonics.get(key);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if spectrum contains a harmonic with by frequency (FractionInput) or Harmonic class
|
|
96
|
+
*/
|
|
97
|
+
has(harmonicOrFrequency: Harmonic | FractionInput): boolean {
|
|
98
|
+
if (harmonicOrFrequency instanceof Harmonic) {
|
|
99
|
+
const key = harmonicOrFrequency.frequencyStr;
|
|
100
|
+
return this.harmonics.has(key);
|
|
101
|
+
} else {
|
|
102
|
+
const key = new Fraction(harmonicOrFrequency).toFraction();
|
|
103
|
+
return this.harmonics.has(key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Remove harmonic by frequency (FractionInput) or Harmonic class
|
|
109
|
+
*/
|
|
110
|
+
remove(harmonicOrFrequency: Harmonic | FractionInput): this {
|
|
111
|
+
if (harmonicOrFrequency instanceof Harmonic) {
|
|
112
|
+
const key = harmonicOrFrequency.frequencyStr;
|
|
113
|
+
this.harmonics.delete(key);
|
|
114
|
+
} else {
|
|
115
|
+
const key = new Fraction(harmonicOrFrequency).toFraction();
|
|
116
|
+
this.harmonics.delete(key);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get harmonics sorted by frequency (ascending)
|
|
124
|
+
*/
|
|
125
|
+
getHarmonics(): Harmonic[] {
|
|
126
|
+
return Array.from(this.harmonics.values()).sort((a, b) => a.compareFrequency(b));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get all frequencies as an array
|
|
131
|
+
*/
|
|
132
|
+
getFrequenciesAsNumbers(): number[] {
|
|
133
|
+
return this.getHarmonics().map(h => h.frequencyNum);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* TODO: UNTESTED
|
|
138
|
+
* Get all keys (frequency strings) as an unsorted array.
|
|
139
|
+
* Useful for iterating over harmonics.
|
|
140
|
+
*/
|
|
141
|
+
getKeys(): string[] {
|
|
142
|
+
return Array.from(this.harmonics.keys());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check if spectrum is empty
|
|
147
|
+
*/
|
|
148
|
+
isEmpty(): boolean {
|
|
149
|
+
return this.size === 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Clear all harmonics
|
|
154
|
+
*/
|
|
155
|
+
clear(): this {
|
|
156
|
+
this.harmonics.clear();
|
|
157
|
+
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Method to transpose a single harmonic in place
|
|
163
|
+
* Removes the old harmonic and adds the transposed one
|
|
164
|
+
*/
|
|
165
|
+
transposeHarmonic(harmonicOrFrequency: Harmonic | FractionInput, ratio: FractionInput): this {
|
|
166
|
+
const oldHarmonic = this.get(harmonicOrFrequency);
|
|
167
|
+
|
|
168
|
+
if (!oldHarmonic) return this;
|
|
169
|
+
|
|
170
|
+
const oldKey = oldHarmonic.frequencyStr;
|
|
171
|
+
const newHarmonic = oldHarmonic.toTransposed(ratio);
|
|
172
|
+
const newKey = newHarmonic.frequencyStr;
|
|
173
|
+
|
|
174
|
+
if (oldKey === newKey) return this;
|
|
175
|
+
|
|
176
|
+
this.harmonics.delete(oldKey);
|
|
177
|
+
this.harmonics.set(newKey, newHarmonic);
|
|
178
|
+
|
|
179
|
+
return this
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Transpose entire spectrum by a ratio in place
|
|
184
|
+
*/
|
|
185
|
+
transpose(ratio: FractionInput): this {
|
|
186
|
+
const r = new Fraction(ratio);
|
|
187
|
+
const ratioValue = r.valueOf();
|
|
188
|
+
|
|
189
|
+
if (ratioValue === 1) return this;
|
|
190
|
+
if (ratioValue <= 0) throw new Error('Ratio must be greater than 0')
|
|
191
|
+
|
|
192
|
+
const allHarmonics = this.getHarmonics();
|
|
193
|
+
|
|
194
|
+
// If transposing up, start from highest frequency and go down
|
|
195
|
+
if (ratioValue > 1) {
|
|
196
|
+
for (let i = allHarmonics.length - 1; i >= 0; i--) {
|
|
197
|
+
this.transposeHarmonic(allHarmonics[i]!, r);
|
|
198
|
+
}
|
|
199
|
+
return this;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (let i = 0; i < allHarmonics.length; i++) {
|
|
203
|
+
this.transposeHarmonic(allHarmonics[i]!, r);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return this;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
toTransposed(ratio: FractionInput): Spectrum {
|
|
210
|
+
return this.clone().transpose(ratio);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Scale all amplitudes by a factor
|
|
215
|
+
*/
|
|
216
|
+
scaleAmplitudes(factor: number): this {
|
|
217
|
+
for (const harmonic of this.harmonics.values()) {
|
|
218
|
+
harmonic.scale(factor);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return this
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get the lowest frequency harmonic
|
|
226
|
+
*/
|
|
227
|
+
getLowestHarmonic(): Harmonic | undefined {
|
|
228
|
+
const sorted = this.getHarmonics();
|
|
229
|
+
return sorted[0];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get the highest frequency harmonic
|
|
234
|
+
*/
|
|
235
|
+
getHighestHarmonic(): Harmonic | undefined {
|
|
236
|
+
const sorted = this.getHarmonics();
|
|
237
|
+
return sorted[sorted.length - 1];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Calculate GCD of all frequency ratios
|
|
242
|
+
*/
|
|
243
|
+
private getGCD(): Fraction | undefined {
|
|
244
|
+
const frequencies = this.getHarmonics().map(h => h.frequency);
|
|
245
|
+
|
|
246
|
+
if (frequencies.length === 0) return undefined;
|
|
247
|
+
|
|
248
|
+
let gcd = frequencies[0]!;
|
|
249
|
+
|
|
250
|
+
for (let i = 1; i < frequencies.length; i++) {
|
|
251
|
+
const freq = frequencies[i];
|
|
252
|
+
if (freq) gcd = gcd.gcd(freq);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return gcd;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Calculate period of the resulting wave
|
|
260
|
+
*/
|
|
261
|
+
getPeriod(): Fraction | undefined {
|
|
262
|
+
const gcd = this.getGCD();
|
|
263
|
+
|
|
264
|
+
if (!gcd) return undefined;
|
|
265
|
+
|
|
266
|
+
return gcd.inverse();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Create a copy of this spectrum
|
|
271
|
+
*/
|
|
272
|
+
clone(): Spectrum {
|
|
273
|
+
const copy = new Spectrum();
|
|
274
|
+
|
|
275
|
+
for (const harmonic of this.harmonics.values()) {
|
|
276
|
+
copy.addHarmonic(harmonic.clone());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return copy;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Iterate over harmonics
|
|
284
|
+
*/
|
|
285
|
+
forEach(callback: (harmonic: Harmonic, key: string) => void): void {
|
|
286
|
+
this.harmonics.forEach(callback);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Serialize spectrum to JSON
|
|
291
|
+
*/
|
|
292
|
+
toJSON(): SpectrumData {
|
|
293
|
+
const data: SpectrumData = [];
|
|
294
|
+
|
|
295
|
+
for (const harmonic of this.harmonics.values()) {
|
|
296
|
+
data.push(harmonic.toJSON());
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return data;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Create harmonic spectrum (110 Hz, 220 Hz, 330 Hz, 440 Hz, ...)
|
|
304
|
+
*/
|
|
305
|
+
static harmonic(count: number, fundamentalHz: FractionInput): Spectrum {
|
|
306
|
+
const spectrum = new Spectrum();
|
|
307
|
+
|
|
308
|
+
for (let i = 1; i <= count; i++) {
|
|
309
|
+
spectrum.add(new Fraction(fundamentalHz).mul(i), 1 / i);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return spectrum;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Create spectrum from arrays of arbitrary frequencies, amplitudes and phases
|
|
317
|
+
*/
|
|
318
|
+
static fromFrequencies(
|
|
319
|
+
frequencies: FractionInput[],
|
|
320
|
+
amplitudes?: number[],
|
|
321
|
+
phases?: number[],
|
|
322
|
+
): Spectrum {
|
|
323
|
+
const spectrum = new Spectrum();
|
|
324
|
+
|
|
325
|
+
frequencies.forEach((frequency, i) => {
|
|
326
|
+
const amp = amplitudes?.[i] ?? 1;
|
|
327
|
+
const phase = phases?.[i] ?? 0;
|
|
328
|
+
spectrum.add(frequency, amp, phase);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return spectrum;
|
|
332
|
+
}
|
|
333
|
+
}
|
package/classes/index.ts
ADDED
package/index.ts
ADDED
package/lib/index.ts
ADDED
package/lib/types.ts
ADDED
package/lib/utils.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Fraction, { type FractionInput } from 'fraction.js';
|
|
2
|
+
import type { HarmonicData } from './types';
|
|
3
|
+
|
|
4
|
+
function round(num: number, places: number = 0): number {
|
|
5
|
+
return Math.round(num * Math.pow(10, places)) / Math.pow(10, places);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Convert a frequency ratio to cents.
|
|
10
|
+
* @param ratio - The ratio as FractionInput (number, string, array, object, or Fraction)
|
|
11
|
+
* @param places - Number of decimal places to round to (default: 0, no decimals)
|
|
12
|
+
* @returns The ratio in cents (1200 * log2(ratio)), rounded to the specified number of decimals
|
|
13
|
+
*/
|
|
14
|
+
export function ratioToCents(ratio: FractionInput, places: number = 0): Fraction {
|
|
15
|
+
const val = new Fraction(ratio).valueOf();
|
|
16
|
+
const num = 1200 * Math.log2(val);
|
|
17
|
+
const rounded = round(num, places);
|
|
18
|
+
|
|
19
|
+
return new Fraction(rounded);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert cents to a frequency ratio.
|
|
24
|
+
* @param cents - The interval in cents as FractionInput (number, string, array, object, or Fraction)
|
|
25
|
+
* @param places - Number of decimal places to round to (default: 0, no decimals)
|
|
26
|
+
* @returns The ratio as a Fraction object (2^(cents/1200)), rounded to the specified number of decimals
|
|
27
|
+
*/
|
|
28
|
+
export function centsToRatio(cents: FractionInput, places: number = 0): Fraction {
|
|
29
|
+
const val = new Fraction(cents).valueOf();
|
|
30
|
+
const num = Math.pow(2, val / 1200);
|
|
31
|
+
const rounded = round(num, places);
|
|
32
|
+
|
|
33
|
+
return new Fraction(rounded);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Type guard to check if a value is HarmonicData
|
|
38
|
+
*/
|
|
39
|
+
export function isHarmonicData(value: unknown): value is HarmonicData {
|
|
40
|
+
return (
|
|
41
|
+
typeof value === 'object' &&
|
|
42
|
+
value !== null &&
|
|
43
|
+
'frequency' in value &&
|
|
44
|
+
'amplitude' in value &&
|
|
45
|
+
'phase' in value
|
|
46
|
+
);
|
|
47
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tuning-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"module": "index.ts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "npx tsc",
|
|
10
|
+
"publish": "npx tsc && bun publish --access public"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/bun": "latest"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"typescript": "^5"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"fraction.js": "^5.3.4"
|
|
20
|
+
}
|
|
21
|
+
}
|