@hraness/dawg 0.4.0 → 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 +86 -0
- package/DAWG.md +300 -35
- 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/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- 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 +197 -50
- package/src/audio/engine.ts +175 -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 +87 -16
- 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 +448 -59
- 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 +826 -47
- 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 +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -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 +79 -12
- 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/sdk/v1.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
/** SDK release; dawg refreshes the vendored copy when its own is newer. */
|
|
30
|
-
export const SDK_VERSION = "1.
|
|
30
|
+
export const SDK_VERSION = "1.19.0";
|
|
31
31
|
/** Major of `SDK_VERSION`; `dawg.json` records it as `sdk`. */
|
|
32
32
|
export const SDK_MAJOR = 1;
|
|
33
33
|
|
|
@@ -90,6 +90,26 @@ export function midi(pitch: Pitch): number {
|
|
|
90
90
|
return value;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* A pitch with an optional cents suffix (SDK 1.16.0): `"E4-14c"` is E4
|
|
95
|
+
* fourteen cents flat, `"A3+50c"` a quarter tone sharp. The offset is
|
|
96
|
+
* static and sits on top of the song or track tuning; ±1200 at most.
|
|
97
|
+
*/
|
|
98
|
+
export function pitchCents(pitch: Pitch): Readonly<{
|
|
99
|
+
pitch: number;
|
|
100
|
+
cents: number;
|
|
101
|
+
}> {
|
|
102
|
+
const match =
|
|
103
|
+
typeof pitch === "string"
|
|
104
|
+
? pitch.trim().match(/^(.+?)([+-]\d+(?:\.\d+)?)c$/)
|
|
105
|
+
: null;
|
|
106
|
+
if (!match) return { pitch: midi(pitch), cents: 0 };
|
|
107
|
+
const cents = Number(match[2]);
|
|
108
|
+
if (!(Math.abs(cents) <= 1200))
|
|
109
|
+
throw new DawgSdkError(`pitch cents must be within ±1200: ${pitch}`);
|
|
110
|
+
return { pitch: midi(match[1]!), cents };
|
|
111
|
+
}
|
|
112
|
+
|
|
93
113
|
// ---------------------------------------------------------------------------
|
|
94
114
|
// Drum voices (General MIDI numbers, same table as dawg's `kit`)
|
|
95
115
|
|
|
@@ -161,7 +181,10 @@ export type NoteSpec = Readonly<{
|
|
|
161
181
|
length: number;
|
|
162
182
|
/** 0..1. */
|
|
163
183
|
velocity: number;
|
|
164
|
-
|
|
184
|
+
/** Static offset in cents from a `"E4-14c"` pitch (SDK 1.16.0); absent is 0. */
|
|
185
|
+
cents?: number;
|
|
186
|
+
}> &
|
|
187
|
+
NoteExpressionSpec;
|
|
165
188
|
|
|
166
189
|
/** A drum or sampler hit addressed by voice name; resolved to a pitch slot by `track()`. */
|
|
167
190
|
export type HitSpec = Readonly<{
|
|
@@ -170,15 +193,205 @@ export type HitSpec = Readonly<{
|
|
|
170
193
|
start: number;
|
|
171
194
|
length: number;
|
|
172
195
|
velocity: number;
|
|
196
|
+
}> &
|
|
197
|
+
NoteExpressionSpec;
|
|
198
|
+
|
|
199
|
+
/** How a note is articulated (SDK 1.15.0). */
|
|
200
|
+
export type Articulation =
|
|
201
|
+
"staccato" | "legato" | "accent" | "tenuto" | "marcato" | "ghost";
|
|
202
|
+
|
|
203
|
+
export const ARTICULATIONS: readonly Articulation[] = Object.freeze([
|
|
204
|
+
"staccato",
|
|
205
|
+
"legato",
|
|
206
|
+
"accent",
|
|
207
|
+
"tenuto",
|
|
208
|
+
"marcato",
|
|
209
|
+
"ghost",
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
/** A pitch-bend point: `[at, cents]`, `at` 0..1 through the note. */
|
|
213
|
+
export type BendPoint = readonly [number, number];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* How one note is played (SDK 1.15.0). Every field is optional.
|
|
217
|
+
*
|
|
218
|
+
* ```ts
|
|
219
|
+
* note("C4", 0, 1, 0.8, { art: "staccato" })
|
|
220
|
+
* note("E4", 1, 2, 0.8, { glide: 0.1, vibrato: { depth: 30, delay: 0.3 } })
|
|
221
|
+
* note("G4", 3, 1, 0.8, { bend: [[0, -200], [0.25, 0]] }) // scoop up a tone
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
export type Expression = Readonly<{
|
|
225
|
+
/**
|
|
226
|
+
* `staccato` (half length), `legato` (held into the next note),
|
|
227
|
+
* `accent` (louder), `tenuto` (full length, a little louder), `marcato`
|
|
228
|
+
* (two-thirds length, much louder) or `ghost` (half length, much softer).
|
|
229
|
+
*/
|
|
230
|
+
articulation?: Articulation;
|
|
231
|
+
/** Short alias of `articulation`. */
|
|
232
|
+
art?: Articulation;
|
|
233
|
+
/** Portamento into this note from the track's previous pitch, seconds (0..10). */
|
|
234
|
+
glide?: number;
|
|
235
|
+
/** Pitch curve in cents as `[at, cents]` points, `at` 0..1 through the note; linear between points. */
|
|
236
|
+
bend?: readonly BendPoint[];
|
|
237
|
+
/** Vibrato: `rate` Hz (default 5.5), `depth` cents either side (default 20), `delay` seconds before it fades in. */
|
|
238
|
+
vibrato?: Readonly<{ rate?: number; depth?: number; delay?: number }>;
|
|
239
|
+
/**
|
|
240
|
+
* This note's humanize (SDK 1.15.0), replacing the track's amounts:
|
|
241
|
+
* `{ timing ms, velocity %, length % }`; `{}` keeps the note exact.
|
|
242
|
+
* Humanize just bars 5-8 with `expr({ humanize: { timing: 10 } }, ...)`.
|
|
243
|
+
*/
|
|
244
|
+
humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
|
|
173
245
|
}>;
|
|
174
246
|
|
|
247
|
+
/** The expression a built note carries; fields are present only when set. */
|
|
248
|
+
export type NoteExpressionSpec = Readonly<{
|
|
249
|
+
articulation?: Articulation;
|
|
250
|
+
glide?: number;
|
|
251
|
+
bend?: readonly BendPoint[];
|
|
252
|
+
vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
|
|
253
|
+
humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
|
|
254
|
+
}>;
|
|
255
|
+
|
|
256
|
+
const DEFAULT_VIBRATO_RATE = 5.5;
|
|
257
|
+
const DEFAULT_VIBRATO_DEPTH = 20;
|
|
258
|
+
|
|
259
|
+
function expression(
|
|
260
|
+
input: Expression | undefined,
|
|
261
|
+
label: string,
|
|
262
|
+
): NoteExpressionSpec {
|
|
263
|
+
if (input === undefined) return {};
|
|
264
|
+
if (!isRecord(input))
|
|
265
|
+
throw new DawgSdkError(`${label} expression must be an object`);
|
|
266
|
+
for (const key of Object.keys(input))
|
|
267
|
+
if (
|
|
268
|
+
!["articulation", "art", "glide", "bend", "vibrato", "humanize"].includes(
|
|
269
|
+
key,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
throw new DawgSdkError(
|
|
273
|
+
`${label} expression has an unknown field "${key.slice(0, 32)}" (articulation glide bend vibrato humanize)`,
|
|
274
|
+
);
|
|
275
|
+
const out: {
|
|
276
|
+
articulation?: Articulation;
|
|
277
|
+
glide?: number;
|
|
278
|
+
bend?: readonly BendPoint[];
|
|
279
|
+
vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
|
|
280
|
+
humanize?: Readonly<{
|
|
281
|
+
timing?: number;
|
|
282
|
+
velocity?: number;
|
|
283
|
+
length?: number;
|
|
284
|
+
}>;
|
|
285
|
+
} = {};
|
|
286
|
+
const articulation = input.articulation ?? input.art;
|
|
287
|
+
if (articulation !== undefined) {
|
|
288
|
+
if (!ARTICULATIONS.includes(articulation))
|
|
289
|
+
throw new DawgSdkError(
|
|
290
|
+
`${label} articulation must be one of ${ARTICULATIONS.join(" ")}`,
|
|
291
|
+
);
|
|
292
|
+
out.articulation = articulation;
|
|
293
|
+
}
|
|
294
|
+
if (input.glide !== undefined) {
|
|
295
|
+
const glide = finite(input.glide, `${label} glide`);
|
|
296
|
+
if (glide < 0) throw new DawgSdkError(`${label} glide must be ≥ 0`);
|
|
297
|
+
out.glide = glide;
|
|
298
|
+
}
|
|
299
|
+
if (input.bend !== undefined) {
|
|
300
|
+
if (!Array.isArray(input.bend) || input.bend.length > 32)
|
|
301
|
+
throw new DawgSdkError(
|
|
302
|
+
`${label} bend must be at most 32 [at, cents] points`,
|
|
303
|
+
);
|
|
304
|
+
if (input.bend.length > 0)
|
|
305
|
+
out.bend = Object.freeze(
|
|
306
|
+
input.bend.map((point: unknown, index: number): BendPoint => {
|
|
307
|
+
if (!Array.isArray(point) || point.length !== 2)
|
|
308
|
+
throw new DawgSdkError(
|
|
309
|
+
`${label} bend[${index}] must be [at, cents]`,
|
|
310
|
+
);
|
|
311
|
+
return Object.freeze([
|
|
312
|
+
unit(point[0], `${label} bend[${index}] at`),
|
|
313
|
+
finite(point[1], `${label} bend[${index}] cents`),
|
|
314
|
+
] as const);
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (input.vibrato !== undefined) {
|
|
319
|
+
if (!isRecord(input.vibrato))
|
|
320
|
+
throw new DawgSdkError(`${label} vibrato must be { rate, depth, delay }`);
|
|
321
|
+
const delay =
|
|
322
|
+
input.vibrato.delay === undefined
|
|
323
|
+
? 0
|
|
324
|
+
: finite(input.vibrato.delay, `${label} vibrato delay`);
|
|
325
|
+
out.vibrato = Object.freeze({
|
|
326
|
+
rate: finite(
|
|
327
|
+
input.vibrato.rate ?? DEFAULT_VIBRATO_RATE,
|
|
328
|
+
`${label} vibrato rate`,
|
|
329
|
+
),
|
|
330
|
+
depth: finite(
|
|
331
|
+
input.vibrato.depth ?? DEFAULT_VIBRATO_DEPTH,
|
|
332
|
+
`${label} vibrato depth`,
|
|
333
|
+
),
|
|
334
|
+
...(delay !== 0 ? { delay } : {}),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
if (input.humanize !== undefined) {
|
|
338
|
+
if (!isRecord(input.humanize))
|
|
339
|
+
throw new DawgSdkError(
|
|
340
|
+
`${label} humanize must be { timing, velocity, length }`,
|
|
341
|
+
);
|
|
342
|
+
const amounts: Record<string, number> = {};
|
|
343
|
+
for (const key of Object.keys(input.humanize)) {
|
|
344
|
+
if (key !== "timing" && key !== "velocity" && key !== "length")
|
|
345
|
+
throw new DawgSdkError(
|
|
346
|
+
`${label} humanize has an unknown field "${key.slice(0, 32)}" (timing velocity length)`,
|
|
347
|
+
);
|
|
348
|
+
const value = finite(input.humanize[key], `${label} humanize ${key}`);
|
|
349
|
+
if (value < 0)
|
|
350
|
+
throw new DawgSdkError(`${label} humanize ${key} must be ≥ 0`);
|
|
351
|
+
if (value > 0) amounts[key] = value;
|
|
352
|
+
}
|
|
353
|
+
out.humanize = Object.freeze(amounts);
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The same expression on many notes or hits (SDK 1.15.0); a note's own
|
|
360
|
+
* fields win.
|
|
361
|
+
*
|
|
362
|
+
* ```ts
|
|
363
|
+
* notes: expr(seq("C2 C2 Eb2 C3", { step: 0.25 }), { art: "staccato" })
|
|
364
|
+
* ```
|
|
365
|
+
*/
|
|
366
|
+
export function expr<T extends NoteSpec | HitSpec>(
|
|
367
|
+
notes: readonly T[],
|
|
368
|
+
expression_: Expression,
|
|
369
|
+
): readonly T[] {
|
|
370
|
+
if (!Array.isArray(notes))
|
|
371
|
+
throw new DawgSdkError("expr needs an array of notes or hits");
|
|
372
|
+
const shared = expression(expression_, "expr");
|
|
373
|
+
return Object.freeze(
|
|
374
|
+
notes.map((item, index) => {
|
|
375
|
+
if (!isRecord(item) || (item.kind !== "note" && item.kind !== "hit"))
|
|
376
|
+
throw new DawgSdkError(
|
|
377
|
+
`expr notes[${index}] must come from note(), seq(), hit() or hits()`,
|
|
378
|
+
);
|
|
379
|
+
return Object.freeze({ ...shared, ...item }) as T;
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
175
384
|
/**
|
|
176
385
|
* One note. `pitch` is a name or MIDI number, `start` and `length` are
|
|
177
|
-
* beats, `velocity` defaults to 0.8
|
|
386
|
+
* beats, `velocity` defaults to 0.8, and `how` adds expression
|
|
387
|
+
* (articulation, glide, bend, vibrato; SDK 1.15.0). A cents suffix detunes
|
|
388
|
+
* one note (SDK 1.16.0): `"E4-14c"` (see `pitchCents`).
|
|
178
389
|
*
|
|
179
390
|
* ```ts
|
|
180
391
|
* note("A1", 0, 1) // A1 on the downbeat for one beat
|
|
181
392
|
* note("A1", 1.5, 0.5, 0.6) // off-beat eighth, softer
|
|
393
|
+
* note("A1", 2, 1, 0.8, { art: "staccato" })
|
|
394
|
+
* note("E4-14c", 2) // a just major third over C, 14 cents flat
|
|
182
395
|
* ```
|
|
183
396
|
*/
|
|
184
397
|
export function note(
|
|
@@ -186,13 +399,17 @@ export function note(
|
|
|
186
399
|
start: number,
|
|
187
400
|
length = 1,
|
|
188
401
|
velocity = DEFAULT_VELOCITY,
|
|
402
|
+
how?: Expression,
|
|
189
403
|
): NoteSpec {
|
|
404
|
+
const tuned = pitchCents(pitch);
|
|
190
405
|
return Object.freeze({
|
|
191
406
|
kind: "note",
|
|
192
|
-
pitch:
|
|
407
|
+
pitch: tuned.pitch,
|
|
193
408
|
start: beat(start, "note start"),
|
|
194
409
|
length: positive(length, "note length"),
|
|
195
410
|
velocity: unit(velocity, "note velocity"),
|
|
411
|
+
...expression(how, "note"),
|
|
412
|
+
...(tuned.cents !== 0 ? { cents: tuned.cents } : {}),
|
|
196
413
|
});
|
|
197
414
|
}
|
|
198
415
|
|
|
@@ -245,13 +462,14 @@ export function seq(
|
|
|
245
462
|
* One drum or sampler hit. On a `kit` track `voice` is `kick`, `snare`,
|
|
246
463
|
* `clap`, `rim`, `tom`, `hat` or `openhat` (aliases `bd`, `sd`, `cp`, `hh`,
|
|
247
464
|
* `oh` work); on a `sampler()` track it is a voice name. `length` defaults
|
|
248
|
-
* to a sixteenth.
|
|
465
|
+
* to a sixteenth; `how` adds expression (`{ art: "ghost" }`, SDK 1.15.0).
|
|
249
466
|
*/
|
|
250
467
|
export function hit(
|
|
251
468
|
voice: string,
|
|
252
469
|
start: number,
|
|
253
470
|
velocity = DEFAULT_VELOCITY,
|
|
254
471
|
length = DEFAULT_HIT_LENGTH,
|
|
472
|
+
how?: Expression,
|
|
255
473
|
): HitSpec {
|
|
256
474
|
if (typeof voice !== "string" || voice.length === 0 || voice.length > 32)
|
|
257
475
|
throw new DawgSdkError("hit voice must be a short name");
|
|
@@ -261,6 +479,7 @@ export function hit(
|
|
|
261
479
|
start: beat(start, "hit start"),
|
|
262
480
|
length: positive(length, "hit length"),
|
|
263
481
|
velocity: unit(velocity, "hit velocity"),
|
|
482
|
+
...expression(how, "hit"),
|
|
264
483
|
});
|
|
265
484
|
}
|
|
266
485
|
|
|
@@ -1458,6 +1677,21 @@ export type TrackInput = Readonly<{
|
|
|
1458
1677
|
* voices.
|
|
1459
1678
|
*/
|
|
1460
1679
|
kit?: string;
|
|
1680
|
+
/**
|
|
1681
|
+
* The track's own clock against the song (SDK 1.14.0): `rate` 1.5 plays
|
|
1682
|
+
* three beats in two, `phase` starts it that many beats later, `cycle`
|
|
1683
|
+
* repeats its first `cycle` beats (a 3-beat cycle over 4/4 is polymeter).
|
|
1684
|
+
* `{ cycle: 3, rate: 13 / 12 }` drifts against a twin and realigns, the
|
|
1685
|
+
* tape phasing of Reich's Come Out; `phasing()` works the rate out for
|
|
1686
|
+
* you, and `stepPhasing()` gives Piano Phase's shift and hold.
|
|
1687
|
+
*/
|
|
1688
|
+
time?: TrackTimeInput;
|
|
1689
|
+
/**
|
|
1690
|
+
* This track's tuning over the song's (SDK 1.16.0): a library name such
|
|
1691
|
+
* as `"pelog"` or `{ edo, ratios, cents, scl, kbm, ref, root, map }`.
|
|
1692
|
+
* `{ ref: 432 }` alone keeps the song's table at another pitch.
|
|
1693
|
+
*/
|
|
1694
|
+
tuning?: TuningInput | null;
|
|
1461
1695
|
/** Synth voice parameters, Strudel names (`{ attack: 0.01, lpf: 800 }`). */
|
|
1462
1696
|
synth?: SynthInput;
|
|
1463
1697
|
muted?: boolean;
|
|
@@ -1476,6 +1710,34 @@ export type TrackInput = Readonly<{
|
|
|
1476
1710
|
/** Insert effects by name (`{ distort: { drive: 3 }, chorus: {} }`). */
|
|
1477
1711
|
fx?: FxInput;
|
|
1478
1712
|
automation?: AutomationInput;
|
|
1713
|
+
/**
|
|
1714
|
+
* Glide between notes (SDK 1.15.0): seconds (`glide: 0.08`, TB-303 style
|
|
1715
|
+
* legato) or `{ time, mode }`. Mode `legato` glides only into a note that
|
|
1716
|
+
* overlaps the previous one and does not retrigger it; `mono` always
|
|
1717
|
+
* glides and retriggers; `poly` glides every voice of a chord from the
|
|
1718
|
+
* matching voice of the previous one.
|
|
1719
|
+
*/
|
|
1720
|
+
glide?: number | Readonly<{ time?: number; mode?: GlideMode }>;
|
|
1721
|
+
/** Sustain pedal changes as `[beat, "down" | "half" | "up"]` (SDK 1.15.0). */
|
|
1722
|
+
pedal?: readonly (readonly [number, PedalState])[];
|
|
1723
|
+
/**
|
|
1724
|
+
* Velocity response (SDK 1.15.0): `soft` (quiet notes louder), `hard`
|
|
1725
|
+
* (needs a firm touch), `fixed` (every note at 0.8, like an organ) or
|
|
1726
|
+
* `{ curve: "fixed", fixed: 0.6 }`. Default `linear`.
|
|
1727
|
+
*/
|
|
1728
|
+
velocityCurve?:
|
|
1729
|
+
VelocityCurveName | Readonly<{ curve: VelocityCurveName; fixed?: number }>;
|
|
1730
|
+
/**
|
|
1731
|
+
* Seeded humanize applied when dawg renders, so the notes stay as written
|
|
1732
|
+
* (SDK 1.15.0): `timing` ms either side, `velocity` and `length` in
|
|
1733
|
+
* percent, `seed` (default 1) picks another take.
|
|
1734
|
+
*/
|
|
1735
|
+
humanize?: Readonly<{
|
|
1736
|
+
timing?: number;
|
|
1737
|
+
velocity?: number;
|
|
1738
|
+
length?: number;
|
|
1739
|
+
seed?: number;
|
|
1740
|
+
}>;
|
|
1479
1741
|
/** `note()`/`seq()` for pitched tracks, `hit()`/`hits()` for kits and one-shot samplers. */
|
|
1480
1742
|
notes?: readonly (NoteSpec | HitSpec)[];
|
|
1481
1743
|
/**
|
|
@@ -1533,6 +1795,22 @@ export type ReverbInput = Readonly<{
|
|
|
1533
1795
|
}>;
|
|
1534
1796
|
}>;
|
|
1535
1797
|
|
|
1798
|
+
/** `track({ time })`: every field optional; absent follows the song. */
|
|
1799
|
+
export type TrackTimeInput = Readonly<{
|
|
1800
|
+
/** Tempo ratio against the song, 0.125..8 (1 = in step). */
|
|
1801
|
+
rate?: number;
|
|
1802
|
+
/** Beats the track's pattern starts late (negative: early); it wraps. */
|
|
1803
|
+
phase?: number;
|
|
1804
|
+
/** Beats of the track that repeat, default the whole song loop. */
|
|
1805
|
+
cycle?: number;
|
|
1806
|
+
/**
|
|
1807
|
+
* Stepped phasing (SDK 1.19.0), as in Reich's Piano Phase: hold `hold`
|
|
1808
|
+
* cycles in step, then move `shift` beats ahead over `drift` cycles, and
|
|
1809
|
+
* repeat. Needs `cycle`; replaces `rate`. `stepPhasing()` builds it.
|
|
1810
|
+
*/
|
|
1811
|
+
steps?: Readonly<{ shift: number; hold: number; drift: number }>;
|
|
1812
|
+
}>;
|
|
1813
|
+
|
|
1536
1814
|
/** Frozen track built by `track()`; `song()` consumes it. Beats, not ticks. */
|
|
1537
1815
|
export type TrackSpec = Readonly<{
|
|
1538
1816
|
kind: "track";
|
|
@@ -1561,8 +1839,133 @@ export type TrackSpec = Readonly<{
|
|
|
1561
1839
|
/** Rhythm rows in order (voice names as written). */
|
|
1562
1840
|
rhythm: readonly RhythmSpec[];
|
|
1563
1841
|
kit: string | null;
|
|
1842
|
+
/** Present only when `track({ time })` set something. */
|
|
1843
|
+
time?: TrackTimeInput;
|
|
1844
|
+
/** Performance (SDK 1.15.0); present only when set. */
|
|
1845
|
+
glide?: Readonly<{ time: number; mode: GlideMode }>;
|
|
1846
|
+
pedal?: readonly (readonly [number, PedalState])[];
|
|
1847
|
+
velocityCurve?: Readonly<{
|
|
1848
|
+
curve: Exclude<VelocityCurveName, "linear">;
|
|
1849
|
+
fixed?: number;
|
|
1850
|
+
}>;
|
|
1851
|
+
humanize?: Readonly<{
|
|
1852
|
+
timing?: number;
|
|
1853
|
+
velocity?: number;
|
|
1854
|
+
length?: number;
|
|
1855
|
+
seed: number;
|
|
1856
|
+
}>;
|
|
1857
|
+
tuning: ScoreTuning | null;
|
|
1564
1858
|
}>;
|
|
1565
1859
|
|
|
1860
|
+
export type GlideMode = "legato" | "mono" | "poly";
|
|
1861
|
+
export type PedalState = "down" | "half" | "up";
|
|
1862
|
+
export type VelocityCurveName = "linear" | "soft" | "hard" | "fixed";
|
|
1863
|
+
|
|
1864
|
+
const DEFAULT_GLIDE_SECONDS = 0.06;
|
|
1865
|
+
const DEFAULT_FIXED_VELOCITY = 0.8;
|
|
1866
|
+
|
|
1867
|
+
/** Normalizes `track()` performance options; dawg validates the ranges. */
|
|
1868
|
+
function trackPerformance(
|
|
1869
|
+
input: TrackInput,
|
|
1870
|
+
name: string,
|
|
1871
|
+
): Partial<Pick<TrackSpec, "glide" | "pedal" | "velocityCurve" | "humanize">> {
|
|
1872
|
+
const out: {
|
|
1873
|
+
glide?: TrackSpec["glide"];
|
|
1874
|
+
pedal?: TrackSpec["pedal"];
|
|
1875
|
+
velocityCurve?: TrackSpec["velocityCurve"];
|
|
1876
|
+
humanize?: TrackSpec["humanize"];
|
|
1877
|
+
} = {};
|
|
1878
|
+
if (input.glide !== undefined) {
|
|
1879
|
+
const raw =
|
|
1880
|
+
typeof input.glide === "number" ? { time: input.glide } : input.glide;
|
|
1881
|
+
if (!isRecord(raw))
|
|
1882
|
+
throw new DawgSdkError(
|
|
1883
|
+
`track ${name}: glide must be seconds or { time, mode }`,
|
|
1884
|
+
);
|
|
1885
|
+
const mode = raw.mode ?? "legato";
|
|
1886
|
+
if (!["legato", "mono", "poly"].includes(mode))
|
|
1887
|
+
throw new DawgSdkError(
|
|
1888
|
+
`track ${name}: glide mode must be legato, mono or poly`,
|
|
1889
|
+
);
|
|
1890
|
+
out.glide = Object.freeze({
|
|
1891
|
+
time: finite(raw.time ?? DEFAULT_GLIDE_SECONDS, `${name} glide time`),
|
|
1892
|
+
mode,
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
if (input.pedal !== undefined) {
|
|
1896
|
+
if (!Array.isArray(input.pedal) || input.pedal.length > 1024)
|
|
1897
|
+
throw new DawgSdkError(
|
|
1898
|
+
`track ${name}: pedal must be at most 1024 [beat, "down" | "half" | "up"] events`,
|
|
1899
|
+
);
|
|
1900
|
+
if (input.pedal.length > 0)
|
|
1901
|
+
out.pedal = Object.freeze(
|
|
1902
|
+
input.pedal.map((event: unknown, index: number) => {
|
|
1903
|
+
if (
|
|
1904
|
+
!Array.isArray(event) ||
|
|
1905
|
+
event.length !== 2 ||
|
|
1906
|
+
!["down", "half", "up"].includes(event[1] as string)
|
|
1907
|
+
)
|
|
1908
|
+
throw new DawgSdkError(
|
|
1909
|
+
`track ${name}: pedal[${index}] must be [beat, "down" | "half" | "up"]`,
|
|
1910
|
+
);
|
|
1911
|
+
return Object.freeze([
|
|
1912
|
+
beat(event[0], `${name} pedal[${index}] beat`),
|
|
1913
|
+
event[1] as PedalState,
|
|
1914
|
+
] as const);
|
|
1915
|
+
}),
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
if (input.velocityCurve !== undefined) {
|
|
1919
|
+
const raw =
|
|
1920
|
+
typeof input.velocityCurve === "string"
|
|
1921
|
+
? { curve: input.velocityCurve }
|
|
1922
|
+
: input.velocityCurve;
|
|
1923
|
+
if (
|
|
1924
|
+
!isRecord(raw) ||
|
|
1925
|
+
!["linear", "soft", "hard", "fixed"].includes(raw.curve as string)
|
|
1926
|
+
)
|
|
1927
|
+
throw new DawgSdkError(
|
|
1928
|
+
`track ${name}: velocityCurve must be linear, soft, hard or fixed`,
|
|
1929
|
+
);
|
|
1930
|
+
if (raw.curve === "fixed")
|
|
1931
|
+
out.velocityCurve = Object.freeze({
|
|
1932
|
+
curve: "fixed",
|
|
1933
|
+
fixed: unit(
|
|
1934
|
+
raw.fixed ?? DEFAULT_FIXED_VELOCITY,
|
|
1935
|
+
`${name} velocityCurve fixed`,
|
|
1936
|
+
),
|
|
1937
|
+
});
|
|
1938
|
+
else if (raw.curve !== "linear")
|
|
1939
|
+
out.velocityCurve = Object.freeze({ curve: raw.curve });
|
|
1940
|
+
}
|
|
1941
|
+
if (input.humanize !== undefined) {
|
|
1942
|
+
if (!isRecord(input.humanize))
|
|
1943
|
+
throw new DawgSdkError(
|
|
1944
|
+
`track ${name}: humanize must be { timing, velocity, length, seed }`,
|
|
1945
|
+
);
|
|
1946
|
+
const amount = (key: "timing" | "velocity" | "length") => {
|
|
1947
|
+
const value = input.humanize![key];
|
|
1948
|
+
return value === undefined ? 0 : finite(value, `${name} humanize ${key}`);
|
|
1949
|
+
};
|
|
1950
|
+
const timing = amount("timing");
|
|
1951
|
+
const velocity = amount("velocity");
|
|
1952
|
+
const length = amount("length");
|
|
1953
|
+
const seed = input.humanize.seed ?? 1;
|
|
1954
|
+
if (!Number.isInteger(seed) || seed < 0)
|
|
1955
|
+
throw new DawgSdkError(
|
|
1956
|
+
`track ${name}: humanize seed must be an integer ≥ 0`,
|
|
1957
|
+
);
|
|
1958
|
+
if (timing !== 0 || velocity !== 0 || length !== 0)
|
|
1959
|
+
out.humanize = Object.freeze({
|
|
1960
|
+
...(timing !== 0 ? { timing } : {}),
|
|
1961
|
+
...(velocity !== 0 ? { velocity } : {}),
|
|
1962
|
+
...(length !== 0 ? { length } : {}),
|
|
1963
|
+
seed,
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
return out;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1566
1969
|
/**
|
|
1567
1970
|
* A raw ZzFX parameter array (Strudel `zzfx([...])`, ZzFX's own layout:
|
|
1568
1971
|
* volume, randomness, frequency, attack, sustain, release, shape,
|
|
@@ -1703,13 +2106,8 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
1703
2106
|
? `track ${name}: unknown sampler voice "${spec.voice}" (${[...slots.keys()].join(" ")})`
|
|
1704
2107
|
: `track ${name}: hit("${spec.voice}") needs instrument "kit" or sampler(...)`,
|
|
1705
2108
|
);
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
pitch,
|
|
1709
|
-
start: spec.start,
|
|
1710
|
-
length: spec.length,
|
|
1711
|
-
velocity: spec.velocity,
|
|
1712
|
-
});
|
|
2109
|
+
const { kind: _kind, voice: _voice, ...rest } = spec;
|
|
2110
|
+
return Object.freeze({ ...rest, kind: "note" as const, pitch });
|
|
1713
2111
|
});
|
|
1714
2112
|
if (notes.length > 4096)
|
|
1715
2113
|
throw new DawgSdkError(`track ${name}: at most 4096 notes`);
|
|
@@ -1834,9 +2232,74 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
1834
2232
|
notes: Object.freeze(notes),
|
|
1835
2233
|
rhythm: Object.freeze([...rhythm]),
|
|
1836
2234
|
kit: drumKit === null ? null : drumKit.trim(),
|
|
2235
|
+
...trackTime(input.time, name),
|
|
2236
|
+
...trackPerformance(input, name),
|
|
2237
|
+
tuning: tuningSpec(input.tuning, `track ${name}`),
|
|
1837
2238
|
});
|
|
1838
2239
|
}
|
|
1839
2240
|
|
|
2241
|
+
function trackTime(input: unknown, name: string): { time?: TrackTimeInput } {
|
|
2242
|
+
if (input === undefined || input === null) return {};
|
|
2243
|
+
if (!isRecord(input))
|
|
2244
|
+
throw new DawgSdkError(`track ${name}: time must be an object`);
|
|
2245
|
+
for (const key of Object.keys(input))
|
|
2246
|
+
if (key !== "rate" && key !== "phase" && key !== "cycle" && key !== "steps")
|
|
2247
|
+
throw new DawgSdkError(
|
|
2248
|
+
`track ${name}: time takes rate, phase, cycle and steps, not ${key}`,
|
|
2249
|
+
);
|
|
2250
|
+
const out: {
|
|
2251
|
+
rate?: number;
|
|
2252
|
+
phase?: number;
|
|
2253
|
+
cycle?: number;
|
|
2254
|
+
steps?: { shift: number; hold: number; drift: number };
|
|
2255
|
+
} = {};
|
|
2256
|
+
if (input.rate !== undefined) {
|
|
2257
|
+
const rate = finite(input.rate, `track ${name} time.rate`);
|
|
2258
|
+
if (rate < 0.125 || rate > 8)
|
|
2259
|
+
throw new DawgSdkError(`track ${name}: time.rate must be 0.125..8`);
|
|
2260
|
+
if (rate !== 1) out.rate = rate;
|
|
2261
|
+
}
|
|
2262
|
+
if (input.phase !== undefined) {
|
|
2263
|
+
const phase = finite(input.phase, `track ${name} time.phase`);
|
|
2264
|
+
if (phase !== 0) out.phase = phase;
|
|
2265
|
+
}
|
|
2266
|
+
if (input.cycle !== undefined) {
|
|
2267
|
+
const cycle = finite(input.cycle, `track ${name} time.cycle`);
|
|
2268
|
+
if (cycle <= 0)
|
|
2269
|
+
throw new DawgSdkError(`track ${name}: time.cycle must be > 0 beats`);
|
|
2270
|
+
out.cycle = cycle;
|
|
2271
|
+
}
|
|
2272
|
+
if (input.steps !== undefined) {
|
|
2273
|
+
if (!isRecord(input.steps))
|
|
2274
|
+
throw new DawgSdkError(`track ${name}: time.steps must be an object`);
|
|
2275
|
+
if (out.cycle === undefined)
|
|
2276
|
+
throw new DawgSdkError(`track ${name}: time.steps needs a cycle`);
|
|
2277
|
+
if (out.rate !== undefined)
|
|
2278
|
+
throw new DawgSdkError(
|
|
2279
|
+
`track ${name}: time takes rate or steps, not both`,
|
|
2280
|
+
);
|
|
2281
|
+
out.steps = phaseSteps(input.steps, out.cycle, `track ${name} time.steps`);
|
|
2282
|
+
}
|
|
2283
|
+
return Object.keys(out).length > 0 ? { time: Object.freeze(out) } : {};
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function phaseSteps(
|
|
2287
|
+
input: Record<string, unknown>,
|
|
2288
|
+
cycle: number,
|
|
2289
|
+
label: string,
|
|
2290
|
+
): Readonly<{ shift: number; hold: number; drift: number }> {
|
|
2291
|
+
const shift = positive(input.shift, `${label}.shift`);
|
|
2292
|
+
if (shift > cycle)
|
|
2293
|
+
throw new DawgSdkError(`${label}.shift must be at most the cycle`);
|
|
2294
|
+
const hold = finite(input.hold, `${label}.hold`);
|
|
2295
|
+
if (!Number.isInteger(hold) || hold < 0 || hold > 64)
|
|
2296
|
+
throw new DawgSdkError(`${label}.hold must be a whole 0..64 cycles`);
|
|
2297
|
+
const drift = finite(input.drift, `${label}.drift`);
|
|
2298
|
+
if (!Number.isInteger(drift) || drift < 1 || drift > 64)
|
|
2299
|
+
throw new DawgSdkError(`${label}.drift must be a whole 1..64 cycles`);
|
|
2300
|
+
return Object.freeze({ shift, hold, drift });
|
|
2301
|
+
}
|
|
2302
|
+
|
|
1840
2303
|
const AUTOMATION_KEYS: readonly (keyof AutomationInput)[] = Object.freeze([
|
|
1841
2304
|
"volume",
|
|
1842
2305
|
"pan",
|
|
@@ -2018,19 +2481,103 @@ function localizeSampler(spec: SamplerSpec, slug: string): SamplerSpec {
|
|
|
2018
2481
|
export type SongInput = Readonly<{
|
|
2019
2482
|
/** BPM 20..300, default 120. */
|
|
2020
2483
|
tempo?: number;
|
|
2021
|
-
/**
|
|
2484
|
+
/**
|
|
2485
|
+
* `[beatsPerBar, noteValue]` or just `beatsPerBar`; default `[4, 4]`.
|
|
2486
|
+
* A note value other than 4 is stored as a meter change at bar 1, so
|
|
2487
|
+
* `[6, 8]` is six eighths (three quarter-note beats) per bar.
|
|
2488
|
+
*/
|
|
2022
2489
|
meter?: readonly [number, number] | number;
|
|
2023
2490
|
/** Loop length in bars, 1..256, default 4. */
|
|
2024
2491
|
bars?: number;
|
|
2025
|
-
/**
|
|
2492
|
+
/**
|
|
2493
|
+
* Free text such as `"A minor"`, or null. A scale name after the tonic
|
|
2494
|
+
* picks a scale: `"D dorian"`, `"E hijaz"`, `"C yaman"`, `"C messiaen-3"`.
|
|
2495
|
+
*/
|
|
2026
2496
|
key?: string | null;
|
|
2497
|
+
/**
|
|
2498
|
+
* Song tuning (SDK 1.16.0), default 12-TET at A4 = 440 Hz: a library name
|
|
2499
|
+
* (`"19-edo"`, `"just"`, `"pelog"`, `"yaman"`) or `{ edo, ratios, cents,
|
|
2500
|
+
* scl, kbm, ref, root, map }`. See `TuningInput`.
|
|
2501
|
+
*/
|
|
2502
|
+
tuning?: TuningInput | null;
|
|
2027
2503
|
/** Integer ticks per beat, default 480. Leave it alone unless you know why. */
|
|
2028
2504
|
ticksPerBeat?: number;
|
|
2505
|
+
/**
|
|
2506
|
+
* Tempo changes, meter changes and fermatas (SDK 1.14.0), in any order:
|
|
2507
|
+
* `tempo()`, `ramp()`, `rit()`, `accel()`, `fermata()` and `meter()`.
|
|
2508
|
+
* `tempo` above stays the opening tempo and `meter` the opening meter.
|
|
2509
|
+
* `rit()` and `accel()` return two marks; list them as they come.
|
|
2510
|
+
*/
|
|
2511
|
+
time?: readonly (TimeMark | readonly TimeMark[])[];
|
|
2029
2512
|
/** Tracks in score order; each from `track()`. */
|
|
2030
2513
|
tracks: readonly TrackSpec[];
|
|
2514
|
+
/** Master chain and loudness target after every track and orbit bus (SDK 1.17.0); omit for none. */
|
|
2515
|
+
master?: MasterInput;
|
|
2516
|
+
/**
|
|
2517
|
+
* Named bar ranges (SDK 1.18.0): `{ name: "chorus", startBar: 8, bars: 8 }`,
|
|
2518
|
+
* optionally with `mute: ["pad"]` and `vary: { lead: { transpose: 12 } }`.
|
|
2519
|
+
*/
|
|
2520
|
+
sections?: readonly SongSection[];
|
|
2521
|
+
/**
|
|
2522
|
+
* The order sections play, with repeats (SDK 1.18.0): `"intro verse
|
|
2523
|
+
* chorus*2 outro"`, or `["intro", { section: "chorus", repeat: 2 }]`.
|
|
2524
|
+
* Absent plays the bars straight through.
|
|
2525
|
+
*/
|
|
2526
|
+
form?: string | readonly (string | SongFormEntry)[];
|
|
2527
|
+
/** The section playback loops (SDK 1.18.0); export ignores it. */
|
|
2528
|
+
loopSection?: string;
|
|
2529
|
+
}>;
|
|
2530
|
+
|
|
2531
|
+
/** A song section (SDK 1.18.0); bars are 0-based like beats. */
|
|
2532
|
+
export type SongSection = Readonly<{
|
|
2533
|
+
/** `intro`, `verse`, `chorus 2`, `A`: 1..32 characters, unique ignoring case. */
|
|
2534
|
+
name: string;
|
|
2535
|
+
/** First bar, 0-based. */
|
|
2536
|
+
startBar: number;
|
|
2537
|
+
/** Length in bars, at least 1. */
|
|
2538
|
+
bars: number;
|
|
2539
|
+
/** Track ids silent in this section. */
|
|
2540
|
+
mute?: readonly string[];
|
|
2541
|
+
/** Per-track changes in this section: semitones and a velocity multiplier. */
|
|
2542
|
+
vary?: Readonly<
|
|
2543
|
+
Record<string, Readonly<{ transpose?: number; gain?: number }>>
|
|
2544
|
+
>;
|
|
2545
|
+
}>;
|
|
2546
|
+
|
|
2547
|
+
/** One step of the song form (SDK 1.18.0). */
|
|
2548
|
+
export type SongFormEntry = Readonly<{ section: string; repeat?: number }>;
|
|
2549
|
+
|
|
2550
|
+
/**
|
|
2551
|
+
* The song master (SDK 1.17.0), processed in the fixed order
|
|
2552
|
+
* eq → glue → tape → width → limiter after the tracks and orbit buses are
|
|
2553
|
+
* summed. Each unit present is on; `{}` takes every default. `target` is
|
|
2554
|
+
* an integrated loudness in LUFS (ITU-R BS.1770-4), -40..-3: renders drive
|
|
2555
|
+
* the limiter (or, without one, a clean gain) to reach it. Streaming is
|
|
2556
|
+
* -14, club -8, loud hyperpop or gabber -6, classical -20, broadcast -23.
|
|
2557
|
+
* The SDK takes LUFS numbers only: a target name such as `master target
|
|
2558
|
+
* club` in the prompt also sets a limiter preset, so write that unit out.
|
|
2559
|
+
* DAWG.md "Master and loudness" lists every parameter with its range.
|
|
2560
|
+
*
|
|
2561
|
+
* ```ts
|
|
2562
|
+
* master: { glue: { ratio: 2 }, limiter: { ceiling: -1 }, target: -14 }
|
|
2563
|
+
* ```
|
|
2564
|
+
*/
|
|
2565
|
+
export type MasterInput = Readonly<{
|
|
2566
|
+
/** `low`/`high` shelves and `bell1`/`bell2` gains in dB, with `…freq` and `…q`. */
|
|
2567
|
+
eq?: EffectParams;
|
|
2568
|
+
/** Bus compressor: threshold, ratio, attack, release (ms), knee, makeup, mix, hpf. */
|
|
2569
|
+
glue?: EffectParams;
|
|
2570
|
+
/** Saturation: drive (dB), bias, tone (Hz), mix. */
|
|
2571
|
+
tape?: EffectParams;
|
|
2572
|
+
/** Stereo width 0..2 (1 unchanged) and `mono` bass below this many Hz. */
|
|
2573
|
+
width?: EffectParams;
|
|
2574
|
+
/** True-peak limiter: ceiling (dBTP), gain, release, lookahead (ms), truepeak. */
|
|
2575
|
+
limiter?: EffectParams;
|
|
2576
|
+
/** Integrated loudness target in LUFS (a negative number, -40..-3). */
|
|
2577
|
+
target?: number;
|
|
2031
2578
|
}>;
|
|
2032
2579
|
|
|
2033
|
-
/** A stored note: integer ticks. */
|
|
2580
|
+
/** A stored note: integer ticks; expression fields only when set. */
|
|
2034
2581
|
export type ScoreNote = Readonly<{
|
|
2035
2582
|
id: string;
|
|
2036
2583
|
trackId: string;
|
|
@@ -2038,6 +2585,13 @@ export type ScoreNote = Readonly<{
|
|
|
2038
2585
|
durationTicks: number;
|
|
2039
2586
|
pitch: number;
|
|
2040
2587
|
velocity: number;
|
|
2588
|
+
articulation?: Articulation;
|
|
2589
|
+
glide?: number;
|
|
2590
|
+
bend?: readonly Readonly<{ at: number; cents: number }>[];
|
|
2591
|
+
vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
|
|
2592
|
+
humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
|
|
2593
|
+
/** Static cents offset (SDK 1.16.0); absent is 0. */
|
|
2594
|
+
cents?: number;
|
|
2041
2595
|
}>;
|
|
2042
2596
|
|
|
2043
2597
|
/** A stored automation point: integer tick. */
|
|
@@ -2094,8 +2648,21 @@ export type ScoreTrack = Readonly<{
|
|
|
2094
2648
|
rhythm?: readonly Readonly<Record<string, unknown>>[];
|
|
2095
2649
|
/** Synth kit name; dawg validates it. */
|
|
2096
2650
|
kit?: string;
|
|
2651
|
+
/** `rate`, plus `phase` and `cycle` in ticks. */
|
|
2652
|
+
time?: Readonly<{
|
|
2653
|
+
rate?: number;
|
|
2654
|
+
phase?: number;
|
|
2655
|
+
cycle?: number;
|
|
2656
|
+
steps?: Readonly<{ shift: number; hold: number; drift: number }>;
|
|
2657
|
+
}>;
|
|
2658
|
+
/** Track tuning; dawg validates it (SDK 1.16.0). */
|
|
2659
|
+
tuning?: ScoreTuning;
|
|
2097
2660
|
wavetable?: Readonly<{ table: ScoreSampleRef } & WavetableParams>;
|
|
2098
2661
|
wtAutomation?: readonly ScorePoint[];
|
|
2662
|
+
glide?: TrackSpec["glide"];
|
|
2663
|
+
pedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
|
|
2664
|
+
velocityCurve?: TrackSpec["velocityCurve"];
|
|
2665
|
+
humanize?: TrackSpec["humanize"];
|
|
2099
2666
|
}>;
|
|
2100
2667
|
|
|
2101
2668
|
/**
|
|
@@ -2111,10 +2678,167 @@ export type Song = Readonly<{
|
|
|
2111
2678
|
bars: number;
|
|
2112
2679
|
ticksPerBeat: number;
|
|
2113
2680
|
key: string | null;
|
|
2681
|
+
/** Present only when `song({ time })` has marks. */
|
|
2682
|
+
time?: ScoreTime;
|
|
2683
|
+
/** Present only when the song sets one (SDK 1.16.0). */
|
|
2684
|
+
tuning?: ScoreTuning;
|
|
2114
2685
|
tracks: readonly ScoreTrack[];
|
|
2115
2686
|
notes: readonly ScoreNote[];
|
|
2687
|
+
master?: MasterInput;
|
|
2688
|
+
/** Present only when the song has sections (SDK 1.18.0). */
|
|
2689
|
+
sections?: readonly SongSection[];
|
|
2690
|
+
/** Present only when the song has a form (SDK 1.18.0). */
|
|
2691
|
+
form?: readonly SongFormEntry[];
|
|
2692
|
+
/** Present only when a section loops (SDK 1.18.0). */
|
|
2693
|
+
loopSection?: string;
|
|
2116
2694
|
}>;
|
|
2117
2695
|
|
|
2696
|
+
/** A stored song `time`: ticks, and 0-based bar indexes. */
|
|
2697
|
+
export type ScoreTime = Readonly<{
|
|
2698
|
+
tempo?: readonly Readonly<{
|
|
2699
|
+
tick: number;
|
|
2700
|
+
bpm: number;
|
|
2701
|
+
ramp?: "linear" | "exp";
|
|
2702
|
+
}>[];
|
|
2703
|
+
meter?: readonly Readonly<{
|
|
2704
|
+
bar: number;
|
|
2705
|
+
beatsPerBar: number;
|
|
2706
|
+
beatUnit?: number;
|
|
2707
|
+
}>[];
|
|
2708
|
+
fermatas?: readonly Readonly<{ tick: number; beats: number }>[];
|
|
2709
|
+
}>;
|
|
2710
|
+
|
|
2711
|
+
const MASTER_KEYS = ["eq", "glue", "tape", "width", "limiter", "target"];
|
|
2712
|
+
|
|
2713
|
+
/** Shape checks only; dawg validates every value when it loads the song. */
|
|
2714
|
+
function masterData(input: unknown): MasterInput | undefined {
|
|
2715
|
+
if (input === undefined || input === null) return undefined;
|
|
2716
|
+
if (!isRecord(input)) throw new DawgSdkError("song master must be an object");
|
|
2717
|
+
const out: Record<string, unknown> = {};
|
|
2718
|
+
for (const [key, value] of Object.entries(input)) {
|
|
2719
|
+
if (value === undefined) continue;
|
|
2720
|
+
if (!MASTER_KEYS.includes(key))
|
|
2721
|
+
throw new DawgSdkError(
|
|
2722
|
+
`song master has no "${key}"; use ${MASTER_KEYS.join(", ")}`,
|
|
2723
|
+
);
|
|
2724
|
+
if (key === "target") {
|
|
2725
|
+
if (typeof value === "string")
|
|
2726
|
+
throw new DawgSdkError(
|
|
2727
|
+
`song master target takes LUFS, e.g. target: -14 (streaming), -8 (club, with limiter: { release: 60, lookahead: 2 }), -6 (loud, with limiter: { release: 20, lookahead: 1 }); got "${value}"`,
|
|
2728
|
+
);
|
|
2729
|
+
out.target = finite(value as number, "song master target");
|
|
2730
|
+
continue;
|
|
2731
|
+
}
|
|
2732
|
+
if (!isRecord(value))
|
|
2733
|
+
throw new DawgSdkError(`song master ${key} must be an object`);
|
|
2734
|
+
out[key] = Object.freeze({ ...value });
|
|
2735
|
+
}
|
|
2736
|
+
return Object.keys(out).length > 0
|
|
2737
|
+
? (Object.freeze(out) as MasterInput)
|
|
2738
|
+
: undefined;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
/** `"intro verse chorus*2"` (comma separated when a name has a space). */
|
|
2742
|
+
function parseSongForm(
|
|
2743
|
+
form: NonNullable<SongInput["form"]>,
|
|
2744
|
+
): readonly SongFormEntry[] {
|
|
2745
|
+
const items: (string | SongFormEntry)[] =
|
|
2746
|
+
typeof form === "string"
|
|
2747
|
+
? (form.includes(",") ? form.split(",") : form.trim().split(/\s+/u))
|
|
2748
|
+
.map((item) => item.trim())
|
|
2749
|
+
.filter((item) => item !== "")
|
|
2750
|
+
: Array.isArray(form)
|
|
2751
|
+
? [...form]
|
|
2752
|
+
: (() => {
|
|
2753
|
+
throw new DawgSdkError(
|
|
2754
|
+
"song form must be a string or an array of section names",
|
|
2755
|
+
);
|
|
2756
|
+
})();
|
|
2757
|
+
return Object.freeze(
|
|
2758
|
+
items.map((item, index) => {
|
|
2759
|
+
let entry: unknown = item;
|
|
2760
|
+
if (typeof item === "string") {
|
|
2761
|
+
const match = /^(.*?)\s*(?:\*|\bx|×)\s*(\d+)$/iu.exec(item);
|
|
2762
|
+
entry =
|
|
2763
|
+
match && match[1]!.length > 0
|
|
2764
|
+
? { section: match[1]!, repeat: Number(match[2]) }
|
|
2765
|
+
: { section: item };
|
|
2766
|
+
}
|
|
2767
|
+
if (!isRecord(entry) || typeof entry.section !== "string")
|
|
2768
|
+
throw new DawgSdkError(
|
|
2769
|
+
`song form[${index}] must be a section name or { section, repeat }`,
|
|
2770
|
+
);
|
|
2771
|
+
const repeat = entry.repeat ?? 1;
|
|
2772
|
+
if (
|
|
2773
|
+
!Number.isInteger(repeat) ||
|
|
2774
|
+
(repeat as number) < 1 ||
|
|
2775
|
+
(repeat as number) > 16
|
|
2776
|
+
)
|
|
2777
|
+
throw new DawgSdkError(`song form[${index}] repeat must be 1..16`);
|
|
2778
|
+
return Object.freeze(
|
|
2779
|
+
repeat === 1
|
|
2780
|
+
? { section: entry.section }
|
|
2781
|
+
: { section: entry.section, repeat: repeat as number },
|
|
2782
|
+
);
|
|
2783
|
+
}),
|
|
2784
|
+
);
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
function songSections(
|
|
2788
|
+
sections: NonNullable<SongInput["sections"]>,
|
|
2789
|
+
): readonly SongSection[] {
|
|
2790
|
+
if (!Array.isArray(sections))
|
|
2791
|
+
throw new DawgSdkError("song sections must be an array");
|
|
2792
|
+
if (sections.length > 64) throw new DawgSdkError("song has over 64 sections");
|
|
2793
|
+
return Object.freeze(
|
|
2794
|
+
sections.map((section, index) => {
|
|
2795
|
+
const where = `song sections[${index}]`;
|
|
2796
|
+
if (!isRecord(section) || typeof section.name !== "string")
|
|
2797
|
+
throw new DawgSdkError(`${where} needs a name`);
|
|
2798
|
+
const stored: Record<string, unknown> = {
|
|
2799
|
+
name: section.name,
|
|
2800
|
+
startBar: finite(section.startBar, `${where}.startBar`),
|
|
2801
|
+
bars: finite(section.bars, `${where}.bars`),
|
|
2802
|
+
};
|
|
2803
|
+
if (section.mute !== undefined) {
|
|
2804
|
+
if (
|
|
2805
|
+
!Array.isArray(section.mute) ||
|
|
2806
|
+
section.mute.some((id) => typeof id !== "string")
|
|
2807
|
+
)
|
|
2808
|
+
throw new DawgSdkError(`${where}.mute must be track ids`);
|
|
2809
|
+
if (section.mute.length > 0)
|
|
2810
|
+
stored.mute = Object.freeze([...section.mute]);
|
|
2811
|
+
}
|
|
2812
|
+
if (section.vary !== undefined) {
|
|
2813
|
+
if (!isRecord(section.vary))
|
|
2814
|
+
throw new DawgSdkError(`${where}.vary must be an object`);
|
|
2815
|
+
const vary = Object.entries(section.vary);
|
|
2816
|
+
if (vary.length > 0)
|
|
2817
|
+
stored.vary = Object.freeze(
|
|
2818
|
+
Object.fromEntries(
|
|
2819
|
+
vary.map(([id, change]) => {
|
|
2820
|
+
if (!isRecord(change))
|
|
2821
|
+
throw new DawgSdkError(
|
|
2822
|
+
`${where}.vary.${id} must be an object`,
|
|
2823
|
+
);
|
|
2824
|
+
const out: Record<string, number> = {};
|
|
2825
|
+
if (change.transpose !== undefined)
|
|
2826
|
+
out.transpose = finite(
|
|
2827
|
+
change.transpose,
|
|
2828
|
+
`${where}.vary.${id}.transpose`,
|
|
2829
|
+
);
|
|
2830
|
+
if (change.gain !== undefined)
|
|
2831
|
+
out.gain = finite(change.gain, `${where}.vary.${id}.gain`);
|
|
2832
|
+
return [id, Object.freeze(out)];
|
|
2833
|
+
}),
|
|
2834
|
+
),
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
return Object.freeze(stored) as SongSection;
|
|
2838
|
+
}),
|
|
2839
|
+
);
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2118
2842
|
/**
|
|
2119
2843
|
* Assemble the song. Beats become ticks (`Math.round(beat * ticksPerBeat)`,
|
|
2120
2844
|
* lengths at least one tick), and every note gets a deterministic id from
|
|
@@ -2127,7 +2851,11 @@ export function song(input: SongInput): Song {
|
|
|
2127
2851
|
const beatsPerBar = Array.isArray(meter)
|
|
2128
2852
|
? finite(meter[0], "song meter[0]")
|
|
2129
2853
|
: finite(meter as number, "song meter");
|
|
2130
|
-
|
|
2854
|
+
const beatUnit = Array.isArray(meter) ? finite(meter[1], "song meter[1]") : 4;
|
|
2855
|
+
if (![1, 2, 4, 8, 16, 32].includes(beatUnit))
|
|
2856
|
+
throw new DawgSdkError(
|
|
2857
|
+
"song meter note value must be 1, 2, 4, 8, 16 or 32",
|
|
2858
|
+
);
|
|
2131
2859
|
const bars = finite(input.bars ?? 4, "song bars");
|
|
2132
2860
|
const ticksPerBeat = input.ticksPerBeat ?? DEFAULT_TICKS_PER_BEAT;
|
|
2133
2861
|
if (
|
|
@@ -2139,6 +2867,8 @@ export function song(input: SongInput): Song {
|
|
|
2139
2867
|
const key = input.key ?? null;
|
|
2140
2868
|
if (key !== null && typeof key !== "string")
|
|
2141
2869
|
throw new DawgSdkError("song key must be a string or null");
|
|
2870
|
+
const songTuning = tuningSpec(input.tuning, "song");
|
|
2871
|
+
const master = masterData(input.master);
|
|
2142
2872
|
if (!Array.isArray(input.tracks))
|
|
2143
2873
|
throw new DawgSdkError("song tracks must be an array of track()");
|
|
2144
2874
|
if (input.tracks.length > 64)
|
|
@@ -2202,6 +2932,36 @@ export function song(input: SongInput): Song {
|
|
|
2202
2932
|
mode: t.sampler.mode,
|
|
2203
2933
|
});
|
|
2204
2934
|
if (t.kit) stored.kit = t.kit;
|
|
2935
|
+
if (t.time) {
|
|
2936
|
+
const time: Record<string, unknown> = {};
|
|
2937
|
+
if (t.time.rate !== undefined) time.rate = t.time.rate;
|
|
2938
|
+
if (t.time.phase !== undefined) {
|
|
2939
|
+
const phase = ticks(t.time.phase);
|
|
2940
|
+
if (phase !== 0) time.phase = phase;
|
|
2941
|
+
}
|
|
2942
|
+
if (t.time.cycle !== undefined)
|
|
2943
|
+
time.cycle = Math.max(1, ticks(t.time.cycle));
|
|
2944
|
+
if (t.time.steps)
|
|
2945
|
+
time.steps = Object.freeze({
|
|
2946
|
+
...t.time.steps,
|
|
2947
|
+
shift: Math.max(1, ticks(t.time.steps.shift)),
|
|
2948
|
+
});
|
|
2949
|
+
if (Object.keys(time).length > 0) stored.time = Object.freeze(time);
|
|
2950
|
+
}
|
|
2951
|
+
if (t.glide) stored.glide = t.glide;
|
|
2952
|
+
if (t.pedal && t.pedal.length > 0) {
|
|
2953
|
+
// One event per tick (the last wins), in tick order, as dawg stores it.
|
|
2954
|
+
const byTick = new Map<number, PedalState>();
|
|
2955
|
+
for (const [at, state] of t.pedal) byTick.set(ticks(at), state);
|
|
2956
|
+
stored.pedal = Object.freeze(
|
|
2957
|
+
[...byTick.entries()]
|
|
2958
|
+
.sort((a, b) => a[0] - b[0])
|
|
2959
|
+
.map(([tick, state]) => Object.freeze({ tick, state })),
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
if (t.velocityCurve) stored.velocityCurve = t.velocityCurve;
|
|
2963
|
+
if (t.humanize) stored.humanize = t.humanize;
|
|
2964
|
+
if (t.tuning) stored.tuning = t.tuning;
|
|
2205
2965
|
if (t.rhythm && t.rhythm.length > 0)
|
|
2206
2966
|
stored.rhythm = Object.freeze(
|
|
2207
2967
|
t.rhythm.map((row) => {
|
|
@@ -2228,10 +2988,42 @@ export function song(input: SongInput): Song {
|
|
|
2228
2988
|
durationTicks,
|
|
2229
2989
|
pitch: n.pitch,
|
|
2230
2990
|
velocity: n.velocity,
|
|
2991
|
+
...(n.articulation ? { articulation: n.articulation } : {}),
|
|
2992
|
+
...(n.glide !== undefined ? { glide: n.glide } : {}),
|
|
2993
|
+
...(n.bend
|
|
2994
|
+
? {
|
|
2995
|
+
bend: Object.freeze(
|
|
2996
|
+
[...n.bend]
|
|
2997
|
+
.sort((a, b) => a[0] - b[0])
|
|
2998
|
+
.map(([at, cents]) => Object.freeze({ at, cents })),
|
|
2999
|
+
),
|
|
3000
|
+
}
|
|
3001
|
+
: {}),
|
|
3002
|
+
...(n.vibrato ? { vibrato: n.vibrato } : {}),
|
|
3003
|
+
...(n.humanize ? { humanize: n.humanize } : {}),
|
|
3004
|
+
...(n.cents ? { cents: n.cents } : {}),
|
|
2231
3005
|
}),
|
|
2232
3006
|
);
|
|
2233
3007
|
}
|
|
2234
3008
|
});
|
|
3009
|
+
const arrangement: {
|
|
3010
|
+
sections?: readonly SongSection[];
|
|
3011
|
+
form?: readonly SongFormEntry[];
|
|
3012
|
+
loopSection?: string;
|
|
3013
|
+
} = {};
|
|
3014
|
+
if (input.sections !== undefined) {
|
|
3015
|
+
const sections = songSections(input.sections);
|
|
3016
|
+
if (sections.length > 0) arrangement.sections = sections;
|
|
3017
|
+
}
|
|
3018
|
+
if (input.form !== undefined) {
|
|
3019
|
+
const form = parseSongForm(input.form);
|
|
3020
|
+
if (form.length > 0) arrangement.form = form;
|
|
3021
|
+
}
|
|
3022
|
+
if (input.loopSection !== undefined) {
|
|
3023
|
+
if (typeof input.loopSection !== "string")
|
|
3024
|
+
throw new DawgSdkError("song loopSection must be a section name");
|
|
3025
|
+
arrangement.loopSection = input.loopSection;
|
|
3026
|
+
}
|
|
2235
3027
|
return Object.freeze({
|
|
2236
3028
|
format: "track.loop/v1",
|
|
2237
3029
|
version: 1,
|
|
@@ -2240,11 +3032,624 @@ export function song(input: SongInput): Song {
|
|
|
2240
3032
|
bars,
|
|
2241
3033
|
ticksPerBeat,
|
|
2242
3034
|
key,
|
|
3035
|
+
...songTime(
|
|
3036
|
+
input.time,
|
|
3037
|
+
tempoBpm,
|
|
3038
|
+
ticks,
|
|
3039
|
+
beatsPerBar,
|
|
3040
|
+
ticksPerBeat,
|
|
3041
|
+
beatUnit,
|
|
3042
|
+
bars,
|
|
3043
|
+
),
|
|
3044
|
+
...(songTuning ? { tuning: songTuning } : {}),
|
|
2243
3045
|
tracks: Object.freeze(tracks),
|
|
2244
3046
|
notes: Object.freeze(notes),
|
|
3047
|
+
...(master ? { master } : {}),
|
|
3048
|
+
...arrangement,
|
|
2245
3049
|
});
|
|
2246
3050
|
}
|
|
2247
3051
|
|
|
3052
|
+
// ---------------------------------------------------------------------------
|
|
3053
|
+
// Time (SDK 1.14.0)
|
|
3054
|
+
|
|
3055
|
+
/** One entry of `song({ time })`; build them with the helpers below. */
|
|
3056
|
+
export type TimeMark =
|
|
3057
|
+
| Readonly<{
|
|
3058
|
+
kind: "tempo";
|
|
3059
|
+
/** Beat the change lands on. */
|
|
3060
|
+
at: number;
|
|
3061
|
+
/** Absent: keep the tempo in effect there, so a ramp can start from it. */
|
|
3062
|
+
bpm?: number;
|
|
3063
|
+
/** Glide into `bpm` from the previous mark instead of stepping. */
|
|
3064
|
+
ramp?: "linear" | "exp";
|
|
3065
|
+
/** Set by `rit()`/`accel()`: the direction `song()` checks. */
|
|
3066
|
+
gradual?: "rit" | "accel";
|
|
3067
|
+
/**
|
|
3068
|
+
* Set by `aTempo()` (the tempo before the last rit/accel) and
|
|
3069
|
+
* `tempoPrimo()` (the song's opening tempo) instead of `bpm`.
|
|
3070
|
+
*/
|
|
3071
|
+
back?: "a-tempo" | "primo";
|
|
3072
|
+
}>
|
|
3073
|
+
| Readonly<{
|
|
3074
|
+
kind: "meter";
|
|
3075
|
+
/** Beat of the bar line where the meter starts. */
|
|
3076
|
+
at: number;
|
|
3077
|
+
beatsPerBar: number;
|
|
3078
|
+
beatUnit: number;
|
|
3079
|
+
}>
|
|
3080
|
+
| Readonly<{
|
|
3081
|
+
kind: "fermata";
|
|
3082
|
+
at: number;
|
|
3083
|
+
/** Extra beats the held beat lasts. */
|
|
3084
|
+
beats: number;
|
|
3085
|
+
}>;
|
|
3086
|
+
|
|
3087
|
+
/** `ramp()` curves: `linear` adds the same BPM each beat, `exp` the same ratio. */
|
|
3088
|
+
export type TempoCurve = "linear" | "exp";
|
|
3089
|
+
|
|
3090
|
+
/**
|
|
3091
|
+
* Tempo change at beat `at`: `tempo(32, 140)`. Omit `bpm` to pin the tempo
|
|
3092
|
+
* in effect there, the start of a ramp.
|
|
3093
|
+
*/
|
|
3094
|
+
export function tempo(at: number, bpm?: number): TimeMark {
|
|
3095
|
+
const start = beat(at, "tempo() at");
|
|
3096
|
+
if (start <= 0)
|
|
3097
|
+
throw new DawgSdkError("tempo() at must be > 0; song({ tempo }) is beat 0");
|
|
3098
|
+
if (bpm === undefined) return Object.freeze({ kind: "tempo", at: start });
|
|
3099
|
+
return Object.freeze({ kind: "tempo", at: start, bpm: songBpm(bpm) });
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
/**
|
|
3103
|
+
* `a tempo` at beat `at` (SDK 1.19.0): step back to the tempo in effect
|
|
3104
|
+
* before the last `rit()`/`accel()` (or ramp) ending before `at`.
|
|
3105
|
+
*/
|
|
3106
|
+
export function aTempo(at: number): TimeMark {
|
|
3107
|
+
const start = beat(at, "aTempo() at");
|
|
3108
|
+
if (start <= 0) throw new DawgSdkError("aTempo() at must be > 0");
|
|
3109
|
+
return Object.freeze({ kind: "tempo", at: start, back: "a-tempo" });
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
/** `tempo primo` at beat `at` (SDK 1.19.0): step back to `song({ tempo })`. */
|
|
3113
|
+
export function tempoPrimo(at: number): TimeMark {
|
|
3114
|
+
const start = beat(at, "tempoPrimo() at");
|
|
3115
|
+
if (start <= 0) throw new DawgSdkError("tempoPrimo() at must be > 0");
|
|
3116
|
+
return Object.freeze({ kind: "tempo", at: start, back: "primo" });
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
/**
|
|
3120
|
+
* Glide from the previous tempo mark (or the song's opening tempo) to `bpm`
|
|
3121
|
+
* at beat `at`: `ramp(64, 90)`. `exp` changes by the same ratio each beat.
|
|
3122
|
+
*/
|
|
3123
|
+
export function ramp(
|
|
3124
|
+
at: number,
|
|
3125
|
+
bpm: number,
|
|
3126
|
+
curve: TempoCurve = "linear",
|
|
3127
|
+
): TimeMark {
|
|
3128
|
+
const end = beat(at, "ramp() at");
|
|
3129
|
+
if (end <= 0) throw new DawgSdkError("ramp() at must be > 0");
|
|
3130
|
+
return Object.freeze({
|
|
3131
|
+
kind: "tempo",
|
|
3132
|
+
at: end,
|
|
3133
|
+
bpm: songBpm(bpm),
|
|
3134
|
+
ramp: tempoCurve(curve, "ramp()"),
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
/**
|
|
3139
|
+
* Ritardando: slow from the tempo at beat `at` to `bpm` over `beats` beats,
|
|
3140
|
+
* `rit(48, 16, 80)`. Same as `[tempo(at), ramp(at + beats, bpm, curve)]`.
|
|
3141
|
+
*/
|
|
3142
|
+
export function rit(
|
|
3143
|
+
at: number,
|
|
3144
|
+
beats: number,
|
|
3145
|
+
bpm: number,
|
|
3146
|
+
curve: TempoCurve = "linear",
|
|
3147
|
+
): readonly TimeMark[] {
|
|
3148
|
+
return gradual("rit", at, beats, bpm, curve);
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
/** Accelerando: like `rit()`, toward a faster `bpm`. */
|
|
3152
|
+
export function accel(
|
|
3153
|
+
at: number,
|
|
3154
|
+
beats: number,
|
|
3155
|
+
bpm: number,
|
|
3156
|
+
curve: TempoCurve = "linear",
|
|
3157
|
+
): readonly TimeMark[] {
|
|
3158
|
+
return gradual("accel", at, beats, bpm, curve);
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
function gradual(
|
|
3162
|
+
direction: "rit" | "accel",
|
|
3163
|
+
at: number,
|
|
3164
|
+
beats: number,
|
|
3165
|
+
bpm: number,
|
|
3166
|
+
curve: TempoCurve,
|
|
3167
|
+
): readonly TimeMark[] {
|
|
3168
|
+
const label = `${direction}()`;
|
|
3169
|
+
const start = beat(at, `${label} at`);
|
|
3170
|
+
const length = positive(beats, `${label} beats`);
|
|
3171
|
+
const target = songBpm(bpm);
|
|
3172
|
+
const shape = tempoCurve(curve, label);
|
|
3173
|
+
const marks: TimeMark[] = [];
|
|
3174
|
+
if (start > 0) marks.push(Object.freeze({ kind: "tempo", at: start }));
|
|
3175
|
+
marks.push(
|
|
3176
|
+
Object.freeze({
|
|
3177
|
+
kind: "tempo",
|
|
3178
|
+
at: start + length,
|
|
3179
|
+
bpm: target,
|
|
3180
|
+
ramp: shape,
|
|
3181
|
+
gradual: direction,
|
|
3182
|
+
}),
|
|
3183
|
+
);
|
|
3184
|
+
return Object.freeze(marks);
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
/** Fermata: the beat at `at` lasts `beats` extra beats (default 2). */
|
|
3188
|
+
export function fermata(at: number, beats = 2): TimeMark {
|
|
3189
|
+
const hold = positive(beats, "fermata() beats");
|
|
3190
|
+
if (hold > 64) throw new DawgSdkError("fermata() beats must be at most 64");
|
|
3191
|
+
return Object.freeze({
|
|
3192
|
+
kind: "fermata",
|
|
3193
|
+
at: beat(at, "fermata() at"),
|
|
3194
|
+
beats: hold,
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
/**
|
|
3199
|
+
* Meter change on the bar line at beat `at`: `meter(16, [7, 8])` or
|
|
3200
|
+
* `meter(16, 3)` (quarter-note beats). It lasts until the next one.
|
|
3201
|
+
*/
|
|
3202
|
+
export function meter(
|
|
3203
|
+
at: number,
|
|
3204
|
+
value: readonly [number, number] | number,
|
|
3205
|
+
): TimeMark {
|
|
3206
|
+
const start = beat(at, "meter() at");
|
|
3207
|
+
const [beatsPerBar, beatUnit] = Array.isArray(value)
|
|
3208
|
+
? [value[0], value[1]]
|
|
3209
|
+
: [value as number, 4];
|
|
3210
|
+
if (
|
|
3211
|
+
typeof beatsPerBar !== "number" ||
|
|
3212
|
+
!Number.isInteger(beatsPerBar) ||
|
|
3213
|
+
beatsPerBar < 1 ||
|
|
3214
|
+
beatsPerBar > 16
|
|
3215
|
+
)
|
|
3216
|
+
throw new DawgSdkError("meter() beats per bar must be an integer 1..16");
|
|
3217
|
+
if (![1, 2, 4, 8, 16, 32].includes(beatUnit as number))
|
|
3218
|
+
throw new DawgSdkError("meter() note value must be 1, 2, 4, 8, 16 or 32");
|
|
3219
|
+
return Object.freeze({
|
|
3220
|
+
kind: "meter",
|
|
3221
|
+
at: start,
|
|
3222
|
+
beatsPerBar,
|
|
3223
|
+
beatUnit: beatUnit as number,
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
/**
|
|
3228
|
+
* Track time for continuous phasing, the tape drift of Reich's It's Gonna
|
|
3229
|
+
* Rain and Come Out: the track's first `cycle` beats repeat a little fast,
|
|
3230
|
+
* gaining `cycles` whole cycles every `over` beats, so it drifts away from
|
|
3231
|
+
* an identical track and lines up again. `over` should divide the song
|
|
3232
|
+
* loop. `track({ ..., time: phasing(3, 48) })`. For Piano Phase's
|
|
3233
|
+
* shift-and-hold, use `stepPhasing()`.
|
|
3234
|
+
*/
|
|
3235
|
+
export function phasing(
|
|
3236
|
+
cycle: number,
|
|
3237
|
+
over: number,
|
|
3238
|
+
cycles = 1,
|
|
3239
|
+
): TrackTimeInput {
|
|
3240
|
+
const length = positive(cycle, "phasing() cycle");
|
|
3241
|
+
const span = positive(over, "phasing() over");
|
|
3242
|
+
const gain = finite(cycles, "phasing() cycles");
|
|
3243
|
+
const repeats = span / length;
|
|
3244
|
+
const rate = (repeats + gain) / repeats;
|
|
3245
|
+
if (!(rate >= 0.125 && rate <= 8))
|
|
3246
|
+
throw new DawgSdkError("phasing() needs a rate between 0.125 and 8");
|
|
3247
|
+
return Object.freeze({ cycle: length, rate });
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
/**
|
|
3251
|
+
* Stepped phasing (SDK 1.19.0), as in Reich's Piano Phase: the track's first `cycle`
|
|
3252
|
+
* beats hold in step with a twin for `hold` cycles, then move `shift`
|
|
3253
|
+
* beats ahead over `drift` cycles, and repeat until a whole cycle ahead.
|
|
3254
|
+
* `track({ ..., time: stepPhasing(3, { hold: 8 }) })`.
|
|
3255
|
+
*/
|
|
3256
|
+
export function stepPhasing(
|
|
3257
|
+
cycle: number,
|
|
3258
|
+
options: Readonly<{ shift?: number; hold?: number; drift?: number }> = {},
|
|
3259
|
+
): TrackTimeInput {
|
|
3260
|
+
const length = positive(cycle, "stepPhasing() cycle");
|
|
3261
|
+
const steps = phaseSteps(
|
|
3262
|
+
{ shift: 0.25, hold: 8, drift: 2, ...options },
|
|
3263
|
+
length,
|
|
3264
|
+
"stepPhasing()",
|
|
3265
|
+
);
|
|
3266
|
+
return Object.freeze({ cycle: length, steps });
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
function songBpm(value: unknown): number {
|
|
3270
|
+
const bpm = finite(value, "tempo bpm");
|
|
3271
|
+
if (bpm < 20 || bpm > 300)
|
|
3272
|
+
throw new DawgSdkError("tempo bpm must be 20..300");
|
|
3273
|
+
return bpm;
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
function tempoCurve(value: unknown, label: string): TempoCurve {
|
|
3277
|
+
if (value !== "linear" && value !== "exp")
|
|
3278
|
+
throw new DawgSdkError(`${label} curve must be "linear" or "exp"`);
|
|
3279
|
+
return value;
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
/** Same as `TIME_LIMITS.maxFermataSeconds` in core/tempo.ts. */
|
|
3283
|
+
const MAX_FERMATA_SECONDS = 16.777;
|
|
3284
|
+
|
|
3285
|
+
/** Tempo at `tick` through resolved tempo events (ramps glide into theirs). */
|
|
3286
|
+
function bpmAtTick(
|
|
3287
|
+
tempo: readonly { tick: number; bpm: number; ramp?: TempoCurve }[],
|
|
3288
|
+
start: number,
|
|
3289
|
+
tick: number,
|
|
3290
|
+
): number {
|
|
3291
|
+
let from = { tick: 0, bpm: start };
|
|
3292
|
+
for (const event of tempo) {
|
|
3293
|
+
if (event.tick <= tick) {
|
|
3294
|
+
from = event;
|
|
3295
|
+
continue;
|
|
3296
|
+
}
|
|
3297
|
+
if (!event.ramp) break;
|
|
3298
|
+
const t = (tick - from.tick) / (event.tick - from.tick);
|
|
3299
|
+
return event.ramp === "exp"
|
|
3300
|
+
? from.bpm * Math.pow(event.bpm / from.bpm, t)
|
|
3301
|
+
: from.bpm + (event.bpm - from.bpm) * t;
|
|
3302
|
+
}
|
|
3303
|
+
return from.bpm;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
/** Resolves `song({ time })` marks into the stored ticks and bar indexes. */
|
|
3307
|
+
function songTime(
|
|
3308
|
+
input: unknown,
|
|
3309
|
+
tempoBpm: number,
|
|
3310
|
+
ticks: (beats: number) => number,
|
|
3311
|
+
beatsPerBar: number,
|
|
3312
|
+
ticksPerBeat: number,
|
|
3313
|
+
beatUnit = 4,
|
|
3314
|
+
bars = Infinity,
|
|
3315
|
+
): { time?: ScoreTime } {
|
|
3316
|
+
if ((input === undefined || input === null) && beatUnit === 4) return {};
|
|
3317
|
+
input ??= [];
|
|
3318
|
+
if (!Array.isArray(input))
|
|
3319
|
+
throw new DawgSdkError("song time must be an array of time marks");
|
|
3320
|
+
const marks: TimeMark[] = [];
|
|
3321
|
+
for (const [index, entry] of (input as unknown[]).entries()) {
|
|
3322
|
+
for (const mark of Array.isArray(entry) ? entry : [entry]) {
|
|
3323
|
+
if (
|
|
3324
|
+
!isRecord(mark) ||
|
|
3325
|
+
(mark.kind !== "tempo" &&
|
|
3326
|
+
mark.kind !== "meter" &&
|
|
3327
|
+
mark.kind !== "fermata")
|
|
3328
|
+
)
|
|
3329
|
+
throw new DawgSdkError(
|
|
3330
|
+
`song time[${index}] must come from tempo(), ramp(), rit(), accel(), fermata() or meter()`,
|
|
3331
|
+
);
|
|
3332
|
+
marks.push(mark as TimeMark);
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
// Tempo: sort by tick, resolve pins against their neighbours.
|
|
3336
|
+
type Event = {
|
|
3337
|
+
tick: number;
|
|
3338
|
+
bpm?: number;
|
|
3339
|
+
ramp?: TempoCurve;
|
|
3340
|
+
back?: "a-tempo" | "primo";
|
|
3341
|
+
};
|
|
3342
|
+
const byTick = new Map<number, Event>();
|
|
3343
|
+
for (const mark of marks) {
|
|
3344
|
+
if (mark.kind !== "tempo") continue;
|
|
3345
|
+
const tick = ticks(mark.at);
|
|
3346
|
+
const previous = byTick.get(tick);
|
|
3347
|
+
const sets = (e: { bpm?: number; back?: unknown }) =>
|
|
3348
|
+
e.bpm !== undefined || e.back !== undefined;
|
|
3349
|
+
if (previous && sets(previous) && sets(mark)) {
|
|
3350
|
+
// A rit or ramp ending where a tempo change starts (`rit(18, 6, 52)`
|
|
3351
|
+
// with `aTempo(24)`): the ramp lands a tick early, then the step.
|
|
3352
|
+
const ramped = previous.ramp ? previous : mark.ramp ? mark : undefined;
|
|
3353
|
+
const other = ramped === previous ? mark : previous;
|
|
3354
|
+
if (ramped && !other.ramp && tick > 1 && !byTick.has(tick - 1)) {
|
|
3355
|
+
byTick.set(tick - 1, {
|
|
3356
|
+
tick: tick - 1,
|
|
3357
|
+
...(ramped.bpm !== undefined ? { bpm: ramped.bpm } : {}),
|
|
3358
|
+
ramp: ramped.ramp!,
|
|
3359
|
+
});
|
|
3360
|
+
byTick.set(tick, {
|
|
3361
|
+
tick,
|
|
3362
|
+
...(other.bpm !== undefined ? { bpm: other.bpm } : {}),
|
|
3363
|
+
...(other.back ? { back: other.back } : {}),
|
|
3364
|
+
});
|
|
3365
|
+
continue;
|
|
3366
|
+
}
|
|
3367
|
+
throw new DawgSdkError(
|
|
3368
|
+
`song time has two tempo changes at beat ${mark.at}; move one, or end a rit() where the next tempo starts`,
|
|
3369
|
+
);
|
|
3370
|
+
}
|
|
3371
|
+
// A pin and a change on the same beat: the change wins.
|
|
3372
|
+
if (previous && !sets(mark)) continue;
|
|
3373
|
+
byTick.set(tick, {
|
|
3374
|
+
tick,
|
|
3375
|
+
...(mark.bpm !== undefined ? { bpm: mark.bpm } : {}),
|
|
3376
|
+
...(mark.ramp ? { ramp: mark.ramp } : {}),
|
|
3377
|
+
...(mark.back ? { back: mark.back } : {}),
|
|
3378
|
+
});
|
|
3379
|
+
}
|
|
3380
|
+
const events = [...byTick.values()].sort((a, b) => a.tick - b.tick);
|
|
3381
|
+
const tempo: { tick: number; bpm: number; ramp?: TempoCurve }[] = [];
|
|
3382
|
+
events.forEach((event, index) => {
|
|
3383
|
+
if (event.back) {
|
|
3384
|
+
let bpm = tempoBpm;
|
|
3385
|
+
if (event.back === "a-tempo") {
|
|
3386
|
+
let last = -1;
|
|
3387
|
+
tempo.forEach((e, i) => {
|
|
3388
|
+
if (e.ramp !== undefined) last = i;
|
|
3389
|
+
});
|
|
3390
|
+
if (last < 0)
|
|
3391
|
+
throw new DawgSdkError(
|
|
3392
|
+
`aTempo() at beat ${event.tick / ticksPerBeat} has no rit() or accel() before it`,
|
|
3393
|
+
);
|
|
3394
|
+
bpm = last > 0 ? tempo[last - 1]!.bpm : tempoBpm;
|
|
3395
|
+
}
|
|
3396
|
+
tempo.push(Object.freeze({ tick: event.tick, bpm }));
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
if (event.bpm !== undefined) {
|
|
3400
|
+
// rit() and accel() check their direction against the tempo they
|
|
3401
|
+
// start from, as the prompt's `rit` and `accel` do.
|
|
3402
|
+
const mark = marks.find(
|
|
3403
|
+
(m) => m.kind === "tempo" && ticks(m.at) === event.tick && m.gradual,
|
|
3404
|
+
) as Extract<TimeMark, { kind: "tempo" }> | undefined;
|
|
3405
|
+
if (mark?.gradual) {
|
|
3406
|
+
const from = tempo[tempo.length - 1]?.bpm ?? tempoBpm;
|
|
3407
|
+
if (mark.gradual === "rit" && event.bpm > from)
|
|
3408
|
+
throw new DawgSdkError(
|
|
3409
|
+
`rit() target ${event.bpm} BPM is faster than ${from}; use accel()`,
|
|
3410
|
+
);
|
|
3411
|
+
if (mark.gradual === "accel" && event.bpm < from)
|
|
3412
|
+
throw new DawgSdkError(
|
|
3413
|
+
`accel() target ${event.bpm} BPM is slower than ${from}; use rit()`,
|
|
3414
|
+
);
|
|
3415
|
+
}
|
|
3416
|
+
tempo.push(
|
|
3417
|
+
Object.freeze({
|
|
3418
|
+
tick: event.tick,
|
|
3419
|
+
bpm: event.bpm,
|
|
3420
|
+
...(event.ramp ? { ramp: event.ramp } : {}),
|
|
3421
|
+
}),
|
|
3422
|
+
);
|
|
3423
|
+
return;
|
|
3424
|
+
}
|
|
3425
|
+
// A pin holds the tempo of the marks before it, and the next ramp
|
|
3426
|
+
// starts from it. Without a ramp after it, it changes nothing.
|
|
3427
|
+
const after = events
|
|
3428
|
+
.slice(index + 1)
|
|
3429
|
+
.find((e) => e.bpm !== undefined || e.back !== undefined);
|
|
3430
|
+
if (!after?.ramp) return;
|
|
3431
|
+
const before = tempo[tempo.length - 1]?.bpm ?? tempoBpm;
|
|
3432
|
+
tempo.push(Object.freeze({ tick: event.tick, bpm: before }));
|
|
3433
|
+
});
|
|
3434
|
+
// Meter: beats to bar indexes, checking each lands on a bar line.
|
|
3435
|
+
const meters = marks
|
|
3436
|
+
.filter((mark) => mark.kind === "meter")
|
|
3437
|
+
.sort((a, b) => a.at - b.at);
|
|
3438
|
+
// `song({ meter: [6, 8] })`: the song meter's note value as a bar-1 change.
|
|
3439
|
+
if (beatUnit !== 4 && !meters.some((mark) => ticks(mark.at) === 0))
|
|
3440
|
+
meters.unshift({ kind: "meter", at: 0, beatsPerBar, beatUnit });
|
|
3441
|
+
const meterOut: { bar: number; beatsPerBar: number; beatUnit?: number }[] =
|
|
3442
|
+
[];
|
|
3443
|
+
let barTick = 0;
|
|
3444
|
+
let barIndex = 0;
|
|
3445
|
+
let barLength = beatsPerBar * ticksPerBeat;
|
|
3446
|
+
for (const mark of meters) {
|
|
3447
|
+
const tick = ticks(mark.at);
|
|
3448
|
+
const bars = (tick - barTick) / barLength;
|
|
3449
|
+
if (!Number.isInteger(bars) || bars < 0)
|
|
3450
|
+
throw new DawgSdkError(`meter() at beat ${mark.at} is not on a bar line`);
|
|
3451
|
+
if (meterOut.length > 0 && bars === 0)
|
|
3452
|
+
throw new DawgSdkError(`song time has two meters at beat ${mark.at}`);
|
|
3453
|
+
barIndex += bars;
|
|
3454
|
+
barTick = tick;
|
|
3455
|
+
barLength = (mark.beatsPerBar * ticksPerBeat * 4) / mark.beatUnit;
|
|
3456
|
+
meterOut.push(
|
|
3457
|
+
Object.freeze({
|
|
3458
|
+
bar: barIndex,
|
|
3459
|
+
beatsPerBar: mark.beatsPerBar,
|
|
3460
|
+
...(mark.beatUnit !== 4 ? { beatUnit: mark.beatUnit } : {}),
|
|
3461
|
+
}),
|
|
3462
|
+
);
|
|
3463
|
+
}
|
|
3464
|
+
const fermatas = marks
|
|
3465
|
+
.filter((mark) => mark.kind === "fermata")
|
|
3466
|
+
.map((mark) => Object.freeze({ tick: ticks(mark.at), beats: mark.beats }))
|
|
3467
|
+
.sort((a, b) => a.tick - b.tick);
|
|
3468
|
+
for (let i = 1; i < fermatas.length; i += 1)
|
|
3469
|
+
if (fermatas[i]!.tick === fermatas[i - 1]!.tick)
|
|
3470
|
+
throw new DawgSdkError("song time has two fermatas on one beat");
|
|
3471
|
+
// Marks past the song end are never heard; the prompt refuses them too.
|
|
3472
|
+
if (Number.isFinite(bars)) {
|
|
3473
|
+
let end = 0;
|
|
3474
|
+
let fromBar = 0;
|
|
3475
|
+
let length = beatsPerBar * ticksPerBeat;
|
|
3476
|
+
for (const change of meterOut) {
|
|
3477
|
+
if (change.bar >= bars) break;
|
|
3478
|
+
end += (change.bar - fromBar) * length;
|
|
3479
|
+
fromBar = change.bar;
|
|
3480
|
+
length = (change.beatsPerBar * ticksPerBeat * 4) / (change.beatUnit ?? 4);
|
|
3481
|
+
}
|
|
3482
|
+
end += (bars - fromBar) * length;
|
|
3483
|
+
const beatOf = (tick: number) => tick / ticksPerBeat;
|
|
3484
|
+
// A ramp to the final barline (`rit()` over the last bars) lands on
|
|
3485
|
+
// the last tick, the tempo the song ends at.
|
|
3486
|
+
tempo.forEach((event, index) => {
|
|
3487
|
+
if (
|
|
3488
|
+
event.tick === end &&
|
|
3489
|
+
event.ramp &&
|
|
3490
|
+
end - 1 > (tempo[index - 1]?.tick ?? 0)
|
|
3491
|
+
)
|
|
3492
|
+
tempo[index] = Object.freeze({ ...event, tick: end - 1 });
|
|
3493
|
+
});
|
|
3494
|
+
for (const event of tempo)
|
|
3495
|
+
if (event.tick >= end)
|
|
3496
|
+
throw new DawgSdkError(
|
|
3497
|
+
`tempo at beat ${beatOf(event.tick)} is past the song end (${beatOf(end)} beats); add bars`,
|
|
3498
|
+
);
|
|
3499
|
+
for (const change of meterOut)
|
|
3500
|
+
if (change.bar >= bars)
|
|
3501
|
+
throw new DawgSdkError(
|
|
3502
|
+
`meter() at bar ${change.bar + 1} is past the song end (${bars} bars); add bars`,
|
|
3503
|
+
);
|
|
3504
|
+
for (const hold of fermatas)
|
|
3505
|
+
if (hold.tick >= end)
|
|
3506
|
+
throw new DawgSdkError(
|
|
3507
|
+
`fermata() at beat ${beatOf(hold.tick)} is past the song end (${beatOf(end)} beats); add bars`,
|
|
3508
|
+
);
|
|
3509
|
+
}
|
|
3510
|
+
// A fermata may hold its beat at most as long as a MIDI file can write.
|
|
3511
|
+
// The held beat is the meter's felt beat (a dotted quarter in 6/8), as
|
|
3512
|
+
// core/tempo.ts fermataSpan has it.
|
|
3513
|
+
const feltBeats = (tick: number): number => {
|
|
3514
|
+
let at = 0;
|
|
3515
|
+
let fromBar = 0;
|
|
3516
|
+
let meter = { beatsPerBar, beatUnit: 4 };
|
|
3517
|
+
let length = beatsPerBar * ticksPerBeat;
|
|
3518
|
+
for (const change of meterOut) {
|
|
3519
|
+
const start = at + (change.bar - fromBar) * length;
|
|
3520
|
+
if (start > tick) break;
|
|
3521
|
+
at = start;
|
|
3522
|
+
fromBar = change.bar;
|
|
3523
|
+
meter = {
|
|
3524
|
+
beatsPerBar: change.beatsPerBar,
|
|
3525
|
+
beatUnit: change.beatUnit ?? 4,
|
|
3526
|
+
};
|
|
3527
|
+
length = (meter.beatsPerBar * ticksPerBeat * 4) / meter.beatUnit;
|
|
3528
|
+
}
|
|
3529
|
+
if (meterOut.length === 0) return 1;
|
|
3530
|
+
const unit = 4 / meter.beatUnit;
|
|
3531
|
+
const compound =
|
|
3532
|
+
meter.beatUnit >= 8 &&
|
|
3533
|
+
meter.beatsPerBar > 3 &&
|
|
3534
|
+
meter.beatsPerBar % 3 === 0;
|
|
3535
|
+
return Math.max(1, compound ? unit * 3 : unit);
|
|
3536
|
+
};
|
|
3537
|
+
for (const hold of fermatas) {
|
|
3538
|
+
const bpm = bpmAtTick(tempo, tempoBpm, hold.tick);
|
|
3539
|
+
const held = ((1 + hold.beats) * feltBeats(hold.tick) * 60) / bpm;
|
|
3540
|
+
if (held > MAX_FERMATA_SECONDS + 1e-9)
|
|
3541
|
+
throw new DawgSdkError(
|
|
3542
|
+
`fermata() at beat ${hold.tick / ticksPerBeat} holds ${held.toFixed(1)} s; at most ${MAX_FERMATA_SECONDS} s (MIDI tempo limit), so use fewer beats or a faster tempo`,
|
|
3543
|
+
);
|
|
3544
|
+
}
|
|
3545
|
+
const time: Record<string, unknown> = {};
|
|
3546
|
+
if (tempo.length > 0) time.tempo = Object.freeze(tempo);
|
|
3547
|
+
if (meterOut.length > 0) time.meter = Object.freeze(meterOut);
|
|
3548
|
+
if (fermatas.length > 0) time.fermatas = Object.freeze(fermatas);
|
|
3549
|
+
return Object.keys(time).length > 0
|
|
3550
|
+
? { time: Object.freeze(time) as ScoreTime }
|
|
3551
|
+
: {};
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
// Tuning (SDK 1.16.0)
|
|
3555
|
+
|
|
3556
|
+
/**
|
|
3557
|
+
* A tuning for `song({ tuning })` or `track({ tuning })`: a library name or
|
|
3558
|
+
* an object with at most one table source (`edo`, `ratios`, `cents` or
|
|
3559
|
+
* `scl`). Library names: `12-tet`, `19-edo`, `24-edo`, `31-edo`,
|
|
3560
|
+
* `pythagorean`, `just` (5-limit), `7-limit`, `well-tuned-piano`, `pelog`,
|
|
3561
|
+
* `slendro`, `nyamaropa`, `shruti`, maqam and dastgah sets (`bayati`,
|
|
3562
|
+
* `rast`, `saba`, `shur`, `homayoun`, `chahargah`) and raga intonations
|
|
3563
|
+
* (`yaman`, `bhairav`, `kafi`, `todi`, …); `dawg` lists them with
|
|
3564
|
+
* `/tuning list`. dawg checks every value when the song loads.
|
|
3565
|
+
*/
|
|
3566
|
+
export type TuningInput =
|
|
3567
|
+
| string
|
|
3568
|
+
| Readonly<{
|
|
3569
|
+
/** A library tuning, or a label for the table given here. */
|
|
3570
|
+
name?: string;
|
|
3571
|
+
/** Equal divisions of the octave, 1..128. */
|
|
3572
|
+
edo?: number;
|
|
3573
|
+
/** Ratios for degrees 1..n, the last the period: `["9/8", "5/4", "2/1"]`. */
|
|
3574
|
+
ratios?: readonly (string | number)[];
|
|
3575
|
+
/** Cents for degrees 1..n, the last the period: `[240, 480, 720, 960, 1200]`. */
|
|
3576
|
+
cents?: readonly number[];
|
|
3577
|
+
/** A Scala `.scl` file in the project, e.g. `"tunings/slendro.scl"`. */
|
|
3578
|
+
scl?: string;
|
|
3579
|
+
/** A Scala `.kbm` keyboard mapping in the project; it sets its own root and A4. */
|
|
3580
|
+
kbm?: string;
|
|
3581
|
+
/** A4 in Hz, 220..880, default 440. */
|
|
3582
|
+
ref?: number;
|
|
3583
|
+
/** Key of degree 0, `"D4"` or 62; default the song key's tonic in octave 4. */
|
|
3584
|
+
root?: Pitch;
|
|
3585
|
+
/** `linear` (default): one key per step. `nearest`: every key plays the step nearest its 12-TET pitch. */
|
|
3586
|
+
map?: "linear" | "nearest";
|
|
3587
|
+
}>;
|
|
3588
|
+
|
|
3589
|
+
/** A stored tuning: `root` is a MIDI number, `ratios` are strings. */
|
|
3590
|
+
export type ScoreTuning = Readonly<{
|
|
3591
|
+
name?: string;
|
|
3592
|
+
edo?: number;
|
|
3593
|
+
ratios?: readonly string[];
|
|
3594
|
+
cents?: readonly number[];
|
|
3595
|
+
scl?: string;
|
|
3596
|
+
kbm?: string;
|
|
3597
|
+
ref?: number;
|
|
3598
|
+
root?: number;
|
|
3599
|
+
map?: "linear" | "nearest";
|
|
3600
|
+
}>;
|
|
3601
|
+
|
|
3602
|
+
const TUNING_FIELDS: readonly string[] = Object.freeze([
|
|
3603
|
+
"name",
|
|
3604
|
+
"edo",
|
|
3605
|
+
"ratios",
|
|
3606
|
+
"cents",
|
|
3607
|
+
"scl",
|
|
3608
|
+
"kbm",
|
|
3609
|
+
"ref",
|
|
3610
|
+
"root",
|
|
3611
|
+
"map",
|
|
3612
|
+
]);
|
|
3613
|
+
|
|
3614
|
+
/** Checks a tuning's shape; dawg validates the values when the song loads. */
|
|
3615
|
+
function tuningSpec(
|
|
3616
|
+
input: TuningInput | null | undefined,
|
|
3617
|
+
where: string,
|
|
3618
|
+
): ScoreTuning | null {
|
|
3619
|
+
if (input === undefined || input === null) return null;
|
|
3620
|
+
if (typeof input === "string") {
|
|
3621
|
+
if (input.trim() === "")
|
|
3622
|
+
throw new DawgSdkError(`${where} tuning must be a name or an object`);
|
|
3623
|
+
return Object.freeze({ name: input.trim() });
|
|
3624
|
+
}
|
|
3625
|
+
if (!isRecord(input))
|
|
3626
|
+
throw new DawgSdkError(`${where} tuning must be a name or an object`);
|
|
3627
|
+
const out: Record<string, unknown> = {};
|
|
3628
|
+
for (const [field, value] of Object.entries(input)) {
|
|
3629
|
+
if (!TUNING_FIELDS.includes(field))
|
|
3630
|
+
throw new DawgSdkError(
|
|
3631
|
+
`${where} tuning has an unknown field "${field}" (use ${TUNING_FIELDS.join(", ")})`,
|
|
3632
|
+
);
|
|
3633
|
+
if (value === undefined || value === null) continue;
|
|
3634
|
+
if (field === "root") out.root = midi(value as Pitch);
|
|
3635
|
+
else if (field === "ratios" || field === "cents") {
|
|
3636
|
+
if (!Array.isArray(value))
|
|
3637
|
+
throw new DawgSdkError(`${where} tuning ${field} must be a list`);
|
|
3638
|
+
out[field] = Object.freeze(
|
|
3639
|
+
field === "ratios" ? value.map((ratio) => String(ratio)) : [...value],
|
|
3640
|
+
);
|
|
3641
|
+
} else out[field] = value;
|
|
3642
|
+
}
|
|
3643
|
+
const sources = ["edo", "ratios", "cents", "scl"].filter(
|
|
3644
|
+
(field) => out[field] !== undefined,
|
|
3645
|
+
);
|
|
3646
|
+
if (sources.length > 1)
|
|
3647
|
+
throw new DawgSdkError(
|
|
3648
|
+
`${where} tuning has ${sources.join(" and ")}; give one table`,
|
|
3649
|
+
);
|
|
3650
|
+
return Object.freeze(out as ScoreTuning);
|
|
3651
|
+
}
|
|
3652
|
+
|
|
2248
3653
|
// ---------------------------------------------------------------------------
|
|
2249
3654
|
// Chords
|
|
2250
3655
|
|
|
@@ -2815,6 +4220,8 @@ const MODES = Object.freeze({
|
|
|
2815
4220
|
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
|
2816
4221
|
locrian: [0, 1, 3, 5, 6, 8, 10],
|
|
2817
4222
|
"harmonic-minor": [0, 2, 3, 5, 7, 8, 11],
|
|
4223
|
+
"melodic-minor": [0, 2, 3, 5, 7, 9, 11],
|
|
4224
|
+
"phrygian-dominant": [0, 1, 4, 5, 7, 8, 10],
|
|
2818
4225
|
} as const);
|
|
2819
4226
|
type ModeName = keyof typeof MODES;
|
|
2820
4227
|
const MODE_NAMES = Object.keys(MODES) as ModeName[];
|
|
@@ -2837,25 +4244,302 @@ const MODE_ALIASES: Readonly<Record<string, ModeName>> = Object.freeze({
|
|
|
2837
4244
|
"harmonic-minor": "harmonic-minor",
|
|
2838
4245
|
"harmonic minor": "harmonic-minor",
|
|
2839
4246
|
harmonic: "harmonic-minor",
|
|
4247
|
+
"melodic-minor": "melodic-minor",
|
|
4248
|
+
"melodic minor": "melodic-minor",
|
|
4249
|
+
melodic: "melodic-minor",
|
|
4250
|
+
"jazz minor": "melodic-minor",
|
|
4251
|
+
"phrygian-dominant": "phrygian-dominant",
|
|
4252
|
+
"phrygian dominant": "phrygian-dominant",
|
|
4253
|
+
freygish: "phrygian-dominant",
|
|
4254
|
+
spanish: "phrygian-dominant",
|
|
4255
|
+
ajam: "major",
|
|
4256
|
+
mahur: "major",
|
|
4257
|
+
bilawal: "major",
|
|
2840
4258
|
});
|
|
2841
4259
|
|
|
2842
|
-
type
|
|
4260
|
+
type ScaleFamily =
|
|
4261
|
+
"pentatonic" | "blues" | "maqam" | "dastgah" | "raga" | "messiaen";
|
|
4262
|
+
|
|
4263
|
+
/**
|
|
4264
|
+
* Scales beyond the chord modes, for keys such as `D bayati`, `C yaman` or
|
|
4265
|
+
* `C messiaen-3`. `steps` are semitones above the tonic and may be
|
|
4266
|
+
* fractional (a quarter tone is .5); `mode` is the seven-note mode the
|
|
4267
|
+
* chord engine harmonizes with (the closest one; see DAWG.md). A raga's
|
|
4268
|
+
* `intonation` is each step's traditional just pitch in cents (shruti
|
|
4269
|
+
* offsets), which its named tuning applies (`tuning yaman`): Pythagorean
|
|
4270
|
+
* ati-komal re and dha for Bhairavi, Bhairav, Purvi and Todi, the high
|
|
4271
|
+
* tivra ma (729/512) for Yaman, 9/5 komal ni for Kafi, after Daniélou and
|
|
4272
|
+
* Jairazbhoy. Maqam and dastgah quarter tones follow the 24-tone convention;
|
|
4273
|
+
* Segah and Sikah start on a half-flat note, so their tonic is the key.
|
|
4274
|
+
*/
|
|
4275
|
+
type ScaleInfo = Readonly<{
|
|
4276
|
+
steps: readonly number[];
|
|
4277
|
+
mode: ModeName;
|
|
4278
|
+
family: ScaleFamily;
|
|
4279
|
+
intonation?: readonly number[];
|
|
4280
|
+
aliases?: readonly string[];
|
|
4281
|
+
}>;
|
|
4282
|
+
|
|
4283
|
+
const SCALES = Object.freeze({
|
|
4284
|
+
"major-pentatonic": {
|
|
4285
|
+
steps: [0, 2, 4, 7, 9],
|
|
4286
|
+
mode: "major",
|
|
4287
|
+
family: "pentatonic",
|
|
4288
|
+
aliases: ["pentatonic", "major pentatonic", "pent"],
|
|
4289
|
+
},
|
|
4290
|
+
"minor-pentatonic": {
|
|
4291
|
+
steps: [0, 3, 5, 7, 10],
|
|
4292
|
+
mode: "minor",
|
|
4293
|
+
family: "pentatonic",
|
|
4294
|
+
aliases: ["minor pentatonic", "m pentatonic", "min pentatonic"],
|
|
4295
|
+
},
|
|
4296
|
+
blues: {
|
|
4297
|
+
steps: [0, 3, 5, 6, 7, 10],
|
|
4298
|
+
mode: "minor",
|
|
4299
|
+
family: "blues",
|
|
4300
|
+
aliases: ["minor blues"],
|
|
4301
|
+
},
|
|
4302
|
+
"major-blues": {
|
|
4303
|
+
steps: [0, 2, 3, 4, 7, 9],
|
|
4304
|
+
mode: "major",
|
|
4305
|
+
family: "blues",
|
|
4306
|
+
aliases: ["major blues"],
|
|
4307
|
+
},
|
|
4308
|
+
hijaz: {
|
|
4309
|
+
steps: [0, 1, 4, 5, 7, 8, 10],
|
|
4310
|
+
mode: "phrygian-dominant",
|
|
4311
|
+
family: "maqam",
|
|
4312
|
+
},
|
|
4313
|
+
bayati: {
|
|
4314
|
+
steps: [0, 1.5, 3, 5, 7, 8, 10],
|
|
4315
|
+
mode: "phrygian",
|
|
4316
|
+
family: "maqam",
|
|
4317
|
+
},
|
|
4318
|
+
rast: { steps: [0, 2, 3.5, 5, 7, 9, 10.5], mode: "major", family: "maqam" },
|
|
4319
|
+
saba: { steps: [0, 1.5, 3, 4, 7, 8, 10], mode: "phrygian", family: "maqam" },
|
|
4320
|
+
kurd: { steps: [0, 1, 3, 5, 7, 8, 10], mode: "phrygian", family: "maqam" },
|
|
4321
|
+
nahawand: {
|
|
4322
|
+
steps: [0, 2, 3, 5, 7, 8, 11],
|
|
4323
|
+
mode: "harmonic-minor",
|
|
4324
|
+
family: "maqam",
|
|
4325
|
+
},
|
|
4326
|
+
sikah: {
|
|
4327
|
+
steps: [0, 1.5, 3.5, 5.5, 7, 8.5, 10.5],
|
|
4328
|
+
mode: "phrygian",
|
|
4329
|
+
family: "maqam",
|
|
4330
|
+
aliases: ["sika"],
|
|
4331
|
+
},
|
|
4332
|
+
huzam: {
|
|
4333
|
+
steps: [0, 1.5, 3.5, 4.5, 7.5, 8.5, 10.5],
|
|
4334
|
+
mode: "phrygian",
|
|
4335
|
+
family: "maqam",
|
|
4336
|
+
aliases: ["houzam"],
|
|
4337
|
+
},
|
|
4338
|
+
nikriz: { steps: [0, 2, 3, 6, 7, 9, 10], mode: "dorian", family: "maqam" },
|
|
4339
|
+
shur: {
|
|
4340
|
+
steps: [0, 1.5, 3, 5, 7, 8, 10],
|
|
4341
|
+
mode: "phrygian",
|
|
4342
|
+
family: "dastgah",
|
|
4343
|
+
},
|
|
4344
|
+
homayoun: {
|
|
4345
|
+
steps: [0, 1.5, 4, 5, 7, 8, 10],
|
|
4346
|
+
mode: "phrygian-dominant",
|
|
4347
|
+
family: "dastgah",
|
|
4348
|
+
aliases: ["homayun"],
|
|
4349
|
+
},
|
|
4350
|
+
chahargah: {
|
|
4351
|
+
steps: [0, 1.5, 4, 5, 7, 8.5, 11],
|
|
4352
|
+
mode: "phrygian-dominant",
|
|
4353
|
+
family: "dastgah",
|
|
4354
|
+
aliases: ["chahar-gah"],
|
|
4355
|
+
},
|
|
4356
|
+
segah: {
|
|
4357
|
+
steps: [0, 1.5, 3.5, 5, 6.5, 8.5, 10.5],
|
|
4358
|
+
mode: "phrygian",
|
|
4359
|
+
family: "dastgah",
|
|
4360
|
+
aliases: ["sehgah", "se-gah"],
|
|
4361
|
+
},
|
|
4362
|
+
nava: {
|
|
4363
|
+
steps: [0, 2, 3.5, 5, 7, 8, 10],
|
|
4364
|
+
mode: "minor",
|
|
4365
|
+
family: "dastgah",
|
|
4366
|
+
},
|
|
4367
|
+
yaman: {
|
|
4368
|
+
steps: [0, 2, 4, 6, 7, 9, 11],
|
|
4369
|
+
mode: "lydian",
|
|
4370
|
+
family: "raga",
|
|
4371
|
+
intonation: [0, 203.91, 386.31, 611.73, 701.96, 884.36, 1088.27],
|
|
4372
|
+
aliases: ["kalyan", "yaman kalyan"],
|
|
4373
|
+
},
|
|
4374
|
+
bhairav: {
|
|
4375
|
+
steps: [0, 1, 4, 5, 7, 8, 11],
|
|
4376
|
+
mode: "phrygian-dominant",
|
|
4377
|
+
family: "raga",
|
|
4378
|
+
intonation: [0, 90.22, 386.31, 498.04, 701.96, 792.18, 1088.27],
|
|
4379
|
+
},
|
|
4380
|
+
kafi: {
|
|
4381
|
+
steps: [0, 2, 3, 5, 7, 9, 10],
|
|
4382
|
+
mode: "dorian",
|
|
4383
|
+
family: "raga",
|
|
4384
|
+
intonation: [0, 203.91, 315.64, 498.04, 701.96, 884.36, 1017.6],
|
|
4385
|
+
},
|
|
4386
|
+
bhairavi: {
|
|
4387
|
+
steps: [0, 1, 3, 5, 7, 8, 10],
|
|
4388
|
+
mode: "phrygian",
|
|
4389
|
+
family: "raga",
|
|
4390
|
+
intonation: [0, 90.22, 294.13, 498.04, 701.96, 792.18, 996.09],
|
|
4391
|
+
},
|
|
4392
|
+
asavari: {
|
|
4393
|
+
steps: [0, 2, 3, 5, 7, 8, 10],
|
|
4394
|
+
mode: "minor",
|
|
4395
|
+
family: "raga",
|
|
4396
|
+
intonation: [0, 203.91, 315.64, 498.04, 701.96, 813.69, 996.09],
|
|
4397
|
+
},
|
|
4398
|
+
khamaj: {
|
|
4399
|
+
steps: [0, 2, 4, 5, 7, 9, 10],
|
|
4400
|
+
mode: "mixolydian",
|
|
4401
|
+
family: "raga",
|
|
4402
|
+
intonation: [0, 203.91, 386.31, 498.04, 701.96, 884.36, 996.09],
|
|
4403
|
+
},
|
|
4404
|
+
todi: {
|
|
4405
|
+
steps: [0, 1, 3, 6, 7, 8, 11],
|
|
4406
|
+
mode: "phrygian",
|
|
4407
|
+
family: "raga",
|
|
4408
|
+
intonation: [0, 95, 294, 606, 702, 792, 1107],
|
|
4409
|
+
},
|
|
4410
|
+
purvi: {
|
|
4411
|
+
steps: [0, 1, 4, 6, 7, 8, 11],
|
|
4412
|
+
mode: "phrygian-dominant",
|
|
4413
|
+
family: "raga",
|
|
4414
|
+
intonation: [0, 90.22, 386.31, 590.22, 701.96, 792.18, 1088.27],
|
|
4415
|
+
},
|
|
4416
|
+
marwa: {
|
|
4417
|
+
steps: [0, 1, 4, 6, 9, 11],
|
|
4418
|
+
mode: "lydian",
|
|
4419
|
+
family: "raga",
|
|
4420
|
+
intonation: [0, 111.73, 386.31, 590.22, 884.36, 1088.27],
|
|
4421
|
+
},
|
|
4422
|
+
darbari: {
|
|
4423
|
+
steps: [0, 2, 3, 5, 7, 8, 10],
|
|
4424
|
+
mode: "minor",
|
|
4425
|
+
family: "raga",
|
|
4426
|
+
intonation: [0, 203.91, 294.13, 498.04, 701.96, 792.18, 996.09],
|
|
4427
|
+
aliases: ["darbari kanada"],
|
|
4428
|
+
},
|
|
4429
|
+
malkauns: {
|
|
4430
|
+
steps: [0, 3, 5, 8, 10],
|
|
4431
|
+
mode: "minor",
|
|
4432
|
+
family: "raga",
|
|
4433
|
+
intonation: [0, 315.64, 498.04, 813.69, 996.09],
|
|
4434
|
+
},
|
|
4435
|
+
bhupali: {
|
|
4436
|
+
steps: [0, 2, 4, 7, 9],
|
|
4437
|
+
mode: "major",
|
|
4438
|
+
family: "raga",
|
|
4439
|
+
intonation: [0, 203.91, 386.31, 701.96, 884.36],
|
|
4440
|
+
},
|
|
4441
|
+
durga: {
|
|
4442
|
+
steps: [0, 2, 5, 7, 9],
|
|
4443
|
+
mode: "major",
|
|
4444
|
+
family: "raga",
|
|
4445
|
+
intonation: [0, 203.91, 498.04, 701.96, 884.36],
|
|
4446
|
+
},
|
|
4447
|
+
"messiaen-1": {
|
|
4448
|
+
steps: [0, 2, 4, 6, 8, 10],
|
|
4449
|
+
mode: "lydian",
|
|
4450
|
+
family: "messiaen",
|
|
4451
|
+
aliases: ["whole-tone", "whole tone", "wholetone"],
|
|
4452
|
+
},
|
|
4453
|
+
"messiaen-2": {
|
|
4454
|
+
steps: [0, 1, 3, 4, 6, 7, 9, 10],
|
|
4455
|
+
mode: "mixolydian",
|
|
4456
|
+
family: "messiaen",
|
|
4457
|
+
aliases: ["octatonic", "diminished", "half-whole"],
|
|
4458
|
+
},
|
|
4459
|
+
"messiaen-3": {
|
|
4460
|
+
steps: [0, 2, 3, 4, 6, 7, 8, 10, 11],
|
|
4461
|
+
mode: "minor",
|
|
4462
|
+
family: "messiaen",
|
|
4463
|
+
},
|
|
4464
|
+
"messiaen-4": {
|
|
4465
|
+
steps: [0, 1, 2, 5, 6, 7, 8, 11],
|
|
4466
|
+
mode: "harmonic-minor",
|
|
4467
|
+
family: "messiaen",
|
|
4468
|
+
},
|
|
4469
|
+
"messiaen-5": {
|
|
4470
|
+
steps: [0, 1, 5, 6, 7, 11],
|
|
4471
|
+
mode: "lydian",
|
|
4472
|
+
family: "messiaen",
|
|
4473
|
+
},
|
|
4474
|
+
"messiaen-6": {
|
|
4475
|
+
steps: [0, 2, 4, 5, 6, 8, 10, 11],
|
|
4476
|
+
mode: "major",
|
|
4477
|
+
family: "messiaen",
|
|
4478
|
+
},
|
|
4479
|
+
"messiaen-7": {
|
|
4480
|
+
steps: [0, 1, 2, 3, 5, 6, 7, 8, 9, 11],
|
|
4481
|
+
mode: "harmonic-minor",
|
|
4482
|
+
family: "messiaen",
|
|
4483
|
+
},
|
|
4484
|
+
} as const satisfies Record<string, ScaleInfo>);
|
|
4485
|
+
type ScaleName = keyof typeof SCALES;
|
|
4486
|
+
const SCALE_NAMES = Object.keys(SCALES) as ScaleName[];
|
|
4487
|
+
|
|
4488
|
+
const SCALE_ALIASES: Readonly<Record<string, ScaleName>> = (() => {
|
|
4489
|
+
const aliases: Record<string, ScaleName> = {};
|
|
4490
|
+
for (const name of SCALE_NAMES) {
|
|
4491
|
+
const info: ScaleInfo = SCALES[name];
|
|
4492
|
+
aliases[name] = name;
|
|
4493
|
+
aliases[name.replace(/-/g, " ")] = name;
|
|
4494
|
+
for (const alias of info.aliases ?? []) aliases[alias] = name;
|
|
4495
|
+
}
|
|
4496
|
+
return Object.freeze(aliases);
|
|
4497
|
+
})();
|
|
4498
|
+
|
|
4499
|
+
/** The library scale named `text` (case and `-`/space insensitive). */
|
|
4500
|
+
function scaleNamed(text: string): ScaleName | undefined {
|
|
4501
|
+
const word = text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
4502
|
+
return SCALE_ALIASES[word] ?? SCALE_ALIASES[word.replace(/ /g, "-")];
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4505
|
+
/**
|
|
4506
|
+
* A key: a tonic and the seven-note `mode` the chord engine uses, plus the
|
|
4507
|
+
* library `scale` when the key names one (`D bayati`, `C yaman`).
|
|
4508
|
+
*/
|
|
4509
|
+
type Key = Readonly<{
|
|
4510
|
+
tonic: number;
|
|
4511
|
+
mode: ModeName;
|
|
4512
|
+
scale?: ScaleName;
|
|
4513
|
+
}>;
|
|
2843
4514
|
|
|
2844
4515
|
/**
|
|
2845
|
-
* Parse a key: `C`, `c major`, `Am`, `a minor`, `F# dorian`, `Eb mixo
|
|
2846
|
-
* Accepts the `<note> <mode>` form
|
|
4516
|
+
* Parse a key: `C`, `c major`, `Am`, `a minor`, `F# dorian`, `Eb mixo`,
|
|
4517
|
+
* `D bayati`, `C messiaen-3`. Accepts the `<note> <mode>` form
|
|
4518
|
+
* `core/key.ts` writes.
|
|
2847
4519
|
*/
|
|
2848
4520
|
function parseKey(text: string | null | undefined): Key | undefined {
|
|
2849
4521
|
if (typeof text !== "string" || text.length > 40) return undefined;
|
|
2850
4522
|
const match = text
|
|
2851
4523
|
.trim()
|
|
2852
|
-
.match(/^([a-gA-G])(#|b|♯|♭)?\s*(m(?![a-z])|[a-zA-Z][a-zA-
|
|
4524
|
+
.match(/^([a-gA-G])(#|b|♯|♭)?\s*(m(?![a-z])|[a-zA-Z][a-zA-Z0-9 -]*)?$/);
|
|
2853
4525
|
if (!match) return undefined;
|
|
2854
4526
|
const tonic = parsePitchClass(`${match[1]}${match[2] ?? ""}`);
|
|
4527
|
+
if (tonic === undefined) return undefined;
|
|
2855
4528
|
const word = (match[3] ?? "").trim();
|
|
2856
4529
|
const mode = MODE_ALIASES[word === "m" ? "m" : word.toLowerCase()];
|
|
2857
|
-
if (
|
|
2858
|
-
|
|
4530
|
+
if (mode !== undefined) return Object.freeze({ tonic, mode });
|
|
4531
|
+
const scale = scaleNamed(word);
|
|
4532
|
+
if (scale === undefined) return undefined;
|
|
4533
|
+
return Object.freeze({ tonic, mode: SCALES[scale].mode, scale });
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4536
|
+
/**
|
|
4537
|
+
* Steps of the key's whole scale in semitones above the tonic (fractional
|
|
4538
|
+
* for quarter tones): the library scale when the key names one, else the
|
|
4539
|
+
* mode.
|
|
4540
|
+
*/
|
|
4541
|
+
function scaleSteps(key: Key): number[] {
|
|
4542
|
+
return [...(key.scale ? SCALES[key.scale].steps : MODES[key.mode])];
|
|
2859
4543
|
}
|
|
2860
4544
|
|
|
2861
4545
|
/** True when names in the key read better with flats (F, Bb, Eb, d minor…). */
|
|
@@ -2870,6 +4554,8 @@ function keyUsesFlats(key: Key): boolean {
|
|
|
2870
4554
|
minor: 9,
|
|
2871
4555
|
locrian: 11,
|
|
2872
4556
|
"harmonic-minor": 9,
|
|
4557
|
+
"melodic-minor": 9,
|
|
4558
|
+
"phrygian-dominant": 4,
|
|
2873
4559
|
};
|
|
2874
4560
|
const parent = mod12(key.tonic - parentOffset[key.mode]);
|
|
2875
4561
|
return [5, 10, 3, 8, 1].includes(parent);
|
|
@@ -2877,7 +4563,7 @@ function keyUsesFlats(key: Key): boolean {
|
|
|
2877
4563
|
|
|
2878
4564
|
/** `C major`, `F# dorian`, `Bb minor`. */
|
|
2879
4565
|
function keyName(key: Key): string {
|
|
2880
|
-
return `${noteName(key.tonic, keyUsesFlats(key))} ${key.mode}`;
|
|
4566
|
+
return `${noteName(key.tonic, keyUsesFlats(key))} ${key.scale ?? key.mode}`;
|
|
2881
4567
|
}
|
|
2882
4568
|
|
|
2883
4569
|
/** Pitch classes of the key's scale, tonic first. */
|