@hraness/dawg 0.4.1 → 0.5.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/CHANGELOG.md +70 -0
- package/DAWG.md +293 -31
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +27 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +90 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +74 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +301 -26
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +709 -33
- package/src/render.ts +109 -7
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +635 -14
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/core/tuning.ts
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tuning systems: how a MIDI key becomes a frequency.
|
|
3
|
+
*
|
|
4
|
+
* A tuning table lists each degree above the root in cents, ending with the
|
|
5
|
+
* period (normally the 2/1 octave), the way a Scala `.scl` file does. A
|
|
6
|
+
* table comes from an n-EDO, a list of just ratios, a list of cents, a
|
|
7
|
+
* Scala file, or a named preset.
|
|
8
|
+
*
|
|
9
|
+
* Keys map onto the table one step per key from the root key, which is the
|
|
10
|
+
* linear default of Scala, Surge and Ableton Live 12, so a 19-EDO octave
|
|
11
|
+
* spans 19 keys. `map: "nearest"` instead gives each key the table pitch
|
|
12
|
+
* closest to its 12-TET pitch, so music written in 12-TET keeps its shape
|
|
13
|
+
* (a 12-TET melody played in 31-EDO sounds in meantone). Without a Scala
|
|
14
|
+
* keyboard mapping the root key sounds at its 12-TET frequency for the
|
|
15
|
+
* reference A4 (`ref`, 440 Hz by default), as the Surge tuning library's
|
|
16
|
+
* default mapping keeps middle C at 261.63 Hz; a `.kbm` mapping sets its
|
|
17
|
+
* own middle key, reference key and frequency.
|
|
18
|
+
*
|
|
19
|
+
* Pure and deterministic: no file or network access. Callers read `.scl`
|
|
20
|
+
* and `.kbm` files and pass their text to `parseScl` and `parseKbm`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { parseKey, SCALES, SCALE_NAMES, type ScaleInfo } from "./chords.ts";
|
|
24
|
+
import { midiToPitch } from "./pitch.ts";
|
|
25
|
+
|
|
26
|
+
export const TUNING_LIMITS = Object.freeze({
|
|
27
|
+
/** Degrees in one table (Scala files may hold more; 128 keys use fewer). */
|
|
28
|
+
maxSteps: 512,
|
|
29
|
+
maxEdo: 128,
|
|
30
|
+
/** |cents| of one table entry. */
|
|
31
|
+
maxCents: 9600,
|
|
32
|
+
/** Ratio numerators and denominators, as the Scala format requires. */
|
|
33
|
+
maxRatioTerm: 2_147_483_647,
|
|
34
|
+
minRefHz: 220,
|
|
35
|
+
maxRefHz: 880,
|
|
36
|
+
/** |cents| of a note's static offset. */
|
|
37
|
+
maxNoteCents: 1200,
|
|
38
|
+
maxNameLength: 64,
|
|
39
|
+
maxPathLength: 256,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export class TuningError extends Error {
|
|
43
|
+
constructor(message: string) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "TuningError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type TuningMap = "linear" | "nearest";
|
|
50
|
+
export const TUNING_MAPS: readonly TuningMap[] = Object.freeze([
|
|
51
|
+
"linear",
|
|
52
|
+
"nearest",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** A Scala keyboard mapping (`.kbm`), resolved. */
|
|
56
|
+
export type KeyMapping = Readonly<{
|
|
57
|
+
/** Keys in one repeating pattern; 0 maps every key to the next degree. */
|
|
58
|
+
size: number;
|
|
59
|
+
/** First and last MIDI keys retuned; keys outside keep 12-TET. */
|
|
60
|
+
first: number;
|
|
61
|
+
last: number;
|
|
62
|
+
/** Key the first mapping entry sits on. */
|
|
63
|
+
middle: number;
|
|
64
|
+
/** Key whose frequency is given. */
|
|
65
|
+
refKey: number;
|
|
66
|
+
refHz: number;
|
|
67
|
+
/** Degree whose interval separates two patterns; 0 means the period. */
|
|
68
|
+
octave: number;
|
|
69
|
+
/** Scale degree per pattern key; null leaves the key silent. */
|
|
70
|
+
map: readonly (number | null)[];
|
|
71
|
+
}>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A song or track tuning. One table source (`edo`, `ratios`, `cents`) or a
|
|
75
|
+
* library `name`; with a source, `name` is just a label. With neither, a
|
|
76
|
+
* track tuning keeps the song's table and changes only `ref` or `root`.
|
|
77
|
+
*/
|
|
78
|
+
export type Tuning = Readonly<{
|
|
79
|
+
name?: string;
|
|
80
|
+
edo?: number;
|
|
81
|
+
/** Ratios for degrees 1..n (`9/8`, `3/2`, `2/1`); the last is the period. */
|
|
82
|
+
ratios?: readonly string[];
|
|
83
|
+
/** Cents for degrees 1..n; the last is the period. */
|
|
84
|
+
cents?: readonly number[];
|
|
85
|
+
/** Project path of the `.scl` file the table was read from. */
|
|
86
|
+
scl?: string;
|
|
87
|
+
/** Project path of the `.kbm` file `keymap` was read from. */
|
|
88
|
+
kbm?: string;
|
|
89
|
+
keymap?: KeyMapping;
|
|
90
|
+
/**
|
|
91
|
+
* 12-TET A4 in Hz (440) that fixes the root: the root key sounds at its
|
|
92
|
+
* 12-TET frequency for this A4, so A4 itself sounds at `ref` only when the
|
|
93
|
+
* table keeps A at its 12-TET place (always when the root is an A).
|
|
94
|
+
*/
|
|
95
|
+
ref?: number;
|
|
96
|
+
/** MIDI key of degree 0 (the song key's tonic in octave 4, else C4). */
|
|
97
|
+
root?: number;
|
|
98
|
+
map?: TuningMap;
|
|
99
|
+
}>;
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Ratios and cents
|
|
103
|
+
|
|
104
|
+
/** Cents of a positive ratio. */
|
|
105
|
+
export function ratioCents(ratio: number): number {
|
|
106
|
+
return 1200 * Math.log2(ratio);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parses `3/2` or `2` (Scala ratio syntax) to a number, or undefined. */
|
|
110
|
+
export function parseRatio(text: string): number | undefined {
|
|
111
|
+
const match = text.trim().match(/^(\d+)(?:\/(\d+))?$/);
|
|
112
|
+
if (!match) return undefined;
|
|
113
|
+
const top = Number(match[1]);
|
|
114
|
+
const bottom = match[2] === undefined ? 1 : Number(match[2]);
|
|
115
|
+
if (
|
|
116
|
+
top < 1 ||
|
|
117
|
+
bottom < 1 ||
|
|
118
|
+
top > TUNING_LIMITS.maxRatioTerm ||
|
|
119
|
+
bottom > TUNING_LIMITS.maxRatioTerm
|
|
120
|
+
)
|
|
121
|
+
return undefined;
|
|
122
|
+
return top / bottom;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function edoCents(steps: number): number[] {
|
|
126
|
+
return Array.from(
|
|
127
|
+
{ length: steps },
|
|
128
|
+
(_, index) => ((index + 1) * 1200) / steps,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Library
|
|
134
|
+
|
|
135
|
+
export type TuningFamily =
|
|
136
|
+
| "equal"
|
|
137
|
+
| "just"
|
|
138
|
+
| "historical"
|
|
139
|
+
| "gamelan"
|
|
140
|
+
| "african"
|
|
141
|
+
| "maqam"
|
|
142
|
+
| "dastgah"
|
|
143
|
+
| "raga"
|
|
144
|
+
| "indian";
|
|
145
|
+
|
|
146
|
+
export type TuningPreset = Readonly<{
|
|
147
|
+
name: string;
|
|
148
|
+
family: TuningFamily;
|
|
149
|
+
about: string;
|
|
150
|
+
/** Degrees 1..n in cents; the last is the period. */
|
|
151
|
+
cents: readonly number[];
|
|
152
|
+
/** The just ratios behind `cents`, when the preset is just. */
|
|
153
|
+
ratios?: readonly string[];
|
|
154
|
+
/** Pitch class of the root when the preset fixes one. */
|
|
155
|
+
root?: number;
|
|
156
|
+
/** True when the values are typical rather than canonical. */
|
|
157
|
+
approximate?: boolean;
|
|
158
|
+
aliases?: readonly string[];
|
|
159
|
+
}>;
|
|
160
|
+
|
|
161
|
+
function fromRatios(ratios: readonly string[]): number[] {
|
|
162
|
+
return ratios.map((ratio) => ratioCents(parseRatio(ratio)!));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const PYTHAGOREAN = [
|
|
166
|
+
"2187/2048",
|
|
167
|
+
"9/8",
|
|
168
|
+
"32/27",
|
|
169
|
+
"81/64",
|
|
170
|
+
"4/3",
|
|
171
|
+
"729/512",
|
|
172
|
+
"3/2",
|
|
173
|
+
"6561/4096",
|
|
174
|
+
"27/16",
|
|
175
|
+
"16/9",
|
|
176
|
+
"243/128",
|
|
177
|
+
"2/1",
|
|
178
|
+
];
|
|
179
|
+
const JUST_5 = [
|
|
180
|
+
"16/15",
|
|
181
|
+
"9/8",
|
|
182
|
+
"6/5",
|
|
183
|
+
"5/4",
|
|
184
|
+
"4/3",
|
|
185
|
+
"45/32",
|
|
186
|
+
"3/2",
|
|
187
|
+
"8/5",
|
|
188
|
+
"5/3",
|
|
189
|
+
"9/5",
|
|
190
|
+
"15/8",
|
|
191
|
+
"2/1",
|
|
192
|
+
];
|
|
193
|
+
const JUST_7 = [
|
|
194
|
+
"16/15",
|
|
195
|
+
"9/8",
|
|
196
|
+
"7/6",
|
|
197
|
+
"5/4",
|
|
198
|
+
"4/3",
|
|
199
|
+
"7/5",
|
|
200
|
+
"3/2",
|
|
201
|
+
"8/5",
|
|
202
|
+
"5/3",
|
|
203
|
+
"7/4",
|
|
204
|
+
"15/8",
|
|
205
|
+
"2/1",
|
|
206
|
+
];
|
|
207
|
+
/** Kyle Gann's published key map of The Well-Tuned Piano, from E♭. */
|
|
208
|
+
const WELL_TUNED_PIANO = [
|
|
209
|
+
"567/512",
|
|
210
|
+
"9/8",
|
|
211
|
+
"147/128",
|
|
212
|
+
"21/16",
|
|
213
|
+
"1323/1024",
|
|
214
|
+
"189/128",
|
|
215
|
+
"3/2",
|
|
216
|
+
"49/32",
|
|
217
|
+
"7/4",
|
|
218
|
+
"441/256",
|
|
219
|
+
"63/32",
|
|
220
|
+
"2/1",
|
|
221
|
+
];
|
|
222
|
+
/** The twelve svaras in their common just ratios, from Sa. */
|
|
223
|
+
const HINDUSTANI = [
|
|
224
|
+
"16/15",
|
|
225
|
+
"9/8",
|
|
226
|
+
"6/5",
|
|
227
|
+
"5/4",
|
|
228
|
+
"4/3",
|
|
229
|
+
"45/32",
|
|
230
|
+
"3/2",
|
|
231
|
+
"8/5",
|
|
232
|
+
"5/3",
|
|
233
|
+
"16/9",
|
|
234
|
+
"15/8",
|
|
235
|
+
"2/1",
|
|
236
|
+
];
|
|
237
|
+
/** The 22 shrutis, from Sa. */
|
|
238
|
+
const SHRUTIS = [
|
|
239
|
+
"256/243",
|
|
240
|
+
"16/15",
|
|
241
|
+
"10/9",
|
|
242
|
+
"9/8",
|
|
243
|
+
"32/27",
|
|
244
|
+
"6/5",
|
|
245
|
+
"5/4",
|
|
246
|
+
"81/64",
|
|
247
|
+
"4/3",
|
|
248
|
+
"27/20",
|
|
249
|
+
"45/32",
|
|
250
|
+
"729/512",
|
|
251
|
+
"3/2",
|
|
252
|
+
"128/81",
|
|
253
|
+
"8/5",
|
|
254
|
+
"5/3",
|
|
255
|
+
"27/16",
|
|
256
|
+
"16/9",
|
|
257
|
+
"9/5",
|
|
258
|
+
"15/8",
|
|
259
|
+
"243/128",
|
|
260
|
+
"2/1",
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
const FIXED_PRESETS: readonly TuningPreset[] = [
|
|
264
|
+
{
|
|
265
|
+
name: "12-tet",
|
|
266
|
+
family: "equal",
|
|
267
|
+
about: "twelve-tone equal temperament, the default",
|
|
268
|
+
cents: edoCents(12),
|
|
269
|
+
aliases: ["12edo", "12-edo", "12tet", "12-et", "equal", "et", "standard"],
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "19-edo",
|
|
273
|
+
family: "equal",
|
|
274
|
+
about: "19 equal steps: meantone-like thirds, 19 keys per octave",
|
|
275
|
+
cents: edoCents(19),
|
|
276
|
+
aliases: ["19edo", "19-tet", "19tet"],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: "24-edo",
|
|
280
|
+
family: "equal",
|
|
281
|
+
about: "24 equal steps: quarter tones, 24 keys per octave",
|
|
282
|
+
cents: edoCents(24),
|
|
283
|
+
aliases: ["24edo", "24-tet", "24tet", "quarter-tone", "quarter-tones"],
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
name: "31-edo",
|
|
287
|
+
family: "equal",
|
|
288
|
+
about: "31 equal steps: near quarter-comma meantone, 31 keys per octave",
|
|
289
|
+
cents: edoCents(31),
|
|
290
|
+
aliases: ["31edo", "31-tet", "31tet"],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "pythagorean",
|
|
294
|
+
family: "historical",
|
|
295
|
+
about: "pure 3/2 fifths from E♭ to G♯ (the wolf between them)",
|
|
296
|
+
cents: fromRatios(PYTHAGOREAN),
|
|
297
|
+
ratios: PYTHAGOREAN,
|
|
298
|
+
aliases: ["pythag", "3-limit"],
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
name: "just",
|
|
302
|
+
family: "just",
|
|
303
|
+
about: "5-limit just intonation: pure 5/4 thirds and 3/2 fifths",
|
|
304
|
+
cents: fromRatios(JUST_5),
|
|
305
|
+
ratios: JUST_5,
|
|
306
|
+
aliases: ["ji", "5-limit", "just-intonation", "ptolemaic"],
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "7-limit",
|
|
310
|
+
family: "just",
|
|
311
|
+
about: "7-limit just intonation: septimal 7/6, 7/5 and 7/4",
|
|
312
|
+
cents: fromRatios(JUST_7),
|
|
313
|
+
ratios: JUST_7,
|
|
314
|
+
aliases: ["septimal", "7-limit-ji"],
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: "well-tuned-piano",
|
|
318
|
+
family: "just",
|
|
319
|
+
about:
|
|
320
|
+
"La Monte Young's Well-Tuned Piano key map (7-limit, from E♭, after Gann)",
|
|
321
|
+
cents: fromRatios(WELL_TUNED_PIANO),
|
|
322
|
+
ratios: WELL_TUNED_PIANO,
|
|
323
|
+
root: 3,
|
|
324
|
+
aliases: ["wtp", "young", "la-monte-young"],
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: "pelog",
|
|
328
|
+
family: "gamelan",
|
|
329
|
+
about:
|
|
330
|
+
"Javanese pelog, 7 notes (Kunst's average of 39 gamelans; every gamelan differs)",
|
|
331
|
+
cents: [120, 270, 540, 670, 785, 950, 1200],
|
|
332
|
+
approximate: true,
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: "slendro",
|
|
336
|
+
family: "gamelan",
|
|
337
|
+
about:
|
|
338
|
+
"Javanese slendro, 5 notes (Surjodiningrat's average of 30 gamelans; every gamelan differs)",
|
|
339
|
+
cents: [231, 474, 717, 955, 1200],
|
|
340
|
+
approximate: true,
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
name: "nyamaropa",
|
|
344
|
+
family: "african",
|
|
345
|
+
about:
|
|
346
|
+
"Shona mbira nyamaropa-style, 7 notes (John Kunaka's mbira after Berliner; every mbira differs)",
|
|
347
|
+
cents: [196, 377, 506, 676, 877, 1050, 1200],
|
|
348
|
+
approximate: true,
|
|
349
|
+
aliases: ["mbira"],
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: "hindustani",
|
|
353
|
+
family: "indian",
|
|
354
|
+
about: "the twelve svaras in common just ratios, from Sa",
|
|
355
|
+
cents: fromRatios(HINDUSTANI),
|
|
356
|
+
ratios: HINDUSTANI,
|
|
357
|
+
aliases: ["svara", "sargam"],
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: "shruti",
|
|
361
|
+
family: "indian",
|
|
362
|
+
about: "the 22 shrutis, 22 keys per octave",
|
|
363
|
+
cents: fromRatios(SHRUTIS),
|
|
364
|
+
ratios: SHRUTIS,
|
|
365
|
+
aliases: ["22-shruti", "shrutis", "sruti"],
|
|
366
|
+
},
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A twelve-key table that retunes a library scale's degrees: maqam and
|
|
371
|
+
* dastgah quarter tones lower the natural key a quarter tone (as Arabic
|
|
372
|
+
* keyboards' scale presets do); ragas apply their shruti intonation over
|
|
373
|
+
* the Hindustani just table.
|
|
374
|
+
*/
|
|
375
|
+
function scalePreset(name: string, info: ScaleInfo): TuningPreset | undefined {
|
|
376
|
+
const quarter = info.steps.some((step) => !Number.isInteger(step));
|
|
377
|
+
if (!quarter && !info.intonation) return undefined;
|
|
378
|
+
const raga = info.family === "raga";
|
|
379
|
+
const table = raga ? fromRatios(HINDUSTANI) : edoCents(12);
|
|
380
|
+
const keys = [0, ...table.slice(0, 11)];
|
|
381
|
+
info.steps.forEach((step, index) => {
|
|
382
|
+
const key = Math.ceil(step) % 12;
|
|
383
|
+
if (key === 0) return;
|
|
384
|
+
keys[key] = info.intonation?.[index] ?? step * 100;
|
|
385
|
+
});
|
|
386
|
+
return {
|
|
387
|
+
name,
|
|
388
|
+
family:
|
|
389
|
+
info.family === "raga"
|
|
390
|
+
? "raga"
|
|
391
|
+
: info.family === "dastgah"
|
|
392
|
+
? "dastgah"
|
|
393
|
+
: "maqam",
|
|
394
|
+
about: raga
|
|
395
|
+
? `raga ${name} with its shruti intonation over the Hindustani just table`
|
|
396
|
+
: `${info.family} ${name} quarter tones on the twelve keys (24-tone convention)`,
|
|
397
|
+
cents: [...keys.slice(1), 1200],
|
|
398
|
+
approximate: true,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const SCALE_PRESETS: readonly TuningPreset[] = SCALE_NAMES.flatMap((name) => {
|
|
403
|
+
const preset = scalePreset(name, SCALES[name]);
|
|
404
|
+
return preset ? [preset] : [];
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
export const TUNING_PRESETS: readonly TuningPreset[] = Object.freeze([
|
|
408
|
+
...FIXED_PRESETS,
|
|
409
|
+
...SCALE_PRESETS,
|
|
410
|
+
]);
|
|
411
|
+
export const TUNING_NAMES: readonly string[] = Object.freeze(
|
|
412
|
+
TUNING_PRESETS.map((preset) => preset.name),
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const PRESET_INDEX: ReadonlyMap<string, TuningPreset> = (() => {
|
|
416
|
+
const index = new Map<string, TuningPreset>();
|
|
417
|
+
for (const preset of TUNING_PRESETS) {
|
|
418
|
+
index.set(preset.name, preset);
|
|
419
|
+
for (const alias of preset.aliases ?? []) index.set(alias, preset);
|
|
420
|
+
}
|
|
421
|
+
return index;
|
|
422
|
+
})();
|
|
423
|
+
|
|
424
|
+
/** The library tuning named `text` (case, space and `-` insensitive). */
|
|
425
|
+
export function tuningPreset(text: string): TuningPreset | undefined {
|
|
426
|
+
const word = text
|
|
427
|
+
.trim()
|
|
428
|
+
.toLowerCase()
|
|
429
|
+
.replace(/[\s_]+/g, "-");
|
|
430
|
+
return PRESET_INDEX.get(word) ?? PRESET_INDEX.get(word.replace(/-/g, ""));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Scala files
|
|
435
|
+
|
|
436
|
+
export type ScalaScale = Readonly<{
|
|
437
|
+
description: string;
|
|
438
|
+
/** Degrees 1..n in cents. */
|
|
439
|
+
cents: readonly number[];
|
|
440
|
+
/** The degrees as ratios when every line is a ratio. */
|
|
441
|
+
ratios?: readonly string[];
|
|
442
|
+
}>;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Parses a Scala `.scl` file: `!` comment lines, a description line, the
|
|
446
|
+
* note count, then one pitch per line (a value with a period is cents,
|
|
447
|
+
* otherwise a ratio `a/b` or integer `a`; anything after the value is
|
|
448
|
+
* ignored; the 1/1 unison is implicit and the last pitch is the period).
|
|
449
|
+
*/
|
|
450
|
+
export function parseScl(text: string): ScalaScale {
|
|
451
|
+
const lines = text
|
|
452
|
+
.replace(/^/, "")
|
|
453
|
+
.split(/\r?\n/)
|
|
454
|
+
.filter((line) => !line.startsWith("!"));
|
|
455
|
+
if (lines.length === 0) throw new TuningError(".scl: missing description");
|
|
456
|
+
const description = lines[0]!.trim();
|
|
457
|
+
const rest = lines.slice(1).filter((line) => line.trim() !== "");
|
|
458
|
+
const countText = rest[0]?.trim().split(/\s+/)[0] ?? "";
|
|
459
|
+
if (!/^\d+$/.test(countText))
|
|
460
|
+
throw new TuningError(
|
|
461
|
+
".scl: the line after the description must be the note count",
|
|
462
|
+
);
|
|
463
|
+
const count = Number(countText);
|
|
464
|
+
if (count < 1)
|
|
465
|
+
throw new TuningError(
|
|
466
|
+
".scl: a scale needs at least one pitch (its period)",
|
|
467
|
+
);
|
|
468
|
+
if (count > TUNING_LIMITS.maxSteps)
|
|
469
|
+
throw new TuningError(`.scl: at most ${TUNING_LIMITS.maxSteps} notes`);
|
|
470
|
+
const pitchLines = rest.slice(1, 1 + count);
|
|
471
|
+
if (pitchLines.length < count)
|
|
472
|
+
throw new TuningError(
|
|
473
|
+
`.scl: expected ${count} pitches, found ${pitchLines.length}`,
|
|
474
|
+
);
|
|
475
|
+
const cents: number[] = [];
|
|
476
|
+
const ratios: string[] = [];
|
|
477
|
+
pitchLines.forEach((line, index) => {
|
|
478
|
+
const value = line.trim().split(/\s+/)[0] ?? "";
|
|
479
|
+
if (value.includes(".")) {
|
|
480
|
+
if (!/^-?(\d+\.\d*|\.\d+)$/.test(value))
|
|
481
|
+
throw new TuningError(
|
|
482
|
+
`.scl: pitch ${index + 1} "${value}" is not a cents value`,
|
|
483
|
+
);
|
|
484
|
+
cents.push(Number(value));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const ratio = parseRatio(value);
|
|
488
|
+
if (ratio === undefined)
|
|
489
|
+
throw new TuningError(
|
|
490
|
+
`.scl: pitch ${index + 1} "${value}" is not a positive ratio or cents value`,
|
|
491
|
+
);
|
|
492
|
+
cents.push(ratioCents(ratio));
|
|
493
|
+
ratios.push(value.includes("/") ? value : `${value}/1`);
|
|
494
|
+
});
|
|
495
|
+
checkTable(cents, ".scl");
|
|
496
|
+
return Object.freeze({
|
|
497
|
+
description,
|
|
498
|
+
cents: Object.freeze(cents),
|
|
499
|
+
...(ratios.length === count ? { ratios: Object.freeze(ratios) } : {}),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Parses a Scala `.kbm` keyboard mapping: map size, first and last keys
|
|
505
|
+
* to retune, middle key, reference key, its frequency, the formal-octave
|
|
506
|
+
* degree, then one degree (or `x` for an unmapped key) per pattern key;
|
|
507
|
+
* trailing unmapped keys may be left out.
|
|
508
|
+
*/
|
|
509
|
+
export function parseKbm(text: string): KeyMapping {
|
|
510
|
+
const values = text
|
|
511
|
+
.replace(/^/, "")
|
|
512
|
+
.split(/\r?\n/)
|
|
513
|
+
.filter((line) => !line.startsWith("!") && line.trim() !== "")
|
|
514
|
+
.map((line) => line.trim().split(/\s+/)[0]!);
|
|
515
|
+
const fields = [
|
|
516
|
+
"map size",
|
|
517
|
+
"first key",
|
|
518
|
+
"last key",
|
|
519
|
+
"middle key",
|
|
520
|
+
"reference key",
|
|
521
|
+
"reference frequency",
|
|
522
|
+
"formal octave degree",
|
|
523
|
+
];
|
|
524
|
+
if (values.length < fields.length)
|
|
525
|
+
throw new TuningError(`.kbm: missing the ${fields[values.length]}`);
|
|
526
|
+
const integer = (index: number, min: number, max: number): number => {
|
|
527
|
+
const value = values[index]!;
|
|
528
|
+
if (!/^\d+$/.test(value) || Number(value) < min || Number(value) > max)
|
|
529
|
+
throw new TuningError(
|
|
530
|
+
`.kbm: the ${fields[index]} must be an integer from ${min} to ${max}`,
|
|
531
|
+
);
|
|
532
|
+
return Number(value);
|
|
533
|
+
};
|
|
534
|
+
const size = integer(0, 0, TUNING_LIMITS.maxSteps);
|
|
535
|
+
const first = integer(1, 0, 127);
|
|
536
|
+
const last = integer(2, 0, 127);
|
|
537
|
+
const middle = integer(3, 0, 127);
|
|
538
|
+
const refKey = integer(4, 0, 127);
|
|
539
|
+
const refHz = Number(values[5]);
|
|
540
|
+
if (
|
|
541
|
+
!/^\d*\.?\d+$|^\d+\.$/.test(values[5]!) ||
|
|
542
|
+
!(refHz > 0) ||
|
|
543
|
+
refHz > 100_000
|
|
544
|
+
)
|
|
545
|
+
throw new TuningError(
|
|
546
|
+
".kbm: the reference frequency must be a positive number of Hz",
|
|
547
|
+
);
|
|
548
|
+
const octave = integer(6, 0, TUNING_LIMITS.maxSteps);
|
|
549
|
+
const entries = values.slice(7);
|
|
550
|
+
if (entries.length > size)
|
|
551
|
+
throw new TuningError(
|
|
552
|
+
`.kbm: ${entries.length} mapping entries for a map of size ${size}`,
|
|
553
|
+
);
|
|
554
|
+
const map = entries.map((entry, index) => {
|
|
555
|
+
if (entry === "x" || entry === "X") return null;
|
|
556
|
+
if (!/^\d+$/.test(entry))
|
|
557
|
+
throw new TuningError(
|
|
558
|
+
`.kbm: mapping entry ${index + 1} "${entry}" must be a degree or x`,
|
|
559
|
+
);
|
|
560
|
+
return Number(entry);
|
|
561
|
+
});
|
|
562
|
+
while (map.length < size) map.push(null);
|
|
563
|
+
return normalizeKeyMapping(
|
|
564
|
+
{ size, first, last, middle, refKey, refHz, octave, map },
|
|
565
|
+
".kbm",
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Fills a plain song or track tuning's `scl` table and `kbm` keymap from
|
|
571
|
+
* project files. `read` returns a file's text, or undefined when it is
|
|
572
|
+
* missing. A tuning without `scl` or `kbm` comes back unchanged.
|
|
573
|
+
*/
|
|
574
|
+
export function readTuningFiles(
|
|
575
|
+
input: unknown,
|
|
576
|
+
read: (path: string) => string | undefined,
|
|
577
|
+
where: string,
|
|
578
|
+
): unknown {
|
|
579
|
+
if (!isRecord(input)) return input;
|
|
580
|
+
const out: Record<string, unknown> = { ...input };
|
|
581
|
+
const load = (field: "scl" | "kbm"): string | undefined => {
|
|
582
|
+
if (input[field] === undefined || input[field] === null) return undefined;
|
|
583
|
+
const path = projectPath(input[field], `${where} ${field}`, `.${field}`);
|
|
584
|
+
const text = read(path);
|
|
585
|
+
if (text === undefined)
|
|
586
|
+
throw new TuningError(`${where} ${field} file ${path} not found`);
|
|
587
|
+
return text;
|
|
588
|
+
};
|
|
589
|
+
const scl = load("scl");
|
|
590
|
+
if (scl !== undefined) {
|
|
591
|
+
if (input.edo != null || input.ratios != null || input.cents != null)
|
|
592
|
+
throw new TuningError(
|
|
593
|
+
`${where}: scl is the table; drop edo, ratios and cents`,
|
|
594
|
+
);
|
|
595
|
+
const scale = withFile(String(input.scl), () => parseScl(scl));
|
|
596
|
+
if (scale.ratios) out.ratios = scale.ratios;
|
|
597
|
+
else out.cents = scale.cents;
|
|
598
|
+
}
|
|
599
|
+
const kbm = load("kbm");
|
|
600
|
+
if (kbm !== undefined)
|
|
601
|
+
out.keymap = withFile(String(input.kbm), () => parseKbm(kbm));
|
|
602
|
+
return out;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function withFile<T>(path: string, parse: () => T): T {
|
|
606
|
+
try {
|
|
607
|
+
return parse();
|
|
608
|
+
} catch (error) {
|
|
609
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
610
|
+
throw new TuningError(`${path}: ${message.replace(/^\.(scl|kbm): /, "")}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// Validation
|
|
616
|
+
|
|
617
|
+
function checkTable(cents: readonly number[], where: string): void {
|
|
618
|
+
if (cents.length < 1 || cents.length > TUNING_LIMITS.maxSteps)
|
|
619
|
+
throw new TuningError(
|
|
620
|
+
`${where} needs 1 to ${TUNING_LIMITS.maxSteps} steps`,
|
|
621
|
+
);
|
|
622
|
+
for (const value of cents)
|
|
623
|
+
if (!Number.isFinite(value) || Math.abs(value) > TUNING_LIMITS.maxCents)
|
|
624
|
+
throw new TuningError(
|
|
625
|
+
`${where} cents must be within ±${TUNING_LIMITS.maxCents}`,
|
|
626
|
+
);
|
|
627
|
+
if (!(cents[cents.length - 1]! > 0))
|
|
628
|
+
throw new TuningError(
|
|
629
|
+
`${where} period (the last step) must be above the root`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
634
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function projectPath(value: unknown, where: string, extension: string): string {
|
|
638
|
+
if (
|
|
639
|
+
typeof value !== "string" ||
|
|
640
|
+
value.length === 0 ||
|
|
641
|
+
value.length > TUNING_LIMITS.maxPathLength ||
|
|
642
|
+
value.startsWith("/") ||
|
|
643
|
+
/^[a-zA-Z]:/.test(value) ||
|
|
644
|
+
value.split(/[\\/]/).includes("..") ||
|
|
645
|
+
!value.toLowerCase().endsWith(extension)
|
|
646
|
+
)
|
|
647
|
+
throw new TuningError(
|
|
648
|
+
`${where} must be a ${extension} path inside the project`,
|
|
649
|
+
);
|
|
650
|
+
return value;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function normalizeKeyMapping(input: unknown, where: string): KeyMapping {
|
|
654
|
+
if (!isRecord(input))
|
|
655
|
+
throw new TuningError(`${where} keymap must be an object`);
|
|
656
|
+
const key = (field: string): number => {
|
|
657
|
+
const value = input[field];
|
|
658
|
+
if (
|
|
659
|
+
typeof value !== "number" ||
|
|
660
|
+
!Number.isInteger(value) ||
|
|
661
|
+
value < 0 ||
|
|
662
|
+
value > 127
|
|
663
|
+
)
|
|
664
|
+
throw new TuningError(
|
|
665
|
+
`${where} keymap ${field} must be a MIDI key 0-127`,
|
|
666
|
+
);
|
|
667
|
+
return value;
|
|
668
|
+
};
|
|
669
|
+
const size = input.size;
|
|
670
|
+
if (
|
|
671
|
+
typeof size !== "number" ||
|
|
672
|
+
!Number.isInteger(size) ||
|
|
673
|
+
size < 0 ||
|
|
674
|
+
size > TUNING_LIMITS.maxSteps
|
|
675
|
+
)
|
|
676
|
+
throw new TuningError(
|
|
677
|
+
`${where} keymap size must be an integer from 0 to ${TUNING_LIMITS.maxSteps}`,
|
|
678
|
+
);
|
|
679
|
+
const octave = input.octave;
|
|
680
|
+
if (
|
|
681
|
+
typeof octave !== "number" ||
|
|
682
|
+
!Number.isInteger(octave) ||
|
|
683
|
+
octave < 0 ||
|
|
684
|
+
octave > TUNING_LIMITS.maxSteps
|
|
685
|
+
)
|
|
686
|
+
throw new TuningError(
|
|
687
|
+
`${where} keymap octave must be a degree from 0 to ${TUNING_LIMITS.maxSteps}`,
|
|
688
|
+
);
|
|
689
|
+
const refHz = input.refHz;
|
|
690
|
+
if (
|
|
691
|
+
typeof refHz !== "number" ||
|
|
692
|
+
!Number.isFinite(refHz) ||
|
|
693
|
+
refHz <= 0 ||
|
|
694
|
+
refHz > 100_000
|
|
695
|
+
)
|
|
696
|
+
throw new TuningError(`${where} keymap refHz must be a positive frequency`);
|
|
697
|
+
const map = input.map;
|
|
698
|
+
if (!Array.isArray(map) || map.length !== size)
|
|
699
|
+
throw new TuningError(`${where} keymap map must list ${size} entries`);
|
|
700
|
+
for (const entry of map)
|
|
701
|
+
if (
|
|
702
|
+
entry !== null &&
|
|
703
|
+
(typeof entry !== "number" ||
|
|
704
|
+
!Number.isInteger(entry) ||
|
|
705
|
+
entry < 0 ||
|
|
706
|
+
entry > TUNING_LIMITS.maxSteps * 128)
|
|
707
|
+
)
|
|
708
|
+
throw new TuningError(`${where} keymap entries must be degrees or null`);
|
|
709
|
+
const mapping: KeyMapping = {
|
|
710
|
+
size,
|
|
711
|
+
first: key("first"),
|
|
712
|
+
last: key("last"),
|
|
713
|
+
middle: key("middle"),
|
|
714
|
+
refKey: key("refKey"),
|
|
715
|
+
refHz,
|
|
716
|
+
octave,
|
|
717
|
+
map: Object.freeze([...(map as (number | null)[])]),
|
|
718
|
+
};
|
|
719
|
+
if (mapping.first > mapping.last)
|
|
720
|
+
throw new TuningError(`${where} keymap first key is above its last key`);
|
|
721
|
+
if (mappedEntry(mapping, mapping.refKey) === undefined)
|
|
722
|
+
throw new TuningError(
|
|
723
|
+
`${where} keymap reference key ${mapping.refKey} is unmapped`,
|
|
724
|
+
);
|
|
725
|
+
return Object.freeze(mapping);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Validates and canonicalizes a song or track tuning; null and undefined
|
|
730
|
+
* mean none. Throws `TuningError` with a message naming `where`.
|
|
731
|
+
*/
|
|
732
|
+
export function normalizeTuning(
|
|
733
|
+
input: unknown,
|
|
734
|
+
where: string,
|
|
735
|
+
): Tuning | undefined {
|
|
736
|
+
if (input === undefined || input === null) return undefined;
|
|
737
|
+
if (typeof input === "string") return normalizeTuning({ name: input }, where);
|
|
738
|
+
if (!isRecord(input)) throw new TuningError(`${where} must be an object`);
|
|
739
|
+
const known = new Set([
|
|
740
|
+
"name",
|
|
741
|
+
"edo",
|
|
742
|
+
"ratios",
|
|
743
|
+
"cents",
|
|
744
|
+
"scl",
|
|
745
|
+
"kbm",
|
|
746
|
+
"keymap",
|
|
747
|
+
"ref",
|
|
748
|
+
"root",
|
|
749
|
+
"map",
|
|
750
|
+
]);
|
|
751
|
+
for (const field of Object.keys(input))
|
|
752
|
+
if (!known.has(field))
|
|
753
|
+
throw new TuningError(`${where} has an unknown field "${field}"`);
|
|
754
|
+
const out: Record<string, unknown> = {};
|
|
755
|
+
const sources = ["edo", "ratios", "cents"].filter(
|
|
756
|
+
(field) => input[field] !== undefined && input[field] !== null,
|
|
757
|
+
);
|
|
758
|
+
if (sources.length > 1)
|
|
759
|
+
throw new TuningError(
|
|
760
|
+
`${where} takes one of edo, ratios or cents, not ${sources.join(" and ")}`,
|
|
761
|
+
);
|
|
762
|
+
if (input.name !== undefined && input.name !== null) {
|
|
763
|
+
if (
|
|
764
|
+
typeof input.name !== "string" ||
|
|
765
|
+
input.name.trim() === "" ||
|
|
766
|
+
input.name.length > TUNING_LIMITS.maxNameLength
|
|
767
|
+
)
|
|
768
|
+
throw new TuningError(`${where} name must be a short string`);
|
|
769
|
+
if (sources.length === 0) {
|
|
770
|
+
const preset = tuningPreset(input.name);
|
|
771
|
+
if (!preset)
|
|
772
|
+
throw new TuningError(
|
|
773
|
+
`${where}: unknown tuning "${input.name}" (try ${TUNING_NAMES.slice(0, 12).join(", ")}, …)`,
|
|
774
|
+
);
|
|
775
|
+
out.name = preset.name;
|
|
776
|
+
} else out.name = input.name.trim();
|
|
777
|
+
}
|
|
778
|
+
if (input.edo !== undefined && input.edo !== null) {
|
|
779
|
+
const edo = input.edo;
|
|
780
|
+
if (
|
|
781
|
+
typeof edo !== "number" ||
|
|
782
|
+
!Number.isInteger(edo) ||
|
|
783
|
+
edo < 1 ||
|
|
784
|
+
edo > TUNING_LIMITS.maxEdo
|
|
785
|
+
)
|
|
786
|
+
throw new TuningError(
|
|
787
|
+
`${where} edo must be an integer from 1 to ${TUNING_LIMITS.maxEdo}`,
|
|
788
|
+
);
|
|
789
|
+
out.edo = edo;
|
|
790
|
+
}
|
|
791
|
+
if (input.ratios !== undefined && input.ratios !== null) {
|
|
792
|
+
if (!Array.isArray(input.ratios))
|
|
793
|
+
throw new TuningError(
|
|
794
|
+
`${where} ratios must be a list like ["9/8", "5/4", "2/1"]`,
|
|
795
|
+
);
|
|
796
|
+
const ratios = input.ratios.map((ratio, index) => {
|
|
797
|
+
const text =
|
|
798
|
+
typeof ratio === "number" && Number.isInteger(ratio)
|
|
799
|
+
? String(ratio)
|
|
800
|
+
: ratio;
|
|
801
|
+
if (typeof text !== "string" || parseRatio(text) === undefined)
|
|
802
|
+
throw new TuningError(
|
|
803
|
+
`${where} ratio ${index + 1} must be a positive ratio like "3/2"`,
|
|
804
|
+
);
|
|
805
|
+
return text.trim();
|
|
806
|
+
});
|
|
807
|
+
checkTable(
|
|
808
|
+
ratios.map((ratio) => ratioCents(parseRatio(ratio)!)),
|
|
809
|
+
`${where} ratios`,
|
|
810
|
+
);
|
|
811
|
+
out.ratios = Object.freeze(ratios);
|
|
812
|
+
}
|
|
813
|
+
if (input.cents !== undefined && input.cents !== null) {
|
|
814
|
+
if (
|
|
815
|
+
!Array.isArray(input.cents) ||
|
|
816
|
+
input.cents.some((value) => typeof value !== "number")
|
|
817
|
+
)
|
|
818
|
+
throw new TuningError(
|
|
819
|
+
`${where} cents must be a list of numbers like [100, 200, 1200]`,
|
|
820
|
+
);
|
|
821
|
+
checkTable(input.cents as number[], `${where} cents`);
|
|
822
|
+
out.cents = Object.freeze([...(input.cents as number[])]);
|
|
823
|
+
}
|
|
824
|
+
if (input.scl !== undefined && input.scl !== null) {
|
|
825
|
+
out.scl = projectPath(input.scl, `${where} scl`, ".scl");
|
|
826
|
+
if (out.ratios === undefined && out.cents === undefined)
|
|
827
|
+
throw new TuningError(
|
|
828
|
+
`${where} scl ${String(input.scl)} has not been read (no ratios or cents)`,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
if (input.kbm !== undefined && input.kbm !== null) {
|
|
832
|
+
out.kbm = projectPath(input.kbm, `${where} kbm`, ".kbm");
|
|
833
|
+
if (input.keymap === undefined || input.keymap === null)
|
|
834
|
+
throw new TuningError(
|
|
835
|
+
`${where} kbm ${String(input.kbm)} has not been read (no keymap)`,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
if (input.keymap !== undefined && input.keymap !== null)
|
|
839
|
+
out.keymap = normalizeKeyMapping(input.keymap, where);
|
|
840
|
+
if (input.ref !== undefined && input.ref !== null) {
|
|
841
|
+
const ref = input.ref;
|
|
842
|
+
if (
|
|
843
|
+
typeof ref !== "number" ||
|
|
844
|
+
!Number.isFinite(ref) ||
|
|
845
|
+
ref < TUNING_LIMITS.minRefHz ||
|
|
846
|
+
ref > TUNING_LIMITS.maxRefHz
|
|
847
|
+
)
|
|
848
|
+
throw new TuningError(
|
|
849
|
+
`${where} ref (12-TET A4 in Hz) must be between ${TUNING_LIMITS.minRefHz} and ${TUNING_LIMITS.maxRefHz}`,
|
|
850
|
+
);
|
|
851
|
+
out.ref = ref;
|
|
852
|
+
}
|
|
853
|
+
if (input.root !== undefined && input.root !== null) {
|
|
854
|
+
const root = input.root;
|
|
855
|
+
if (
|
|
856
|
+
typeof root !== "number" ||
|
|
857
|
+
!Number.isInteger(root) ||
|
|
858
|
+
root < 0 ||
|
|
859
|
+
root > 127
|
|
860
|
+
)
|
|
861
|
+
throw new TuningError(`${where} root must be a MIDI key 0-127`);
|
|
862
|
+
out.root = root;
|
|
863
|
+
}
|
|
864
|
+
if (input.map !== undefined && input.map !== null) {
|
|
865
|
+
if (!TUNING_MAPS.includes(input.map as TuningMap))
|
|
866
|
+
throw new TuningError(`${where} map must be ${TUNING_MAPS.join(" or ")}`);
|
|
867
|
+
if (input.map !== "linear") out.map = input.map;
|
|
868
|
+
}
|
|
869
|
+
if (
|
|
870
|
+
out.keymap &&
|
|
871
|
+
(out.ref !== undefined || out.root !== undefined || out.map !== undefined)
|
|
872
|
+
)
|
|
873
|
+
throw new TuningError(
|
|
874
|
+
`${where}: a keymap sets its own reference and root (drop ref, root and map)`,
|
|
875
|
+
);
|
|
876
|
+
return Object.freeze(out as Tuning);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** Validates a note's static cents offset; 0 and absence are the same. */
|
|
880
|
+
export function normalizeNoteCents(
|
|
881
|
+
input: unknown,
|
|
882
|
+
where: string,
|
|
883
|
+
): number | undefined {
|
|
884
|
+
if (input === undefined || input === null || input === 0) return undefined;
|
|
885
|
+
if (
|
|
886
|
+
typeof input !== "number" ||
|
|
887
|
+
!Number.isFinite(input) ||
|
|
888
|
+
Math.abs(input) > TUNING_LIMITS.maxNoteCents
|
|
889
|
+
)
|
|
890
|
+
throw new TuningError(
|
|
891
|
+
`${where} cents must be within ±${TUNING_LIMITS.maxNoteCents}`,
|
|
892
|
+
);
|
|
893
|
+
return input;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
// Resolution
|
|
898
|
+
|
|
899
|
+
/** Degrees 1..n in cents for a tuning's own table, or undefined to inherit. */
|
|
900
|
+
export function tuningTable(tuning: Tuning): readonly number[] | undefined {
|
|
901
|
+
if (tuning.edo !== undefined) return edoCents(tuning.edo);
|
|
902
|
+
if (tuning.ratios)
|
|
903
|
+
return tuning.ratios.map((ratio) => ratioCents(parseRatio(ratio)!));
|
|
904
|
+
if (tuning.cents) return tuning.cents;
|
|
905
|
+
if (tuning.name) return tuningPreset(tuning.name)?.cents;
|
|
906
|
+
return undefined;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Keys tuned at or above this frequency are silent (unmapped). */
|
|
910
|
+
export const TUNING_MAX_HZ = 20_000;
|
|
911
|
+
|
|
912
|
+
/** A tuning merged from the song and a track, ready to play. */
|
|
913
|
+
export type TuningTable = Readonly<{
|
|
914
|
+
/** Frequency of each MIDI key 0..127 in Hz; 0 for an unmapped key. */
|
|
915
|
+
hz: Float64Array;
|
|
916
|
+
/** Steps per period. */
|
|
917
|
+
size: number;
|
|
918
|
+
/** Period in cents. */
|
|
919
|
+
period: number;
|
|
920
|
+
/** Key of degree 0. */
|
|
921
|
+
root: number;
|
|
922
|
+
/** True when keys step through the table one key per degree. */
|
|
923
|
+
linear: boolean;
|
|
924
|
+
/** Display name. */
|
|
925
|
+
name: string;
|
|
926
|
+
}>;
|
|
927
|
+
|
|
928
|
+
function mappedEntry(
|
|
929
|
+
mapping: KeyMapping,
|
|
930
|
+
key: number,
|
|
931
|
+
): { pattern: number; degree: number } | undefined {
|
|
932
|
+
const offset = key - mapping.middle;
|
|
933
|
+
if (mapping.size === 0) return { pattern: 0, degree: offset };
|
|
934
|
+
const pattern = Math.floor(offset / mapping.size);
|
|
935
|
+
const entry = mapping.map[offset - pattern * mapping.size];
|
|
936
|
+
if (entry === null || entry === undefined) return undefined;
|
|
937
|
+
return { pattern, degree: entry };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* The merged tuning for a track: the track's table if it has one, else the
|
|
942
|
+
* song's; `ref`, `root`, `map` and the keymap come from the track when it
|
|
943
|
+
* sets them, else the song. Undefined when neither has a tuning, so callers
|
|
944
|
+
* keep their 12-TET path untouched. `keyText` is the song key, whose
|
|
945
|
+
* tonic is the default root.
|
|
946
|
+
*/
|
|
947
|
+
export function resolveTuning(
|
|
948
|
+
song: Tuning | undefined,
|
|
949
|
+
track: Tuning | undefined,
|
|
950
|
+
keyText?: string | null,
|
|
951
|
+
): TuningTable | undefined {
|
|
952
|
+
if (!song && !track) return undefined;
|
|
953
|
+
const own = track ? tuningTable(track) : undefined;
|
|
954
|
+
const source = own ? track! : song;
|
|
955
|
+
const table = (source ? tuningTable(source) : undefined) ?? edoCents(12);
|
|
956
|
+
const keymap = track?.keymap ?? (own ? undefined : song?.keymap);
|
|
957
|
+
const ref = track?.ref ?? song?.ref ?? 440;
|
|
958
|
+
const presetRoot =
|
|
959
|
+
source?.name && !source.edo && !source.ratios && !source.cents
|
|
960
|
+
? tuningPreset(source.name)?.root
|
|
961
|
+
: undefined;
|
|
962
|
+
const tonic = parseKey(keyText ?? undefined)?.tonic;
|
|
963
|
+
const root = track?.root ?? song?.root ?? 60 + (presetRoot ?? tonic ?? 0);
|
|
964
|
+
const nearest = (track?.map ?? song?.map) === "nearest";
|
|
965
|
+
const size = table.length;
|
|
966
|
+
const period = table[size - 1]!;
|
|
967
|
+
const degreeCents = (degree: number): number => {
|
|
968
|
+
const octave = Math.floor(degree / size);
|
|
969
|
+
const index = degree - octave * size;
|
|
970
|
+
return octave * period + (index === 0 ? 0 : table[index - 1]!);
|
|
971
|
+
};
|
|
972
|
+
const hz = new Float64Array(128);
|
|
973
|
+
if (keymap) {
|
|
974
|
+
const step = keymap.octave === 0 ? period : degreeCents(keymap.octave);
|
|
975
|
+
const centsOf = (key: number): number | undefined => {
|
|
976
|
+
const found = mappedEntry(keymap, key);
|
|
977
|
+
return found
|
|
978
|
+
? found.pattern * step + degreeCents(found.degree)
|
|
979
|
+
: undefined;
|
|
980
|
+
};
|
|
981
|
+
const refCents = centsOf(keymap.refKey)!;
|
|
982
|
+
for (let key = 0; key < 128; key += 1) {
|
|
983
|
+
if (key < keymap.first || key > keymap.last) {
|
|
984
|
+
hz[key] = ref * 2 ** ((key - 69) / 12);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const cents = centsOf(key);
|
|
988
|
+
hz[key] =
|
|
989
|
+
cents === undefined
|
|
990
|
+
? 0
|
|
991
|
+
: keymap.refHz * 2 ** ((cents - refCents) / 1200);
|
|
992
|
+
}
|
|
993
|
+
} else {
|
|
994
|
+
const rootHz = ref * 2 ** ((root - 69) / 12);
|
|
995
|
+
for (let key = 0; key < 128; key += 1) {
|
|
996
|
+
const cents = nearest
|
|
997
|
+
? nearestCents(table, (key - root) * 100)
|
|
998
|
+
: degreeCents(key - root);
|
|
999
|
+
hz[key] = rootHz * 2 ** (cents / 1200);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const name =
|
|
1003
|
+
source?.name ??
|
|
1004
|
+
(source?.edo
|
|
1005
|
+
? `${source.edo}-edo`
|
|
1006
|
+
: source?.scl
|
|
1007
|
+
? source.scl.split("/").pop()!
|
|
1008
|
+
: source
|
|
1009
|
+
? "custom"
|
|
1010
|
+
: "12-tet");
|
|
1011
|
+
// Keys above the audible range (or overflowing, as `{ edo: 1 }` does at
|
|
1012
|
+
// key 127) are unmapped, so voices stay silent instead of aliasing.
|
|
1013
|
+
for (let key = 0; key < 128; key += 1)
|
|
1014
|
+
if (!Number.isFinite(hz[key]!) || hz[key]! >= TUNING_MAX_HZ) hz[key] = 0;
|
|
1015
|
+
return Object.freeze({
|
|
1016
|
+
hz,
|
|
1017
|
+
size,
|
|
1018
|
+
period,
|
|
1019
|
+
root: keymap ? keymap.middle : root,
|
|
1020
|
+
linear: !keymap && !nearest,
|
|
1021
|
+
name,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** The table pitch (any period) closest to `target` cents; lower on ties. */
|
|
1026
|
+
function nearestCents(table: readonly number[], target: number): number {
|
|
1027
|
+
const period = table[table.length - 1]!;
|
|
1028
|
+
let best = 0;
|
|
1029
|
+
let bestDistance = Infinity;
|
|
1030
|
+
for (const step of [0, ...table.slice(0, -1)]) {
|
|
1031
|
+
const octave = Math.round((target - step) / period);
|
|
1032
|
+
for (const candidate of [
|
|
1033
|
+
(octave - 1) * period + step,
|
|
1034
|
+
octave * period + step,
|
|
1035
|
+
(octave + 1) * period + step,
|
|
1036
|
+
]) {
|
|
1037
|
+
const distance = Math.abs(candidate - target);
|
|
1038
|
+
if (
|
|
1039
|
+
distance < bestDistance - 1e-9 ||
|
|
1040
|
+
(Math.abs(distance - bestDistance) <= 1e-9 && candidate < best)
|
|
1041
|
+
) {
|
|
1042
|
+
best = candidate;
|
|
1043
|
+
bestDistance = distance;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return best;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* A note's frequency: `440 · 2^((pitch − 69)/12)` exactly when there is no
|
|
1052
|
+
* tuning and no cents, so untuned projects render byte-identically.
|
|
1053
|
+
*/
|
|
1054
|
+
export function noteHz(
|
|
1055
|
+
pitch: number,
|
|
1056
|
+
cents: number | undefined,
|
|
1057
|
+
table: TuningTable | undefined,
|
|
1058
|
+
): number {
|
|
1059
|
+
const base = table ? table.hz[pitch]! : 440 * 2 ** ((pitch - 69) / 12);
|
|
1060
|
+
return cents ? base * 2 ** (cents / 1200) : base;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Cents a key sounds above A4 = 440 Hz in a tuning, for glides between
|
|
1065
|
+
* tuned keys (`PerformanceTiming.keyCents`). An unmapped key falls back to
|
|
1066
|
+
* its 12-TET place.
|
|
1067
|
+
*/
|
|
1068
|
+
export function keyCentsFor(table: TuningTable): (pitch: number) => number {
|
|
1069
|
+
return (pitch) => {
|
|
1070
|
+
const hz = table.hz[pitch];
|
|
1071
|
+
return hz && hz > 0 ? ratioCents(hz / 440) : (pitch - 69) * 100;
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Cents a key sounds away from 12-TET at A4 = 440 Hz (0 when unmapped). */
|
|
1076
|
+
export function keyDeviation(
|
|
1077
|
+
table: TuningTable | undefined,
|
|
1078
|
+
pitch: number,
|
|
1079
|
+
cents = 0,
|
|
1080
|
+
): number {
|
|
1081
|
+
const hz = noteHz(pitch, cents, table);
|
|
1082
|
+
if (!(hz > 0)) return 0;
|
|
1083
|
+
return ratioCents(hz / (440 * 2 ** ((pitch - 69) / 12)));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* The key that sounds closest to a 12-TET pitch, measured from the tuning's
|
|
1088
|
+
* root. Twelve-key and mapped tunings return the pitch unchanged (JI chords
|
|
1089
|
+
* stay on their keys and sound pure); a linear non-12 tuning (19-EDO,
|
|
1090
|
+
* pelog) moves it to the nearest step, so a chord written in semitones
|
|
1091
|
+
* keeps its shape instead of collapsing into small steps.
|
|
1092
|
+
*/
|
|
1093
|
+
export function snapToTuning(
|
|
1094
|
+
pitch: number,
|
|
1095
|
+
table: TuningTable | undefined,
|
|
1096
|
+
): number {
|
|
1097
|
+
if (!table || !table.linear || table.size === 12) return pitch;
|
|
1098
|
+
const rootHz = table.hz[table.root]!;
|
|
1099
|
+
if (!(rootHz > 0)) return pitch;
|
|
1100
|
+
const target = rootHz * 2 ** ((pitch - table.root) / 12);
|
|
1101
|
+
let best = pitch;
|
|
1102
|
+
let bestDistance = Infinity;
|
|
1103
|
+
for (let key = 0; key < 128; key += 1) {
|
|
1104
|
+
const hz = table.hz[key]!;
|
|
1105
|
+
if (!(hz > 0)) continue;
|
|
1106
|
+
const distance = Math.abs(Math.log2(hz / target));
|
|
1107
|
+
if (distance < bestDistance - 1e-12) {
|
|
1108
|
+
best = key;
|
|
1109
|
+
bestDistance = distance;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return best;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** `+14`, `−32`: a compact signed cents label (empty within ±0.5). */
|
|
1116
|
+
export function centsLabel(cents: number): string {
|
|
1117
|
+
const rounded = Math.round(cents);
|
|
1118
|
+
if (rounded === 0) return "";
|
|
1119
|
+
return rounded > 0 ? `+${rounded}` : `−${-rounded}`;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** One line describing a tuning, for `/tuning` and the menu. */
|
|
1123
|
+
export function describeTuning(tuning: Tuning | undefined): string {
|
|
1124
|
+
if (!tuning) return "12-tet (default)";
|
|
1125
|
+
const parts: string[] = [];
|
|
1126
|
+
if (tuning.name) parts.push(tuning.name);
|
|
1127
|
+
else if (tuning.edo) parts.push(`${tuning.edo}-edo`);
|
|
1128
|
+
if (tuning.scl) parts.push(tuning.scl);
|
|
1129
|
+
else if (tuning.ratios) parts.push(`${tuning.ratios.length} ratios`);
|
|
1130
|
+
else if (tuning.cents) parts.push(`${tuning.cents.length} steps`);
|
|
1131
|
+
if (tuning.kbm) parts.push(tuning.kbm);
|
|
1132
|
+
if (tuning.ref !== undefined) parts.push(`A4=${tuning.ref}Hz`);
|
|
1133
|
+
if (tuning.root !== undefined) parts.push(`root ${midiToPitch(tuning.root)}`);
|
|
1134
|
+
if (tuning.map) parts.push(tuning.map);
|
|
1135
|
+
return parts.length > 0 ? parts.join(" · ") : "inherits the song tuning";
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Cents a note sounds away from its nearest 12-TET pitch, folded to ±50, for
|
|
1140
|
+
* the highway tag. Undefined when it sounds in 12-TET.
|
|
1141
|
+
*/
|
|
1142
|
+
export function displayCents(
|
|
1143
|
+
table: TuningTable | undefined,
|
|
1144
|
+
pitch: number,
|
|
1145
|
+
cents: number | undefined,
|
|
1146
|
+
): number | undefined {
|
|
1147
|
+
if (!table && !cents) return undefined;
|
|
1148
|
+
const deviation = keyDeviation(table, pitch, cents ?? 0);
|
|
1149
|
+
const folded = deviation - 100 * Math.round(deviation / 100);
|
|
1150
|
+
return Math.abs(folded) < 0.5 ? undefined : folded;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* The highway tag for a note. In a twelve-key table the lane is the note the
|
|
1155
|
+
* deviation is measured from, so the tag is bare cents (`+14`). In a linear
|
|
1156
|
+
* non-12 table (19-EDO, slendro) the lane is just the key, so the tag names
|
|
1157
|
+
* the nearest 12-TET pitch class it is measured from (`D−47`, or `C` when it
|
|
1158
|
+
* sounds on that pitch). Undefined when the note sounds on its own lane.
|
|
1159
|
+
*/
|
|
1160
|
+
export function displayTag(
|
|
1161
|
+
table: TuningTable | undefined,
|
|
1162
|
+
pitch: number,
|
|
1163
|
+
cents: number | undefined,
|
|
1164
|
+
): { cents?: number; name?: string } | undefined {
|
|
1165
|
+
if (!table || !table.linear || table.size === 12) {
|
|
1166
|
+
const folded = displayCents(table, pitch, cents);
|
|
1167
|
+
return folded === undefined ? undefined : { cents: folded };
|
|
1168
|
+
}
|
|
1169
|
+
const hz = noteHz(pitch, cents, table);
|
|
1170
|
+
if (!(hz > 0)) return undefined;
|
|
1171
|
+
const semitones = 69 + 12 * Math.log2(hz / 440);
|
|
1172
|
+
const nearest = Math.round(semitones);
|
|
1173
|
+
const off = (semitones - nearest) * 100;
|
|
1174
|
+
if (nearest === pitch && Math.abs(off) < 0.5) return undefined;
|
|
1175
|
+
const name = midiToPitch(Math.max(0, Math.min(127, nearest))).replace(
|
|
1176
|
+
/-?\d+$/,
|
|
1177
|
+
"",
|
|
1178
|
+
);
|
|
1179
|
+
return Math.abs(off) < 0.5 ? { name } : { cents: off, name };
|
|
1180
|
+
}
|