@hraness/dawg 0.4.1 → 0.6.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 +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -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 +29 -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 +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -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/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -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/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -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 +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +459 -43
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/core/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.25.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 }>;
|
|
245
|
+
}>;
|
|
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 }>;
|
|
173
254
|
}>;
|
|
174
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
|
|
|
@@ -1058,6 +1277,12 @@ export type SampleSpec = Readonly<{
|
|
|
1058
1277
|
accelerate?: number;
|
|
1059
1278
|
/** Like Tidal `squiz`: pitch-raise ratio per zero-crossing cycle (1..32). */
|
|
1060
1279
|
squiz?: number;
|
|
1280
|
+
/** The file's own tempo (20..400, SDK 1.20.0): the window follows the song's tempo map. */
|
|
1281
|
+
bpm?: number;
|
|
1282
|
+
/** How a fitted window changes time (SDK 1.20.0): `"repitch"` (tape), `"beats"` (onset slices), `"tones"` (keeps pitch). */
|
|
1283
|
+
fitmode?: "repitch" | "beats" | "tones";
|
|
1284
|
+
/** Window length in beats (SDK 1.20.0); `fit` wins over `bpm`, `bpm` over `len`. */
|
|
1285
|
+
len?: number;
|
|
1061
1286
|
}>;
|
|
1062
1287
|
|
|
1063
1288
|
/** Result of `sampler()`; pass it as a track's `instrument`. */
|
|
@@ -1098,7 +1323,7 @@ export function sampler(
|
|
|
1098
1323
|
throw new DawgSdkError(
|
|
1099
1324
|
`sampler voice "${name}" must be a short identifier`,
|
|
1100
1325
|
);
|
|
1101
|
-
out[name] =
|
|
1326
|
+
out[name] = sampleSpec(voices[name]!, name);
|
|
1102
1327
|
}
|
|
1103
1328
|
return Object.freeze({ kind: "sampler", voices: Object.freeze(out), mode });
|
|
1104
1329
|
}
|
|
@@ -1214,6 +1439,314 @@ export function wavetable(
|
|
|
1214
1439
|
return Object.freeze(out) as WavetableSpec;
|
|
1215
1440
|
}
|
|
1216
1441
|
|
|
1442
|
+
/** Instrument name of the 0.6 string engine (`Track.string`). */
|
|
1443
|
+
export const STRING_INSTRUMENT = "string";
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* String engine settings (SDK 1.21.0): a `preset` (`nylon`, `steel`,
|
|
1447
|
+
* `electric`, `jangle`, `ebass`, `slap`, `upright`, `sitar`, `tanpura`,
|
|
1448
|
+
* `harpsichord`, `lute`, `oud`, `setar`, `tar`, `santur`, `dulcimer`, `koto`,
|
|
1449
|
+
* `harp`, `banjo`, `tres`, `requinto`) plus any parameter to override
|
|
1450
|
+
* (`ring`, `bright`, `damp`, `pos`, `mute`, `buzz`, `body`, `sym`, ...).
|
|
1451
|
+
* dawg validates names and ranges; see **Strings** in DAWG.md.
|
|
1452
|
+
*/
|
|
1453
|
+
export type StringInput = Readonly<
|
|
1454
|
+
{ preset?: string } & Record<string, number | string | undefined>
|
|
1455
|
+
>;
|
|
1456
|
+
|
|
1457
|
+
/** Result of `stringed()`; pass it as a track's `instrument`. */
|
|
1458
|
+
export type StringSpec = Readonly<{ kind: "string" } & StringInput>;
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* A plucked string instrument (SDK 1.21.0): a preset and overrides.
|
|
1462
|
+
*
|
|
1463
|
+
* instrument: stringed("nylon")
|
|
1464
|
+
* instrument: stringed("sitar", { buzz: 0.8, sym: 0.5 })
|
|
1465
|
+
*/
|
|
1466
|
+
export function stringed(
|
|
1467
|
+
preset = "nylon",
|
|
1468
|
+
params: Readonly<Record<string, number | string>> = {},
|
|
1469
|
+
): StringSpec {
|
|
1470
|
+
if (typeof preset !== "string" || preset.length === 0)
|
|
1471
|
+
throw new DawgSdkError("stringed needs a preset name");
|
|
1472
|
+
if (!isRecord(params))
|
|
1473
|
+
throw new DawgSdkError("stringed params must be an object");
|
|
1474
|
+
const out: Record<string, number | string> = { kind: "string", preset };
|
|
1475
|
+
for (const [key, value] of Object.entries(params)) {
|
|
1476
|
+
if (value === undefined || key === "kind" || key === "preset") continue;
|
|
1477
|
+
out[key] =
|
|
1478
|
+
typeof value === "string" ? value : finite(value, `string ${key}`);
|
|
1479
|
+
}
|
|
1480
|
+
return Object.freeze(out) as StringSpec;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** Instrument name of the 0.6 granular engine (`Track.granular`). */
|
|
1484
|
+
export const GRANULAR_INSTRUMENT = "granular";
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Granular settings (SDK 1.23.0). `src` is a sample (`sample(...)` shape,
|
|
1488
|
+
* pinned like a sampler voice) or a built-in synth render
|
|
1489
|
+
* `"synth:<preset>[@note]"` (default `synth:pad`, nothing to download);
|
|
1490
|
+
* `preset` is `cloud`, `hold`, `sparkle`, `swarm`, `stutter`, `microloop`,
|
|
1491
|
+
* `backwards` or `dust`; every other key overrides one parameter (`grain`
|
|
1492
|
+
* seconds, `overlap`, `scan`, `pos`, `begin`, `end`, `spray`, `jitter`,
|
|
1493
|
+
* `pitch`, `detune`, `shimmer`, `shimint`, `spread`, `window`, `reverse`,
|
|
1494
|
+
* `freeze`, `repeat`, `hold`, `drift`, `drate`, `attack`, `release`,
|
|
1495
|
+
* `veltone`, `gain`, `seed`, `root`). dawg validates names and ranges; see
|
|
1496
|
+
* **Granular** in DAWG.md.
|
|
1497
|
+
*/
|
|
1498
|
+
export type GranularInput = Readonly<
|
|
1499
|
+
{
|
|
1500
|
+
src?: string | SampleSpec;
|
|
1501
|
+
preset?: string;
|
|
1502
|
+
} & Record<string, number | string | boolean | SampleSpec | undefined>
|
|
1503
|
+
>;
|
|
1504
|
+
|
|
1505
|
+
/** Result of `granular()`; pass it as a track's `instrument`. */
|
|
1506
|
+
export type GranularSpec = Readonly<{ kind: "granular" } & GranularInput>;
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* A granular instrument (SDK 1.23.0): an optional preset, then overrides.
|
|
1510
|
+
*
|
|
1511
|
+
* ```ts
|
|
1512
|
+
* instrument: granular("cloud")
|
|
1513
|
+
* instrument: granular("hold", { src: "samples/choir.wav", scan: 0 })
|
|
1514
|
+
* instrument: granular({ src: "synth:bell@72", grain: 0.08, overlap: 6 })
|
|
1515
|
+
* ```
|
|
1516
|
+
*/
|
|
1517
|
+
export function granular(
|
|
1518
|
+
preset?: string | GranularInput,
|
|
1519
|
+
params: GranularInput = {},
|
|
1520
|
+
): GranularSpec {
|
|
1521
|
+
const fields =
|
|
1522
|
+
typeof preset === "object" && preset !== null ? preset : params;
|
|
1523
|
+
if (!isRecord(fields))
|
|
1524
|
+
throw new DawgSdkError("granular params must be an object");
|
|
1525
|
+
const out: Record<string, unknown> = { kind: "granular" };
|
|
1526
|
+
if (typeof preset === "string") {
|
|
1527
|
+
if (preset.length === 0)
|
|
1528
|
+
throw new DawgSdkError("granular needs a preset name");
|
|
1529
|
+
out.preset = preset;
|
|
1530
|
+
} else if (preset !== undefined && (typeof preset !== "object" || !preset))
|
|
1531
|
+
throw new DawgSdkError("granular takes a preset name or params");
|
|
1532
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
1533
|
+
if (value === undefined || key === "kind") continue;
|
|
1534
|
+
if (key === "src")
|
|
1535
|
+
out.src =
|
|
1536
|
+
typeof value === "string" && value.startsWith("synth:")
|
|
1537
|
+
? value
|
|
1538
|
+
: sampleSpec(value as string | SampleSpec, "granular src");
|
|
1539
|
+
else if (key === "preset" && typeof preset === "string") continue;
|
|
1540
|
+
else if (key === "root" && typeof value === "string")
|
|
1541
|
+
out.root = midi(value as Pitch);
|
|
1542
|
+
else if (
|
|
1543
|
+
typeof value === "number" ||
|
|
1544
|
+
typeof value === "string" ||
|
|
1545
|
+
typeof value === "boolean"
|
|
1546
|
+
)
|
|
1547
|
+
out[key] =
|
|
1548
|
+
typeof value === "number" ? finite(value, `granular ${key}`) : value;
|
|
1549
|
+
else throw new DawgSdkError(`granular ${key} must be a value`);
|
|
1550
|
+
}
|
|
1551
|
+
return Object.freeze(out) as GranularSpec;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/** Instrument name that selects the modal mallet-and-bell engine (SDK 1.25.0). */
|
|
1555
|
+
export const MODAL_INSTRUMENT = "modal";
|
|
1556
|
+
|
|
1557
|
+
/** Modal presets (dawg's core/resonators.ts). */
|
|
1558
|
+
export type ModalPresetName =
|
|
1559
|
+
| "marimba"
|
|
1560
|
+
| "vibes"
|
|
1561
|
+
| "xylophone"
|
|
1562
|
+
| "glock"
|
|
1563
|
+
| "celesta"
|
|
1564
|
+
| "chimes"
|
|
1565
|
+
| "kalimba"
|
|
1566
|
+
| "mbira"
|
|
1567
|
+
| "steelpan"
|
|
1568
|
+
| "bowl"
|
|
1569
|
+
| "gong"
|
|
1570
|
+
| "timpani";
|
|
1571
|
+
|
|
1572
|
+
/** Modal overrides; omitted means the preset's value. dawg validates ranges. */
|
|
1573
|
+
export type ModalParams = Readonly<{
|
|
1574
|
+
/** `yarn` `cord` `rubber` `plastic` `brass`: sets hardness. */
|
|
1575
|
+
mallet?: "yarn" | "cord" | "rubber" | "plastic" | "brass";
|
|
1576
|
+
/** Mallet hardness 0..1: brighter, shorter contact. */
|
|
1577
|
+
hardness?: number;
|
|
1578
|
+
/** Strike position 0..1 (0.5 is the bar's centre). */
|
|
1579
|
+
position?: number;
|
|
1580
|
+
/** Fundamental ring time (T60 seconds). */
|
|
1581
|
+
ring?: number;
|
|
1582
|
+
/** How much faster upper modes die (octaves of decay per octave). */
|
|
1583
|
+
tilt?: number;
|
|
1584
|
+
/** Damping on note-off 0..1 (0 lets the bar ring). */
|
|
1585
|
+
damp?: number;
|
|
1586
|
+
/** Choke time after note-off, seconds. */
|
|
1587
|
+
release?: number;
|
|
1588
|
+
/** Vibraphone motor rate Hz and depth 0..1. */
|
|
1589
|
+
motor?: number;
|
|
1590
|
+
motordepth?: number;
|
|
1591
|
+
/** Beat between paired gamelan modes, Hz. */
|
|
1592
|
+
ombak?: number;
|
|
1593
|
+
/** Mbira buzz 0..1 and mallet click 0..1. */
|
|
1594
|
+
buzz?: number;
|
|
1595
|
+
click?: number;
|
|
1596
|
+
/** Strike pitch bend in semitones and its decay seconds (Strudel penv/pdecay). */
|
|
1597
|
+
strikebend?: number;
|
|
1598
|
+
strikedecay?: number;
|
|
1599
|
+
/** Output level 0..2 (1 is the preset level). */
|
|
1600
|
+
gain?: number;
|
|
1601
|
+
/** Mode table override (`marimba`, `bell`, `gong`, …). */
|
|
1602
|
+
body?: string;
|
|
1603
|
+
}>;
|
|
1604
|
+
|
|
1605
|
+
const MODAL_KEYS = Object.freeze([
|
|
1606
|
+
"mallet",
|
|
1607
|
+
"hardness",
|
|
1608
|
+
"position",
|
|
1609
|
+
"ring",
|
|
1610
|
+
"tilt",
|
|
1611
|
+
"damp",
|
|
1612
|
+
"release",
|
|
1613
|
+
"motor",
|
|
1614
|
+
"motordepth",
|
|
1615
|
+
"ombak",
|
|
1616
|
+
"buzz",
|
|
1617
|
+
"click",
|
|
1618
|
+
"strikebend",
|
|
1619
|
+
"strikedecay",
|
|
1620
|
+
"gain",
|
|
1621
|
+
"body",
|
|
1622
|
+
] as const);
|
|
1623
|
+
|
|
1624
|
+
/** Mallet words (core/resonators.ts MODAL_MALLETS). */
|
|
1625
|
+
const MODAL_MALLET_WORDS: readonly string[] = Object.freeze([
|
|
1626
|
+
"yarn",
|
|
1627
|
+
"cord",
|
|
1628
|
+
"rubber",
|
|
1629
|
+
"plastic",
|
|
1630
|
+
"brass",
|
|
1631
|
+
]);
|
|
1632
|
+
|
|
1633
|
+
/** Mode tables a `body` override may name (core/resonators.ts MODAL_BODIES). */
|
|
1634
|
+
const MODAL_BODY_WORDS: readonly string[] = Object.freeze([
|
|
1635
|
+
"marimba",
|
|
1636
|
+
"vibraphone",
|
|
1637
|
+
"xylophone",
|
|
1638
|
+
"glockenspiel",
|
|
1639
|
+
"celesta",
|
|
1640
|
+
"chimes",
|
|
1641
|
+
"crotale",
|
|
1642
|
+
"mbira",
|
|
1643
|
+
"kalimba",
|
|
1644
|
+
"musicbox",
|
|
1645
|
+
"toypiano",
|
|
1646
|
+
"saron",
|
|
1647
|
+
"bonang",
|
|
1648
|
+
"gender",
|
|
1649
|
+
"kempul",
|
|
1650
|
+
"gong",
|
|
1651
|
+
"bell",
|
|
1652
|
+
"steelpan",
|
|
1653
|
+
"bowl",
|
|
1654
|
+
"timpani",
|
|
1655
|
+
"tabla",
|
|
1656
|
+
"frame",
|
|
1657
|
+
]);
|
|
1658
|
+
|
|
1659
|
+
/** Numeric ranges (core/resonators.ts MODAL_PARAMS min..max). */
|
|
1660
|
+
const MODAL_RANGES: Readonly<Record<string, readonly [number, number]>> =
|
|
1661
|
+
Object.freeze({
|
|
1662
|
+
hardness: [0, 1],
|
|
1663
|
+
position: [0, 1],
|
|
1664
|
+
ring: [0.05, 30],
|
|
1665
|
+
tilt: [0, 2],
|
|
1666
|
+
damp: [0, 1],
|
|
1667
|
+
release: [0.005, 2],
|
|
1668
|
+
motor: [0, 12],
|
|
1669
|
+
motordepth: [0, 1],
|
|
1670
|
+
ombak: [0, 12],
|
|
1671
|
+
buzz: [0, 1],
|
|
1672
|
+
click: [0, 1],
|
|
1673
|
+
strikebend: [-24, 24],
|
|
1674
|
+
strikedecay: [0.001, 2],
|
|
1675
|
+
gain: [0, 2],
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
const MODAL_PRESET_WORDS: readonly string[] = Object.freeze([
|
|
1679
|
+
"marimba",
|
|
1680
|
+
"vibes",
|
|
1681
|
+
"xylophone",
|
|
1682
|
+
"glock",
|
|
1683
|
+
"celesta",
|
|
1684
|
+
"chimes",
|
|
1685
|
+
"kalimba",
|
|
1686
|
+
"mbira",
|
|
1687
|
+
"steelpan",
|
|
1688
|
+
"bowl",
|
|
1689
|
+
"gong",
|
|
1690
|
+
"timpani",
|
|
1691
|
+
]);
|
|
1692
|
+
|
|
1693
|
+
/** Result of `modal()`; pass it as a track's `instrument`. */
|
|
1694
|
+
export type ModalSpec = Readonly<
|
|
1695
|
+
{ kind: "modal"; preset?: ModalPresetName } & ModalParams
|
|
1696
|
+
>;
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Mallets and bells on the modal engine (SDK 1.25.0): a preset and
|
|
1700
|
+
* optional overrides. A preset word alone (`instrument: "vibes"`) is the
|
|
1701
|
+
* same as `modal("vibes")`, except `"marimba"`, which stays the legacy
|
|
1702
|
+
* marimba voice; `modal("marimba")` is the modal one.
|
|
1703
|
+
*
|
|
1704
|
+
* ```ts
|
|
1705
|
+
* instrument: modal("vibes", { motor: 4, hardness: 0.6 })
|
|
1706
|
+
* instrument: modal("marimba", { mallet: "rubber" })
|
|
1707
|
+
* instrument: modal({ ring: 2 }) // default preset (marimba)
|
|
1708
|
+
* ```
|
|
1709
|
+
*/
|
|
1710
|
+
export function modal(
|
|
1711
|
+
preset?: ModalPresetName | ModalParams,
|
|
1712
|
+
params: ModalParams = {},
|
|
1713
|
+
): ModalSpec {
|
|
1714
|
+
const overrides = isRecord(preset) ? preset : params;
|
|
1715
|
+
const name = isRecord(preset) ? undefined : preset;
|
|
1716
|
+
if (!isRecord(overrides))
|
|
1717
|
+
throw new DawgSdkError("modal params must be an object");
|
|
1718
|
+
const out: Record<string, unknown> = { kind: "modal" };
|
|
1719
|
+
if (name !== undefined) {
|
|
1720
|
+
if (typeof name !== "string" || !MODAL_PRESET_WORDS.includes(name))
|
|
1721
|
+
throw new DawgSdkError(
|
|
1722
|
+
`modal preset "${String(name).slice(0, 32)}" is not one of ${MODAL_PRESET_WORDS.join(" ")}`,
|
|
1723
|
+
);
|
|
1724
|
+
out.preset = name;
|
|
1725
|
+
}
|
|
1726
|
+
for (const key of Object.keys(overrides)) {
|
|
1727
|
+
const value = (overrides as Record<string, unknown>)[key];
|
|
1728
|
+
if (value === undefined) continue;
|
|
1729
|
+
if (key === "mallet" || key === "body") {
|
|
1730
|
+
const words = key === "mallet" ? MODAL_MALLET_WORDS : MODAL_BODY_WORDS;
|
|
1731
|
+
if (typeof value !== "string" || !words.includes(value))
|
|
1732
|
+
throw new DawgSdkError(
|
|
1733
|
+
`modal ${key} "${String(value).slice(0, 32)}" is not one of ${words.join(" ")}`,
|
|
1734
|
+
);
|
|
1735
|
+
out[key] = value;
|
|
1736
|
+
} else if ((MODAL_KEYS as readonly string[]).includes(key)) {
|
|
1737
|
+
const number = finite(value, `modal ${key}`);
|
|
1738
|
+
const [min, max] = MODAL_RANGES[key]!;
|
|
1739
|
+
if (number < min || number > max)
|
|
1740
|
+
throw new DawgSdkError(`modal ${key} must be ${min}..${max}`);
|
|
1741
|
+
out[key] = number;
|
|
1742
|
+
} else
|
|
1743
|
+
throw new DawgSdkError(
|
|
1744
|
+
`modal has no parameter "${key.slice(0, 32)}" (${MODAL_KEYS.join(" ")})`,
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
return Object.freeze(out) as ModalSpec;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1217
1750
|
/**
|
|
1218
1751
|
* `count` equal slices of one file as voices `prefix0 … prefixN-1`, for
|
|
1219
1752
|
* chopped breaks: `sampler(slices("samples/break.wav", 8, "brk"))`, then
|
|
@@ -1226,7 +1759,7 @@ export function slices(
|
|
|
1226
1759
|
): Record<string, SampleSpec> {
|
|
1227
1760
|
if (!Number.isInteger(count) || count < 1 || count > 64)
|
|
1228
1761
|
throw new DawgSdkError("slices count must be an integer 1..64");
|
|
1229
|
-
const base =
|
|
1762
|
+
const base = sampleSpec(src, prefix);
|
|
1230
1763
|
const begin = base.begin ?? 0;
|
|
1231
1764
|
const end = base.end ?? 1;
|
|
1232
1765
|
const span = (end - begin) / count;
|
|
@@ -1241,7 +1774,7 @@ export function slices(
|
|
|
1241
1774
|
return voices;
|
|
1242
1775
|
}
|
|
1243
1776
|
|
|
1244
|
-
function
|
|
1777
|
+
function sampleSpec(value: string | SampleSpec, name: string): SampleSpec {
|
|
1245
1778
|
const spec = typeof value === "string" ? { src: value } : value;
|
|
1246
1779
|
if (!isRecord(spec) || typeof spec.src !== "string" || spec.src.length === 0)
|
|
1247
1780
|
throw new DawgSdkError(`sampler voice ${name} needs a src path`);
|
|
@@ -1264,6 +1797,9 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
|
|
|
1264
1797
|
fit?: boolean;
|
|
1265
1798
|
accelerate?: number;
|
|
1266
1799
|
squiz?: number;
|
|
1800
|
+
bpm?: number;
|
|
1801
|
+
fitmode?: "repitch" | "beats" | "tones";
|
|
1802
|
+
len?: number;
|
|
1267
1803
|
} = { src: spec.src };
|
|
1268
1804
|
if (spec.src.startsWith("pack:")) {
|
|
1269
1805
|
if (spec.sha256 !== undefined)
|
|
@@ -1313,9 +1849,36 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
|
|
|
1313
1849
|
if (spec.accelerate !== undefined)
|
|
1314
1850
|
out.accelerate = finite(spec.accelerate, `${name} accelerate`);
|
|
1315
1851
|
if (spec.squiz !== undefined) out.squiz = finite(spec.squiz, `${name} squiz`);
|
|
1852
|
+
if (spec.bpm !== undefined) out.bpm = finite(spec.bpm, `${name} bpm`);
|
|
1853
|
+
if (spec.fitmode !== undefined) {
|
|
1854
|
+
if (
|
|
1855
|
+
spec.fitmode !== "repitch" &&
|
|
1856
|
+
spec.fitmode !== "beats" &&
|
|
1857
|
+
spec.fitmode !== "tones"
|
|
1858
|
+
)
|
|
1859
|
+
throw new DawgSdkError(
|
|
1860
|
+
`${name} fitmode must be "repitch", "beats" or "tones"`,
|
|
1861
|
+
);
|
|
1862
|
+
out.fitmode = spec.fitmode;
|
|
1863
|
+
}
|
|
1864
|
+
if (spec.len !== undefined) out.len = finite(spec.len, `${name} len`);
|
|
1316
1865
|
return Object.freeze(out);
|
|
1317
1866
|
}
|
|
1318
1867
|
|
|
1868
|
+
/**
|
|
1869
|
+
* One sample file with options (SDK 1.20.0), for `sampler({ brk: ... })`:
|
|
1870
|
+
* `sample("samples/break.wav", { bpm: 174, fitmode: "beats" })` plays a
|
|
1871
|
+
* 174 BPM break in time with the song, cut at its hits.
|
|
1872
|
+
*/
|
|
1873
|
+
export function sample(
|
|
1874
|
+
src: string,
|
|
1875
|
+
options: Omit<SampleSpec, "src"> = {},
|
|
1876
|
+
): SampleSpec {
|
|
1877
|
+
if (typeof src !== "string" || src.length === 0)
|
|
1878
|
+
throw new DawgSdkError("sample() needs a src path");
|
|
1879
|
+
return Object.freeze({ ...options, src });
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1319
1882
|
// ---------------------------------------------------------------------------
|
|
1320
1883
|
// Tracks
|
|
1321
1884
|
|
|
@@ -1345,14 +1908,177 @@ export type AutomationInput = Readonly<{
|
|
|
1345
1908
|
wt?: readonly Point[];
|
|
1346
1909
|
}>;
|
|
1347
1910
|
|
|
1911
|
+
/**
|
|
1912
|
+
* Every rig preset (SDK 1.22.0), kept equal to `RIG_PRESETS` in
|
|
1913
|
+
* core/fx.ts by `print-rig.test.ts`: the stomp, head and cab stages plus
|
|
1914
|
+
* the companion effects a few rigs need (funk's and wah's autofilter,
|
|
1915
|
+
* bachata's chorus, jangle's compressor). `spring`'s short room is the
|
|
1916
|
+
* track's `reverb`, not `fx`: give it as
|
|
1917
|
+
* `reverb: { mix: 0.3, size: 0.35, fade: 1.5, predelay: 0, dim: 3500 }`.
|
|
1918
|
+
*/
|
|
1919
|
+
export const RIG_PRESETS: Readonly<
|
|
1920
|
+
Record<
|
|
1921
|
+
string,
|
|
1922
|
+
Readonly<
|
|
1923
|
+
Record<"stomp" | "head" | "cab", EffectParams | undefined> &
|
|
1924
|
+
Readonly<Record<string, EffectParams | undefined>>
|
|
1925
|
+
>
|
|
1926
|
+
>
|
|
1927
|
+
> = Object.freeze({
|
|
1928
|
+
clean: {
|
|
1929
|
+
stomp: undefined,
|
|
1930
|
+
head: { type: "clean", gain: 3, treble: 6 },
|
|
1931
|
+
cab: { type: "1x12" },
|
|
1932
|
+
},
|
|
1933
|
+
crunch: {
|
|
1934
|
+
stomp: undefined,
|
|
1935
|
+
head: { type: "crunch", gain: 5 },
|
|
1936
|
+
cab: { type: "4x12" },
|
|
1937
|
+
},
|
|
1938
|
+
punk: {
|
|
1939
|
+
stomp: undefined,
|
|
1940
|
+
head: { type: "crunch", gain: 7, mid: 6, master: 6 },
|
|
1941
|
+
cab: { type: "4x12", mic: 0.2 },
|
|
1942
|
+
},
|
|
1943
|
+
ragged: {
|
|
1944
|
+
stomp: { type: "face", gain: 6, tone: 0.6 },
|
|
1945
|
+
head: { type: "chime", gain: 4 },
|
|
1946
|
+
cab: { type: "2x12" },
|
|
1947
|
+
},
|
|
1948
|
+
lead: {
|
|
1949
|
+
stomp: { type: "od", gain: 3, tone: 0.5, level: 3 },
|
|
1950
|
+
head: { type: "lead", gain: 6, mid: 6 },
|
|
1951
|
+
cab: { type: "4x12" },
|
|
1952
|
+
},
|
|
1953
|
+
metal: {
|
|
1954
|
+
stomp: { type: "od", gain: 0, tone: 0.6, level: 6 },
|
|
1955
|
+
head: { type: "high", gain: 7, bass: 6, mid: 3, treble: 7, gate: -55 },
|
|
1956
|
+
cab: { type: "4x12", mic: 0.2 },
|
|
1957
|
+
},
|
|
1958
|
+
fuzz: {
|
|
1959
|
+
stomp: { type: "fuzz", gain: 7, tone: 0.5 },
|
|
1960
|
+
head: { type: "clean", gain: 4 },
|
|
1961
|
+
cab: { type: "2x12" },
|
|
1962
|
+
},
|
|
1963
|
+
octave: {
|
|
1964
|
+
stomp: { type: "octave", gain: 6, tone: 0.6, octave: 0.8 },
|
|
1965
|
+
head: { type: "clean", gain: 3 },
|
|
1966
|
+
cab: { type: "1x12" },
|
|
1967
|
+
},
|
|
1968
|
+
funk: {
|
|
1969
|
+
stomp: undefined,
|
|
1970
|
+
head: { type: "clean", gain: 2, treble: 7, presence: 6 },
|
|
1971
|
+
cab: { type: "2x12" },
|
|
1972
|
+
autofilter: {
|
|
1973
|
+
type: "bpf",
|
|
1974
|
+
sync: 0,
|
|
1975
|
+
rate: 0.01,
|
|
1976
|
+
depth: 0,
|
|
1977
|
+
follow: 3,
|
|
1978
|
+
cutoff: 500,
|
|
1979
|
+
resonance: 0.6,
|
|
1980
|
+
},
|
|
1981
|
+
},
|
|
1982
|
+
wah: {
|
|
1983
|
+
stomp: undefined,
|
|
1984
|
+
head: { type: "crunch", gain: 4 },
|
|
1985
|
+
cab: { type: "2x12" },
|
|
1986
|
+
autofilter: {
|
|
1987
|
+
type: "bpf",
|
|
1988
|
+
sync: 0.5,
|
|
1989
|
+
depth: 2,
|
|
1990
|
+
shape: "sine",
|
|
1991
|
+
cutoff: 700,
|
|
1992
|
+
resonance: 0.6,
|
|
1993
|
+
},
|
|
1994
|
+
},
|
|
1995
|
+
bachata: {
|
|
1996
|
+
stomp: undefined,
|
|
1997
|
+
head: { type: "clean", gain: 2, mid: 6, treble: 7 },
|
|
1998
|
+
cab: { type: "1x12", mic: 0.2 },
|
|
1999
|
+
chorus: { rate: 0.8, depth: 0.25, mix: 0.3 },
|
|
2000
|
+
},
|
|
2001
|
+
spring: {
|
|
2002
|
+
stomp: undefined,
|
|
2003
|
+
head: { type: "clean", gain: 3, treble: 6 },
|
|
2004
|
+
cab: { type: "open" },
|
|
2005
|
+
},
|
|
2006
|
+
bassdrive: {
|
|
2007
|
+
stomp: { type: "od", gain: 4, tone: 0.5, mix: 0.6 },
|
|
2008
|
+
head: { type: "bass", gain: 4 },
|
|
2009
|
+
cab: { type: "8x10" },
|
|
2010
|
+
},
|
|
2011
|
+
reese: {
|
|
2012
|
+
stomp: { type: "rat", gain: 3, tone: 0.3, mix: 0.5 },
|
|
2013
|
+
head: { type: "bass", gain: 6, master: 6 },
|
|
2014
|
+
cab: { type: "1x15" },
|
|
2015
|
+
},
|
|
2016
|
+
jangle: {
|
|
2017
|
+
stomp: undefined,
|
|
2018
|
+
head: { type: "chime", gain: 3, treble: 7 },
|
|
2019
|
+
cab: { type: "2x12", mic: 0.2 },
|
|
2020
|
+
compressor: { threshold: -20, ratio: 4, attack: 0.01, release: 0.15 },
|
|
2021
|
+
},
|
|
2022
|
+
alt: {
|
|
2023
|
+
stomp: { type: "rat", gain: 6, tone: 0.4 },
|
|
2024
|
+
head: { type: "crunch", gain: 4 },
|
|
2025
|
+
cab: { type: "4x12" },
|
|
2026
|
+
},
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
/**
|
|
2030
|
+
* A guitar rig for a track's `fx` (SDK 1.22.0): the stomp → head → cab
|
|
2031
|
+
* stages of rig preset `name` and its companion effects (as the `rig`
|
|
2032
|
+
* command sets them), with optional per-stage overrides. Spread it
|
|
2033
|
+
* into `fx` next to other effects:
|
|
2034
|
+
*
|
|
2035
|
+
* ```ts
|
|
2036
|
+
* fx: { ...rig("crunch"), chorus: {} }
|
|
2037
|
+
* fx: { ...rig("metal", { head: { gain: 9 } }) }
|
|
2038
|
+
* ```
|
|
2039
|
+
*/
|
|
2040
|
+
export function rig(
|
|
2041
|
+
name: string,
|
|
2042
|
+
overrides: Readonly<
|
|
2043
|
+
Partial<Record<"stomp" | "head" | "cab", EffectParams>>
|
|
2044
|
+
> = {},
|
|
2045
|
+
): FxInput {
|
|
2046
|
+
if (
|
|
2047
|
+
typeof name !== "string" ||
|
|
2048
|
+
!Object.prototype.hasOwnProperty.call(RIG_PRESETS, name)
|
|
2049
|
+
)
|
|
2050
|
+
throw new DawgSdkError(
|
|
2051
|
+
`unknown rig "${String(name)}" (rigs: ${Object.keys(RIG_PRESETS).join(", ")})`,
|
|
2052
|
+
);
|
|
2053
|
+
if (!isRecord(overrides))
|
|
2054
|
+
throw new DawgSdkError("rig overrides must be an object");
|
|
2055
|
+
const out: Record<string, EffectParams> = {};
|
|
2056
|
+
// Companion effects first, then the stages in chain order.
|
|
2057
|
+
for (const [effect, values] of Object.entries(RIG_PRESETS[name]!))
|
|
2058
|
+
if (values && effect !== "stomp" && effect !== "head" && effect !== "cab")
|
|
2059
|
+
out[effect] = Object.freeze({ ...values });
|
|
2060
|
+
for (const stage of ["stomp", "head", "cab"] as const) {
|
|
2061
|
+
const base = RIG_PRESETS[name]![stage];
|
|
2062
|
+
const extra = overrides[stage];
|
|
2063
|
+
if (extra !== undefined && !isRecord(extra))
|
|
2064
|
+
throw new DawgSdkError(`rig ${stage} overrides must be an object`);
|
|
2065
|
+
if (base || extra)
|
|
2066
|
+
out[stage] = Object.freeze({ ...(base ?? {}), ...(extra ?? {}) });
|
|
2067
|
+
}
|
|
2068
|
+
return Object.freeze(out);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1348
2071
|
/** One effect's parameters; omitted ones take dawg's defaults. */
|
|
1349
2072
|
export type EffectParams = Readonly<Record<string, number | string | boolean>>;
|
|
1350
2073
|
|
|
1351
2074
|
/**
|
|
1352
2075
|
* Insert effects by name, rendered in the fixed chain order
|
|
1353
|
-
* filter → djf → autofilter → vowel → crush → distort →
|
|
1354
|
-
* compressor → pan → phaser → chorus → leslie → postgain →
|
|
1355
|
-
*
|
|
2076
|
+
* filter → djf → autofilter → vowel → crush → distort → stomp → head →
|
|
2077
|
+
* cab → tremolo → compressor → pan → phaser → chorus → leslie → postgain →
|
|
2078
|
+
* delay → reverb. The guitar rig (stomp, head, cab; SDK 1.22.0) is easiest
|
|
2079
|
+
* as `...rig("crunch")`.
|
|
2080
|
+
* Keys here: djf, autofilter, vowel, crush, distort, stomp, head, cab,
|
|
2081
|
+
* tremolo, compressor,
|
|
1356
2082
|
* phaser, chorus, leslie, postgain, plus the mix-bus keys `orbit`
|
|
1357
2083
|
* (`{ orbit: 2 }`, SDK 1.9.0) and `duck` (`{ orbit: 2, depth: 0.85 }`:
|
|
1358
2084
|
* this track's onsets duck every other track on that orbit). See
|
|
@@ -1366,6 +2092,53 @@ export type FxInput = Readonly<Record<string, EffectParams>>;
|
|
|
1366
2092
|
* `"synth-<param>"` (e.g. `"synth-lpf"`), read at each note's onset.
|
|
1367
2093
|
* Every parameter, range and default: **Synth** in DAWG.md.
|
|
1368
2094
|
*/
|
|
2095
|
+
/**
|
|
2096
|
+
* Modelled piano settings (SDK 1.24.0), for a track whose instrument is a
|
|
2097
|
+
* piano family (`"grand"`, `"upright"`, `"felt"`, `"honkytonk"`,
|
|
2098
|
+
* `"prepared"`; the words `"ballad"` and `"lofi"` pick presets). Every field
|
|
2099
|
+
* is optional; `{}` is the family's own sound. Automate a parameter with
|
|
2100
|
+
* `automation.fx["keys-<param>"]` (hardness, touch, decay, release, knock,
|
|
2101
|
+
* noise, felt), read at each note's onset. Ranges: **Keys** in DAWG.md.
|
|
2102
|
+
*/
|
|
2103
|
+
export type KeysInput = Readonly<{
|
|
2104
|
+
/** A named preset: grand ballad upright felt lofi honkytonk prepared. */
|
|
2105
|
+
preset?: string;
|
|
2106
|
+
/** Hammer hardness 0..1: brightness at a given velocity (0.5). */
|
|
2107
|
+
hardness?: number;
|
|
2108
|
+
/** Velocity sensitivity 0..1 (1). */
|
|
2109
|
+
touch?: number;
|
|
2110
|
+
/** Inharmonicity multiplier 0..4 (1 grand, 2.5 upright, 0 harmonic). */
|
|
2111
|
+
inharm?: number;
|
|
2112
|
+
/** Unison detune in cents 0..30 (0.7; honkytonk 16). */
|
|
2113
|
+
unison?: number;
|
|
2114
|
+
/** Sustain time multiplier 0.1..4 (1). */
|
|
2115
|
+
decay?: number;
|
|
2116
|
+
/** Damper time multiplier 0.1..4 (1). */
|
|
2117
|
+
release?: number;
|
|
2118
|
+
/** Hammer position along the string 0.04..0.3 (0.12). */
|
|
2119
|
+
strike?: number;
|
|
2120
|
+
/** Aftersound share 0..1 (0.3). */
|
|
2121
|
+
after?: number;
|
|
2122
|
+
/** Soundboard knock 0..1 (0.5). */
|
|
2123
|
+
knock?: number;
|
|
2124
|
+
/** Key and damper mechanics 0..1 (0.25). */
|
|
2125
|
+
noise?: number;
|
|
2126
|
+
/** Felt strip 0..1 (0; felt family 1). */
|
|
2127
|
+
felt?: number;
|
|
2128
|
+
/** Share of prepared keys 0..1 (0; prepared family 0.6). */
|
|
2129
|
+
prep?: number;
|
|
2130
|
+
/** Keyboard stereo width 0..1 (0.6). */
|
|
2131
|
+
width?: number;
|
|
2132
|
+
/** Octave stretch 0..1 (1); 0 keeps every key exactly on its tuning. */
|
|
2133
|
+
stretch?: number;
|
|
2134
|
+
/** Body EQ: grand upright felt honkytonk prepared (the family's own). */
|
|
2135
|
+
body?: string;
|
|
2136
|
+
/** Pitch wobble rate in Hz (tape wow), 0 off. */
|
|
2137
|
+
vib?: number;
|
|
2138
|
+
/** Pitch wobble depth in semitones (0.5). */
|
|
2139
|
+
vibmod?: number;
|
|
2140
|
+
}>;
|
|
2141
|
+
|
|
1369
2142
|
export type SynthInput = Readonly<{
|
|
1370
2143
|
attack?: number;
|
|
1371
2144
|
decay?: number;
|
|
@@ -1449,17 +2222,60 @@ export type TrackInput = Readonly<{
|
|
|
1449
2222
|
* `triangle`, and Strudel's `sawtooth`, `supersaw`, `pulse`, `user`,
|
|
1450
2223
|
* `white`, `pink`, `brown`, `crackle`, and the ZzFX sounds `z_sine`,
|
|
1451
2224
|
* `z_triangle`, `z_sawtooth`, `z_square`, `z_tan`, `z_noise`), `kit` for drums,
|
|
1452
|
-
* `sampler(...)` or `wavetable(...)
|
|
2225
|
+
* `sampler(...)` or `wavetable(...)`, or a mallet or bell (`vibes`,
|
|
2226
|
+
* `glock`, `gong`, … or `modal(...)`, SDK 1.25.0). Default `sine`.
|
|
2227
|
+
*/
|
|
2228
|
+
instrument?:
|
|
2229
|
+
| string
|
|
2230
|
+
| SamplerSpec
|
|
2231
|
+
| WavetableSpec
|
|
2232
|
+
| StringSpec
|
|
2233
|
+
| GranularSpec
|
|
2234
|
+
| ModalSpec;
|
|
2235
|
+
/**
|
|
2236
|
+
* The sampler a `granular(...)` track keeps while it grains one of its
|
|
2237
|
+
* voices (SDK 1.23.0); `grain off` plays it again.
|
|
2238
|
+
*/
|
|
2239
|
+
sampler?: SamplerSpec | null;
|
|
2240
|
+
/**
|
|
2241
|
+
* Granular engine (SDK 1.23.0) for an `instrument: "granular"` track, or
|
|
2242
|
+
* use `instrument: granular("cloud", {...})` or a word (`"cloud"`).
|
|
1453
2243
|
*/
|
|
1454
|
-
|
|
2244
|
+
granular?: GranularInput | null;
|
|
1455
2245
|
/**
|
|
1456
2246
|
* Synthesized drum kit for an `instrument: "kit"` track: `syn808`,
|
|
1457
2247
|
* `syn909`, `acoustic`, `lofi`, `electro` or `trap`. Omit for the default
|
|
1458
2248
|
* voices.
|
|
1459
2249
|
*/
|
|
1460
2250
|
kit?: string;
|
|
2251
|
+
/**
|
|
2252
|
+
* The track's own clock against the song (SDK 1.14.0): `rate` 1.5 plays
|
|
2253
|
+
* three beats in two, `phase` starts it that many beats later, `cycle`
|
|
2254
|
+
* repeats its first `cycle` beats (a 3-beat cycle over 4/4 is polymeter).
|
|
2255
|
+
* `{ cycle: 3, rate: 13 / 12 }` drifts against a twin and realigns, the
|
|
2256
|
+
* tape phasing of Reich's Come Out; `phasing()` works the rate out for
|
|
2257
|
+
* you, and `stepPhasing()` gives Piano Phase's shift and hold.
|
|
2258
|
+
*/
|
|
2259
|
+
time?: TrackTimeInput;
|
|
2260
|
+
/**
|
|
2261
|
+
* This track's tuning over the song's (SDK 1.16.0): a library name such
|
|
2262
|
+
* as `"pelog"` or `{ edo, ratios, cents, scl, kbm, ref, root, map }`.
|
|
2263
|
+
* `{ ref: 432 }` alone keeps the song's table at another pitch.
|
|
2264
|
+
*/
|
|
2265
|
+
tuning?: TuningInput | null;
|
|
1461
2266
|
/** Synth voice parameters, Strudel names (`{ attack: 0.01, lpf: 800 }`). */
|
|
1462
2267
|
synth?: SynthInput;
|
|
2268
|
+
/**
|
|
2269
|
+
* String engine (SDK 1.21.0) for an `instrument: "string"` track, or use
|
|
2270
|
+
* `instrument: stringed("sitar", {...})` or a preset word (`"nylon"`).
|
|
2271
|
+
*/
|
|
2272
|
+
string?: StringInput | null;
|
|
2273
|
+
/**
|
|
2274
|
+
* Modelled piano settings (SDK 1.24.0) for `instrument: "grand"` and the
|
|
2275
|
+
* other piano families; `{}` is the family's sound. The word `"piano"`
|
|
2276
|
+
* keeps the classic 0.4 tone; use `"grand"` for the modelled piano.
|
|
2277
|
+
*/
|
|
2278
|
+
keys?: KeysInput;
|
|
1463
2279
|
muted?: boolean;
|
|
1464
2280
|
/** When any track is soloed only soloed tracks play. */
|
|
1465
2281
|
solo?: boolean;
|
|
@@ -1476,6 +2292,34 @@ export type TrackInput = Readonly<{
|
|
|
1476
2292
|
/** Insert effects by name (`{ distort: { drive: 3 }, chorus: {} }`). */
|
|
1477
2293
|
fx?: FxInput;
|
|
1478
2294
|
automation?: AutomationInput;
|
|
2295
|
+
/**
|
|
2296
|
+
* Glide between notes (SDK 1.15.0): seconds (`glide: 0.08`, TB-303 style
|
|
2297
|
+
* legato) or `{ time, mode }`. Mode `legato` glides only into a note that
|
|
2298
|
+
* overlaps the previous one and does not retrigger it; `mono` always
|
|
2299
|
+
* glides and retriggers; `poly` glides every voice of a chord from the
|
|
2300
|
+
* matching voice of the previous one.
|
|
2301
|
+
*/
|
|
2302
|
+
glide?: number | Readonly<{ time?: number; mode?: GlideMode }>;
|
|
2303
|
+
/** Sustain pedal changes as `[beat, "down" | "half" | "up"]` (SDK 1.15.0). */
|
|
2304
|
+
pedal?: readonly (readonly [number, PedalState])[];
|
|
2305
|
+
/**
|
|
2306
|
+
* Velocity response (SDK 1.15.0): `soft` (quiet notes louder), `hard`
|
|
2307
|
+
* (needs a firm touch), `fixed` (every note at 0.8, like an organ) or
|
|
2308
|
+
* `{ curve: "fixed", fixed: 0.6 }`. Default `linear`.
|
|
2309
|
+
*/
|
|
2310
|
+
velocityCurve?:
|
|
2311
|
+
VelocityCurveName | Readonly<{ curve: VelocityCurveName; fixed?: number }>;
|
|
2312
|
+
/**
|
|
2313
|
+
* Seeded humanize applied when dawg renders, so the notes stay as written
|
|
2314
|
+
* (SDK 1.15.0): `timing` ms either side, `velocity` and `length` in
|
|
2315
|
+
* percent, `seed` (default 1) picks another take.
|
|
2316
|
+
*/
|
|
2317
|
+
humanize?: Readonly<{
|
|
2318
|
+
timing?: number;
|
|
2319
|
+
velocity?: number;
|
|
2320
|
+
length?: number;
|
|
2321
|
+
seed?: number;
|
|
2322
|
+
}>;
|
|
1479
2323
|
/** `note()`/`seq()` for pitched tracks, `hit()`/`hits()` for kits and one-shot samplers. */
|
|
1480
2324
|
notes?: readonly (NoteSpec | HitSpec)[];
|
|
1481
2325
|
/**
|
|
@@ -1533,6 +2377,22 @@ export type ReverbInput = Readonly<{
|
|
|
1533
2377
|
}>;
|
|
1534
2378
|
}>;
|
|
1535
2379
|
|
|
2380
|
+
/** `track({ time })`: every field optional; absent follows the song. */
|
|
2381
|
+
export type TrackTimeInput = Readonly<{
|
|
2382
|
+
/** Tempo ratio against the song, 0.125..8 (1 = in step). */
|
|
2383
|
+
rate?: number;
|
|
2384
|
+
/** Beats the track's pattern starts late (negative: early); it wraps. */
|
|
2385
|
+
phase?: number;
|
|
2386
|
+
/** Beats of the track that repeat, default the whole song loop. */
|
|
2387
|
+
cycle?: number;
|
|
2388
|
+
/**
|
|
2389
|
+
* Stepped phasing (SDK 1.19.0), as in Reich's Piano Phase: hold `hold`
|
|
2390
|
+
* cycles in step, then move `shift` beats ahead over `drift` cycles, and
|
|
2391
|
+
* repeat. Needs `cycle`; replaces `rate`. `stepPhasing()` builds it.
|
|
2392
|
+
*/
|
|
2393
|
+
steps?: Readonly<{ shift: number; hold: number; drift: number }>;
|
|
2394
|
+
}>;
|
|
2395
|
+
|
|
1536
2396
|
/** Frozen track built by `track()`; `song()` consumes it. Beats, not ticks. */
|
|
1537
2397
|
export type TrackSpec = Readonly<{
|
|
1538
2398
|
kind: "track";
|
|
@@ -1555,14 +2415,147 @@ export type TrackSpec = Readonly<{
|
|
|
1555
2415
|
synth: SynthInput | null;
|
|
1556
2416
|
sampler: SamplerSpec | null;
|
|
1557
2417
|
wavetable: WavetableSpec | null;
|
|
2418
|
+
/** String engine settings (SDK 1.21.0); present only when set. */
|
|
2419
|
+
string?: StringInput;
|
|
2420
|
+
/** Granular engine settings (SDK 1.23.0); null when not granular. */
|
|
2421
|
+
granular?: GranularInput | null;
|
|
1558
2422
|
automation: Readonly<Required<AutomationInput>>;
|
|
1559
2423
|
/** Every hit resolved to its pitch slot. */
|
|
1560
2424
|
notes: readonly NoteSpec[];
|
|
1561
2425
|
/** Rhythm rows in order (voice names as written). */
|
|
1562
2426
|
rhythm: readonly RhythmSpec[];
|
|
1563
2427
|
kit: string | null;
|
|
2428
|
+
/** Present only when `track({ time })` set something. */
|
|
2429
|
+
time?: TrackTimeInput;
|
|
2430
|
+
/** Performance (SDK 1.15.0); present only when set. */
|
|
2431
|
+
glide?: Readonly<{ time: number; mode: GlideMode }>;
|
|
2432
|
+
pedal?: readonly (readonly [number, PedalState])[];
|
|
2433
|
+
velocityCurve?: Readonly<{
|
|
2434
|
+
curve: Exclude<VelocityCurveName, "linear">;
|
|
2435
|
+
fixed?: number;
|
|
2436
|
+
}>;
|
|
2437
|
+
humanize?: Readonly<{
|
|
2438
|
+
timing?: number;
|
|
2439
|
+
velocity?: number;
|
|
2440
|
+
length?: number;
|
|
2441
|
+
seed: number;
|
|
2442
|
+
}>;
|
|
2443
|
+
tuning: ScoreTuning | null;
|
|
2444
|
+
/** Modelled piano settings (SDK 1.24.0); present only when set. */
|
|
2445
|
+
keys?: KeysInput;
|
|
2446
|
+
/** Modal settings (SDK 1.25.0); present only on a modal track. */
|
|
2447
|
+
modal?: Readonly<{ preset?: ModalPresetName } & ModalParams>;
|
|
1564
2448
|
}>;
|
|
1565
2449
|
|
|
2450
|
+
export type GlideMode = "legato" | "mono" | "poly";
|
|
2451
|
+
export type PedalState = "down" | "half" | "up";
|
|
2452
|
+
export type VelocityCurveName = "linear" | "soft" | "hard" | "fixed";
|
|
2453
|
+
|
|
2454
|
+
const DEFAULT_GLIDE_SECONDS = 0.06;
|
|
2455
|
+
const DEFAULT_FIXED_VELOCITY = 0.8;
|
|
2456
|
+
|
|
2457
|
+
/** Normalizes `track()` performance options; dawg validates the ranges. */
|
|
2458
|
+
function trackPerformance(
|
|
2459
|
+
input: TrackInput,
|
|
2460
|
+
name: string,
|
|
2461
|
+
): Partial<Pick<TrackSpec, "glide" | "pedal" | "velocityCurve" | "humanize">> {
|
|
2462
|
+
const out: {
|
|
2463
|
+
glide?: TrackSpec["glide"];
|
|
2464
|
+
pedal?: TrackSpec["pedal"];
|
|
2465
|
+
velocityCurve?: TrackSpec["velocityCurve"];
|
|
2466
|
+
humanize?: TrackSpec["humanize"];
|
|
2467
|
+
} = {};
|
|
2468
|
+
if (input.glide !== undefined) {
|
|
2469
|
+
const raw =
|
|
2470
|
+
typeof input.glide === "number" ? { time: input.glide } : input.glide;
|
|
2471
|
+
if (!isRecord(raw))
|
|
2472
|
+
throw new DawgSdkError(
|
|
2473
|
+
`track ${name}: glide must be seconds or { time, mode }`,
|
|
2474
|
+
);
|
|
2475
|
+
const mode = raw.mode ?? "legato";
|
|
2476
|
+
if (!["legato", "mono", "poly"].includes(mode))
|
|
2477
|
+
throw new DawgSdkError(
|
|
2478
|
+
`track ${name}: glide mode must be legato, mono or poly`,
|
|
2479
|
+
);
|
|
2480
|
+
out.glide = Object.freeze({
|
|
2481
|
+
time: finite(raw.time ?? DEFAULT_GLIDE_SECONDS, `${name} glide time`),
|
|
2482
|
+
mode,
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
if (input.pedal !== undefined) {
|
|
2486
|
+
if (!Array.isArray(input.pedal) || input.pedal.length > 1024)
|
|
2487
|
+
throw new DawgSdkError(
|
|
2488
|
+
`track ${name}: pedal must be at most 1024 [beat, "down" | "half" | "up"] events`,
|
|
2489
|
+
);
|
|
2490
|
+
if (input.pedal.length > 0)
|
|
2491
|
+
out.pedal = Object.freeze(
|
|
2492
|
+
input.pedal.map((event: unknown, index: number) => {
|
|
2493
|
+
if (
|
|
2494
|
+
!Array.isArray(event) ||
|
|
2495
|
+
event.length !== 2 ||
|
|
2496
|
+
!["down", "half", "up"].includes(event[1] as string)
|
|
2497
|
+
)
|
|
2498
|
+
throw new DawgSdkError(
|
|
2499
|
+
`track ${name}: pedal[${index}] must be [beat, "down" | "half" | "up"]`,
|
|
2500
|
+
);
|
|
2501
|
+
return Object.freeze([
|
|
2502
|
+
beat(event[0], `${name} pedal[${index}] beat`),
|
|
2503
|
+
event[1] as PedalState,
|
|
2504
|
+
] as const);
|
|
2505
|
+
}),
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
if (input.velocityCurve !== undefined) {
|
|
2509
|
+
const raw =
|
|
2510
|
+
typeof input.velocityCurve === "string"
|
|
2511
|
+
? { curve: input.velocityCurve }
|
|
2512
|
+
: input.velocityCurve;
|
|
2513
|
+
if (
|
|
2514
|
+
!isRecord(raw) ||
|
|
2515
|
+
!["linear", "soft", "hard", "fixed"].includes(raw.curve as string)
|
|
2516
|
+
)
|
|
2517
|
+
throw new DawgSdkError(
|
|
2518
|
+
`track ${name}: velocityCurve must be linear, soft, hard or fixed`,
|
|
2519
|
+
);
|
|
2520
|
+
if (raw.curve === "fixed")
|
|
2521
|
+
out.velocityCurve = Object.freeze({
|
|
2522
|
+
curve: "fixed",
|
|
2523
|
+
fixed: unit(
|
|
2524
|
+
raw.fixed ?? DEFAULT_FIXED_VELOCITY,
|
|
2525
|
+
`${name} velocityCurve fixed`,
|
|
2526
|
+
),
|
|
2527
|
+
});
|
|
2528
|
+
else if (raw.curve !== "linear")
|
|
2529
|
+
out.velocityCurve = Object.freeze({ curve: raw.curve });
|
|
2530
|
+
}
|
|
2531
|
+
if (input.humanize !== undefined) {
|
|
2532
|
+
if (!isRecord(input.humanize))
|
|
2533
|
+
throw new DawgSdkError(
|
|
2534
|
+
`track ${name}: humanize must be { timing, velocity, length, seed }`,
|
|
2535
|
+
);
|
|
2536
|
+
const amount = (key: "timing" | "velocity" | "length") => {
|
|
2537
|
+
const value = input.humanize![key];
|
|
2538
|
+
return value === undefined ? 0 : finite(value, `${name} humanize ${key}`);
|
|
2539
|
+
};
|
|
2540
|
+
const timing = amount("timing");
|
|
2541
|
+
const velocity = amount("velocity");
|
|
2542
|
+
const length = amount("length");
|
|
2543
|
+
const seed = input.humanize.seed ?? 1;
|
|
2544
|
+
if (!Number.isInteger(seed) || seed < 0)
|
|
2545
|
+
throw new DawgSdkError(
|
|
2546
|
+
`track ${name}: humanize seed must be an integer ≥ 0`,
|
|
2547
|
+
);
|
|
2548
|
+
if (timing !== 0 || velocity !== 0 || length !== 0)
|
|
2549
|
+
out.humanize = Object.freeze({
|
|
2550
|
+
...(timing !== 0 ? { timing } : {}),
|
|
2551
|
+
...(velocity !== 0 ? { velocity } : {}),
|
|
2552
|
+
...(length !== 0 ? { length } : {}),
|
|
2553
|
+
seed,
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
return out;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
1566
2559
|
/**
|
|
1567
2560
|
* A raw ZzFX parameter array (Strudel `zzfx([...])`, ZzFX's own layout:
|
|
1568
2561
|
* volume, randomness, frequency, attack, sustain, release, shape,
|
|
@@ -1653,34 +2646,77 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
1653
2646
|
if (typeof id !== "string" || id.length === 0 || id.length > 64)
|
|
1654
2647
|
throw new DawgSdkError(`track ${name}: id must be 1..64 characters`);
|
|
1655
2648
|
const rawInstrument = input.instrument ?? "sine";
|
|
2649
|
+
// A granular track may keep the sampler it grains (`grain off` goes back).
|
|
2650
|
+
const keptSampler =
|
|
2651
|
+
isRecord(input.sampler) &&
|
|
2652
|
+
input.sampler.kind === "sampler" &&
|
|
2653
|
+
isRecord(rawInstrument) &&
|
|
2654
|
+
rawInstrument.kind === "granular"
|
|
2655
|
+
? localizeSampler(input.sampler as SamplerSpec, slug)
|
|
2656
|
+
: null;
|
|
2657
|
+
if (input.sampler !== undefined && input.sampler !== null && !keptSampler)
|
|
2658
|
+
throw new DawgSdkError(
|
|
2659
|
+
`track ${name}: sampler: is only for a granular(...) track; use instrument: sampler({...})`,
|
|
2660
|
+
);
|
|
1656
2661
|
const samplerSpec =
|
|
1657
2662
|
isRecord(rawInstrument) && rawInstrument.kind === "sampler"
|
|
1658
2663
|
? localizeSampler(rawInstrument as SamplerSpec, slug)
|
|
1659
|
-
:
|
|
2664
|
+
: keptSampler;
|
|
1660
2665
|
const wavetableSpec =
|
|
1661
2666
|
isRecord(rawInstrument) && rawInstrument.kind === "wavetable"
|
|
1662
2667
|
? localizeWavetable(rawInstrument as WavetableSpec, slug)
|
|
1663
2668
|
: null;
|
|
1664
|
-
const
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
2669
|
+
const stringFromInstrument =
|
|
2670
|
+
isRecord(rawInstrument) && rawInstrument.kind === "string"
|
|
2671
|
+
? stringInput(rawInstrument, name)
|
|
2672
|
+
: null;
|
|
2673
|
+
const granularFromInstrument =
|
|
2674
|
+
isRecord(rawInstrument) && rawInstrument.kind === "granular"
|
|
2675
|
+
? granularInput(rawInstrument, name, slug)
|
|
2676
|
+
: null;
|
|
2677
|
+
const word =
|
|
2678
|
+
typeof rawInstrument === "string"
|
|
2679
|
+
? resolveInstrumentWord(rawInstrument)
|
|
2680
|
+
: undefined;
|
|
2681
|
+
const modalSpec = trackModal(rawInstrument);
|
|
2682
|
+
const instrument = granularFromInstrument
|
|
2683
|
+
? GRANULAR_INSTRUMENT
|
|
2684
|
+
: samplerSpec
|
|
2685
|
+
? SAMPLER_INSTRUMENT
|
|
2686
|
+
: wavetableSpec
|
|
2687
|
+
? WAVETABLE_INSTRUMENT
|
|
2688
|
+
: stringFromInstrument
|
|
2689
|
+
? STRING_INSTRUMENT
|
|
2690
|
+
: modalSpec
|
|
2691
|
+
? MODAL_INSTRUMENT
|
|
2692
|
+
: typeof rawInstrument === "string"
|
|
2693
|
+
? (word?.instrument ?? rawInstrument)
|
|
2694
|
+
: undefined;
|
|
2695
|
+
// A granular word (`"cloud"`) turns the engine on with its preset.
|
|
2696
|
+
const granularSpec =
|
|
2697
|
+
granularInput(input.granular, name, slug) ??
|
|
2698
|
+
granularFromInstrument ??
|
|
2699
|
+
(word?.field === "granular" && word.preset
|
|
2700
|
+
? Object.freeze({ preset: word.preset })
|
|
2701
|
+
: instrument === GRANULAR_INSTRUMENT
|
|
2702
|
+
? Object.freeze({})
|
|
2703
|
+
: null);
|
|
1671
2704
|
if (
|
|
1672
2705
|
instrument === undefined ||
|
|
1673
2706
|
instrument.length === 0 ||
|
|
1674
2707
|
instrument.length > 64
|
|
1675
2708
|
)
|
|
1676
2709
|
throw new DawgSdkError(
|
|
1677
|
-
`track ${name}: instrument must be a voice name, "kit", sampler(...) or
|
|
2710
|
+
`track ${name}: instrument must be a voice name, "kit", sampler(...), wavetable(...), stringed(...), granular(...) or modal(...)`,
|
|
1678
2711
|
);
|
|
1679
2712
|
if (instrument === SAMPLER_INSTRUMENT && !samplerSpec)
|
|
1680
2713
|
throw new DawgSdkError(
|
|
1681
2714
|
`track ${name}: use instrument: sampler({...}) for a sampler track`,
|
|
1682
2715
|
);
|
|
1683
|
-
const slots =
|
|
2716
|
+
const slots =
|
|
2717
|
+
samplerSpec && instrument === SAMPLER_INSTRUMENT
|
|
2718
|
+
? voiceSlots(samplerSpec)
|
|
2719
|
+
: undefined;
|
|
1684
2720
|
const kit = KIT_INSTRUMENTS.includes(instrument.trim().toLowerCase());
|
|
1685
2721
|
const notes = (input.notes ?? []).map((item, index) => {
|
|
1686
2722
|
if (!isRecord(item) || (item.kind !== "note" && item.kind !== "hit"))
|
|
@@ -1703,13 +2739,8 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
1703
2739
|
? `track ${name}: unknown sampler voice "${spec.voice}" (${[...slots.keys()].join(" ")})`
|
|
1704
2740
|
: `track ${name}: hit("${spec.voice}") needs instrument "kit" or sampler(...)`,
|
|
1705
2741
|
);
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
pitch,
|
|
1709
|
-
start: spec.start,
|
|
1710
|
-
length: spec.length,
|
|
1711
|
-
velocity: spec.velocity,
|
|
1712
|
-
});
|
|
2742
|
+
const { kind: _kind, voice: _voice, ...rest } = spec;
|
|
2743
|
+
return Object.freeze({ ...rest, kind: "note" as const, pitch });
|
|
1713
2744
|
});
|
|
1714
2745
|
if (notes.length > 4096)
|
|
1715
2746
|
throw new DawgSdkError(`track ${name}: at most 4096 notes`);
|
|
@@ -1760,8 +2791,25 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
1760
2791
|
throw new DawgSdkError(
|
|
1761
2792
|
`track ${name}: unknown automation lane "${key}" (${AUTOMATION_KEYS.join(" ")})`,
|
|
1762
2793
|
);
|
|
1763
|
-
|
|
1764
|
-
|
|
2794
|
+
// A keys preset word (`"lofi"`, `"ballad"`) brings its preset's effects,
|
|
2795
|
+
// as the prompt does; explicit filter, fx and reverb win.
|
|
2796
|
+
const presetFx = keysPresetFx(input.keys, rawInstrument);
|
|
2797
|
+
if (presetFx) {
|
|
2798
|
+
input = {
|
|
2799
|
+
...input,
|
|
2800
|
+
...(input.filter === undefined && presetFx.filter
|
|
2801
|
+
? { filter: presetFx.filter }
|
|
2802
|
+
: {}),
|
|
2803
|
+
...(input.reverb === undefined && presetFx.reverb
|
|
2804
|
+
? { reverb: presetFx.reverb }
|
|
2805
|
+
: {}),
|
|
2806
|
+
...(presetFx.fx && input.fx !== null
|
|
2807
|
+
? { fx: { ...presetFx.fx, ...(isRecord(input.fx) ? input.fx : {}) } }
|
|
2808
|
+
: {}),
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
const filter =
|
|
2812
|
+
input.filter === undefined || input.filter === null
|
|
1765
2813
|
? null
|
|
1766
2814
|
: Object.freeze({
|
|
1767
2815
|
cutoff: finite(input.filter.cutoff, `${name} filter.cutoff`),
|
|
@@ -1802,8 +2850,26 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
1802
2850
|
? {}
|
|
1803
2851
|
: { ir: reverbIr(input.reverb.ir, name, slug) }),
|
|
1804
2852
|
});
|
|
1805
|
-
|
|
2853
|
+
// A guitar alias (`instrument: "jangle"`) also loads its rig; stages and
|
|
2854
|
+
// effects the track's own `fx` names win.
|
|
2855
|
+
const aliasRig =
|
|
2856
|
+
typeof rawInstrument === "string"
|
|
2857
|
+
? resolveInstrumentWord(rawInstrument)?.fx
|
|
2858
|
+
: undefined;
|
|
2859
|
+
const fx = fxInput(
|
|
2860
|
+
aliasRig === undefined
|
|
2861
|
+
? input.fx
|
|
2862
|
+
: { ...rig(aliasRig), ...(isRecord(input.fx) ? input.fx : {}) },
|
|
2863
|
+
name,
|
|
2864
|
+
);
|
|
1806
2865
|
const synth = synthInput(input.synth, name);
|
|
2866
|
+
// A string preset word (`"nylon"`) turns the engine on with its preset.
|
|
2867
|
+
const string =
|
|
2868
|
+
stringInput(input.string, name) ??
|
|
2869
|
+
stringFromInstrument ??
|
|
2870
|
+
(word?.field === "string" && word.preset
|
|
2871
|
+
? Object.freeze({ preset: word.preset })
|
|
2872
|
+
: null);
|
|
1807
2873
|
return Object.freeze({
|
|
1808
2874
|
kind: "track",
|
|
1809
2875
|
id,
|
|
@@ -1821,6 +2887,8 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
1821
2887
|
synth,
|
|
1822
2888
|
sampler: samplerSpec,
|
|
1823
2889
|
wavetable: wavetableSpec,
|
|
2890
|
+
...(string ? { string } : {}),
|
|
2891
|
+
...(granularSpec ? { granular: granularSpec } : {}),
|
|
1824
2892
|
automation: Object.freeze({
|
|
1825
2893
|
volume: lane("volume"),
|
|
1826
2894
|
pan: lane("pan"),
|
|
@@ -1834,9 +2902,165 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
1834
2902
|
notes: Object.freeze(notes),
|
|
1835
2903
|
rhythm: Object.freeze([...rhythm]),
|
|
1836
2904
|
kit: drumKit === null ? null : drumKit.trim(),
|
|
2905
|
+
...trackTime(input.time, name),
|
|
2906
|
+
...trackPerformance(input, name),
|
|
2907
|
+
tuning: tuningSpec(input.tuning, `track ${name}`),
|
|
2908
|
+
...keysSpec(input.keys, rawInstrument, name),
|
|
2909
|
+
...(modalSpec ? { modal: modalSpec } : {}),
|
|
1837
2910
|
});
|
|
1838
2911
|
}
|
|
1839
2912
|
|
|
2913
|
+
/**
|
|
2914
|
+
* Effects the keys presets set with the voice: a copy of `KEYS_PRESETS`
|
|
2915
|
+
* filter, fx and reverb in core/keys.ts (core/keys.test.ts checks they
|
|
2916
|
+
* match the prompt's `piano <preset>`).
|
|
2917
|
+
*/
|
|
2918
|
+
const KEYS_PRESET_FX: Readonly<
|
|
2919
|
+
Record<
|
|
2920
|
+
string,
|
|
2921
|
+
Readonly<{
|
|
2922
|
+
filter?: FilterInput;
|
|
2923
|
+
fx?: FxInput;
|
|
2924
|
+
reverb?: ReverbInput;
|
|
2925
|
+
}>
|
|
2926
|
+
>
|
|
2927
|
+
> = Object.freeze({
|
|
2928
|
+
ballad: { reverb: { mix: 0.25, size: 0.7 } },
|
|
2929
|
+
felt: { reverb: { mix: 0.2, size: 0.5 } },
|
|
2930
|
+
lofi: {
|
|
2931
|
+
filter: { cutoff: 3500, resonance: 0.1 },
|
|
2932
|
+
fx: { crush: { bits: 10 } },
|
|
2933
|
+
},
|
|
2934
|
+
});
|
|
2935
|
+
|
|
2936
|
+
/**
|
|
2937
|
+
* The preset effects an instrument word brings: a preset word that is not
|
|
2938
|
+
* also its family (`"lofi"`, `"ballad"`), or any preset word without
|
|
2939
|
+
* `keys` (`"felt"`). A printed track names its family and always prints
|
|
2940
|
+
* `keys`, so print → eval never adds them twice.
|
|
2941
|
+
*/
|
|
2942
|
+
function keysPresetFx(
|
|
2943
|
+
keys: unknown,
|
|
2944
|
+
word: unknown,
|
|
2945
|
+
): (typeof KEYS_PRESET_FX)[string] | undefined {
|
|
2946
|
+
if (typeof word !== "string") return undefined;
|
|
2947
|
+
const meaning = resolveInstrumentWord(word);
|
|
2948
|
+
if (meaning?.field !== "keys" || !meaning.preset) return undefined;
|
|
2949
|
+
if (keys !== undefined && keys !== null && word === meaning.instrument)
|
|
2950
|
+
return undefined;
|
|
2951
|
+
return KEYS_PRESET_FX[meaning.preset];
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
/**
|
|
2955
|
+
* `keys` for `track()`: the input as given, or `{ preset }` when the
|
|
2956
|
+
* instrument word names a keys preset (`"grand"`, `"lofi"`), so the word
|
|
2957
|
+
* alone plays the modelled piano. dawg validates the values.
|
|
2958
|
+
*/
|
|
2959
|
+
function keysSpec(
|
|
2960
|
+
input: unknown,
|
|
2961
|
+
word: unknown,
|
|
2962
|
+
name: string,
|
|
2963
|
+
): { keys?: KeysInput } {
|
|
2964
|
+
const meaning =
|
|
2965
|
+
typeof word === "string" ? resolveInstrumentWord(word) : undefined;
|
|
2966
|
+
const preset = meaning?.field === "keys" ? meaning.preset : undefined;
|
|
2967
|
+
if (input === undefined || input === null)
|
|
2968
|
+
return preset ? { keys: Object.freeze({ preset }) } : {};
|
|
2969
|
+
if (!isRecord(input))
|
|
2970
|
+
throw new DawgSdkError(`track ${name}: keys must be an object`);
|
|
2971
|
+
const out: Record<string, EffectValue> = {};
|
|
2972
|
+
// A preset word (`"lofi"`) keeps its preset under given overrides; a
|
|
2973
|
+
// family word (`"upright"`) with `keys` is exactly the given keys.
|
|
2974
|
+
if (preset && input.preset === undefined && word !== meaning?.instrument)
|
|
2975
|
+
out.preset = preset;
|
|
2976
|
+
for (const [key, value] of Object.entries(input)) {
|
|
2977
|
+
if (value === undefined) continue;
|
|
2978
|
+
out[key] = effectValue(value, `${name} keys.${key}`);
|
|
2979
|
+
}
|
|
2980
|
+
return { keys: Object.freeze(out) };
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
/**
|
|
2984
|
+
* The modal field an instrument makes: `modal(...)`, or a modal preset
|
|
2985
|
+
* word (`"vibes"`, `"glockenspiel"`). The bare words `"modal"` and
|
|
2986
|
+
* `"marimba"` keep their pre-0.6 meaning and make none.
|
|
2987
|
+
*/
|
|
2988
|
+
function trackModal(
|
|
2989
|
+
raw: unknown,
|
|
2990
|
+
): Readonly<{ preset?: ModalPresetName } & ModalParams> | undefined {
|
|
2991
|
+
if (isRecord(raw) && raw.kind === "modal") {
|
|
2992
|
+
const { kind: _kind, ...fields } = raw as ModalSpec;
|
|
2993
|
+
return Object.freeze(fields);
|
|
2994
|
+
}
|
|
2995
|
+
if (typeof raw !== "string" || raw === MODAL_INSTRUMENT) return undefined;
|
|
2996
|
+
const meaning = resolveInstrumentWord(raw);
|
|
2997
|
+
if (meaning?.instrument !== MODAL_INSTRUMENT || !meaning.preset)
|
|
2998
|
+
return undefined;
|
|
2999
|
+
return Object.freeze({ preset: meaning.preset as ModalPresetName });
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
function trackTime(input: unknown, name: string): { time?: TrackTimeInput } {
|
|
3003
|
+
if (input === undefined || input === null) return {};
|
|
3004
|
+
if (!isRecord(input))
|
|
3005
|
+
throw new DawgSdkError(`track ${name}: time must be an object`);
|
|
3006
|
+
for (const key of Object.keys(input))
|
|
3007
|
+
if (key !== "rate" && key !== "phase" && key !== "cycle" && key !== "steps")
|
|
3008
|
+
throw new DawgSdkError(
|
|
3009
|
+
`track ${name}: time takes rate, phase, cycle and steps, not ${key}`,
|
|
3010
|
+
);
|
|
3011
|
+
const out: {
|
|
3012
|
+
rate?: number;
|
|
3013
|
+
phase?: number;
|
|
3014
|
+
cycle?: number;
|
|
3015
|
+
steps?: { shift: number; hold: number; drift: number };
|
|
3016
|
+
} = {};
|
|
3017
|
+
if (input.rate !== undefined) {
|
|
3018
|
+
const rate = finite(input.rate, `track ${name} time.rate`);
|
|
3019
|
+
if (rate < 0.125 || rate > 8)
|
|
3020
|
+
throw new DawgSdkError(`track ${name}: time.rate must be 0.125..8`);
|
|
3021
|
+
if (rate !== 1) out.rate = rate;
|
|
3022
|
+
}
|
|
3023
|
+
if (input.phase !== undefined) {
|
|
3024
|
+
const phase = finite(input.phase, `track ${name} time.phase`);
|
|
3025
|
+
if (phase !== 0) out.phase = phase;
|
|
3026
|
+
}
|
|
3027
|
+
if (input.cycle !== undefined) {
|
|
3028
|
+
const cycle = finite(input.cycle, `track ${name} time.cycle`);
|
|
3029
|
+
if (cycle <= 0)
|
|
3030
|
+
throw new DawgSdkError(`track ${name}: time.cycle must be > 0 beats`);
|
|
3031
|
+
out.cycle = cycle;
|
|
3032
|
+
}
|
|
3033
|
+
if (input.steps !== undefined) {
|
|
3034
|
+
if (!isRecord(input.steps))
|
|
3035
|
+
throw new DawgSdkError(`track ${name}: time.steps must be an object`);
|
|
3036
|
+
if (out.cycle === undefined)
|
|
3037
|
+
throw new DawgSdkError(`track ${name}: time.steps needs a cycle`);
|
|
3038
|
+
if (out.rate !== undefined)
|
|
3039
|
+
throw new DawgSdkError(
|
|
3040
|
+
`track ${name}: time takes rate or steps, not both`,
|
|
3041
|
+
);
|
|
3042
|
+
out.steps = phaseSteps(input.steps, out.cycle, `track ${name} time.steps`);
|
|
3043
|
+
}
|
|
3044
|
+
return Object.keys(out).length > 0 ? { time: Object.freeze(out) } : {};
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
function phaseSteps(
|
|
3048
|
+
input: Record<string, unknown>,
|
|
3049
|
+
cycle: number,
|
|
3050
|
+
label: string,
|
|
3051
|
+
): Readonly<{ shift: number; hold: number; drift: number }> {
|
|
3052
|
+
const shift = positive(input.shift, `${label}.shift`);
|
|
3053
|
+
if (shift > cycle)
|
|
3054
|
+
throw new DawgSdkError(`${label}.shift must be at most the cycle`);
|
|
3055
|
+
const hold = finite(input.hold, `${label}.hold`);
|
|
3056
|
+
if (!Number.isInteger(hold) || hold < 0 || hold > 64)
|
|
3057
|
+
throw new DawgSdkError(`${label}.hold must be a whole 0..64 cycles`);
|
|
3058
|
+
const drift = finite(input.drift, `${label}.drift`);
|
|
3059
|
+
if (!Number.isInteger(drift) || drift < 1 || drift > 64)
|
|
3060
|
+
throw new DawgSdkError(`${label}.drift must be a whole 1..64 cycles`);
|
|
3061
|
+
return Object.freeze({ shift, hold, drift });
|
|
3062
|
+
}
|
|
3063
|
+
|
|
1840
3064
|
const AUTOMATION_KEYS: readonly (keyof AutomationInput)[] = Object.freeze([
|
|
1841
3065
|
"volume",
|
|
1842
3066
|
"pan",
|
|
@@ -1885,6 +3109,21 @@ function fxInput(input: unknown, name: string): FxInput | null {
|
|
|
1885
3109
|
return Object.keys(out).length > 0 ? Object.freeze(out) : null;
|
|
1886
3110
|
}
|
|
1887
3111
|
|
|
3112
|
+
function stringInput(input: unknown, name: string): StringInput | null {
|
|
3113
|
+
if (input === undefined || input === null) return null;
|
|
3114
|
+
if (!isRecord(input))
|
|
3115
|
+
throw new DawgSdkError(`track ${name}: string must be an object`);
|
|
3116
|
+
const out: Record<string, number | string> = {};
|
|
3117
|
+
for (const [key, value] of Object.entries(input)) {
|
|
3118
|
+
if (value === undefined || key === "kind") continue;
|
|
3119
|
+
out[key] =
|
|
3120
|
+
typeof value === "string"
|
|
3121
|
+
? value
|
|
3122
|
+
: finite(value, `${name} string.${key}`);
|
|
3123
|
+
}
|
|
3124
|
+
return Object.freeze(out);
|
|
3125
|
+
}
|
|
3126
|
+
|
|
1888
3127
|
function synthInput(input: unknown, name: string): SynthInput | null {
|
|
1889
3128
|
if (input === undefined || input === null) return null;
|
|
1890
3129
|
if (!isRecord(input))
|
|
@@ -1970,7 +3209,7 @@ function reverbIr(
|
|
|
1970
3209
|
throw new DawgSdkError(`track ${name}: reverb.ir needs a src`);
|
|
1971
3210
|
const src = spec.src.trim().replace(/^\.\//, "");
|
|
1972
3211
|
if (src.startsWith("pack:")) {
|
|
1973
|
-
const ref =
|
|
3212
|
+
const ref = sampleSpec(spec as SampleSpec, `${name} reverb.ir`);
|
|
1974
3213
|
return Object.freeze({
|
|
1975
3214
|
src: ref.src,
|
|
1976
3215
|
...(ref.sha256 ? { sha256: ref.sha256 } : {}),
|
|
@@ -1982,6 +3221,41 @@ function reverbIr(
|
|
|
1982
3221
|
return src.startsWith("tracks/") ? src : `tracks/${slug}/${src}`;
|
|
1983
3222
|
}
|
|
1984
3223
|
|
|
3224
|
+
/** Validates `granular` input and localizes a sample source like a voice. */
|
|
3225
|
+
function granularInput(
|
|
3226
|
+
input: unknown,
|
|
3227
|
+
name: string,
|
|
3228
|
+
slug: string,
|
|
3229
|
+
): GranularInput | null {
|
|
3230
|
+
if (input === undefined || input === null) return null;
|
|
3231
|
+
if (!isRecord(input))
|
|
3232
|
+
throw new DawgSdkError(`track ${name}: granular must be an object`);
|
|
3233
|
+
const out: Record<string, unknown> = {};
|
|
3234
|
+
for (const [key, value] of Object.entries(input)) {
|
|
3235
|
+
if (value === undefined || key === "kind") continue;
|
|
3236
|
+
if (key === "src" && isRecord(value)) {
|
|
3237
|
+
const ref = sampleSpec(value as SampleSpec, `${name} granular src`);
|
|
3238
|
+
const src = ref.src.replace(/^\.\//, "");
|
|
3239
|
+
out.src = Object.freeze({
|
|
3240
|
+
...ref,
|
|
3241
|
+
src:
|
|
3242
|
+
src.startsWith("tracks/") || src.startsWith("pack:")
|
|
3243
|
+
? src
|
|
3244
|
+
: `tracks/${slug}/${src}`,
|
|
3245
|
+
});
|
|
3246
|
+
} else if (key === "src" && typeof value === "string")
|
|
3247
|
+
out.src = value.startsWith("synth:")
|
|
3248
|
+
? value
|
|
3249
|
+
: granularInput({ src: { src: value } }, name, slug)!.src;
|
|
3250
|
+
else if (typeof value === "number")
|
|
3251
|
+
out[key] = finite(value, `${name} granular.${key}`);
|
|
3252
|
+
else if (typeof value === "string" || typeof value === "boolean")
|
|
3253
|
+
out[key] = value;
|
|
3254
|
+
else throw new DawgSdkError(`${name} granular.${key} must be a value`);
|
|
3255
|
+
}
|
|
3256
|
+
return Object.freeze(out) as GranularInput;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
1985
3259
|
/** `./wavetables/x.wav` → `tracks/<slug>/wavetables/x.wav`, like sampler files. */
|
|
1986
3260
|
function localizeWavetable(spec: WavetableSpec, slug: string): WavetableSpec {
|
|
1987
3261
|
const src = spec.table.src;
|
|
@@ -2018,19 +3292,103 @@ function localizeSampler(spec: SamplerSpec, slug: string): SamplerSpec {
|
|
|
2018
3292
|
export type SongInput = Readonly<{
|
|
2019
3293
|
/** BPM 20..300, default 120. */
|
|
2020
3294
|
tempo?: number;
|
|
2021
|
-
/**
|
|
3295
|
+
/**
|
|
3296
|
+
* `[beatsPerBar, noteValue]` or just `beatsPerBar`; default `[4, 4]`.
|
|
3297
|
+
* A note value other than 4 is stored as a meter change at bar 1, so
|
|
3298
|
+
* `[6, 8]` is six eighths (three quarter-note beats) per bar.
|
|
3299
|
+
*/
|
|
2022
3300
|
meter?: readonly [number, number] | number;
|
|
2023
3301
|
/** Loop length in bars, 1..256, default 4. */
|
|
2024
3302
|
bars?: number;
|
|
2025
|
-
/**
|
|
3303
|
+
/**
|
|
3304
|
+
* Free text such as `"A minor"`, or null. A scale name after the tonic
|
|
3305
|
+
* picks a scale: `"D dorian"`, `"E hijaz"`, `"C yaman"`, `"C messiaen-3"`.
|
|
3306
|
+
*/
|
|
2026
3307
|
key?: string | null;
|
|
3308
|
+
/**
|
|
3309
|
+
* Song tuning (SDK 1.16.0), default 12-TET at A4 = 440 Hz: a library name
|
|
3310
|
+
* (`"19-edo"`, `"just"`, `"pelog"`, `"yaman"`) or `{ edo, ratios, cents,
|
|
3311
|
+
* scl, kbm, ref, root, map }`. See `TuningInput`.
|
|
3312
|
+
*/
|
|
3313
|
+
tuning?: TuningInput | null;
|
|
2027
3314
|
/** Integer ticks per beat, default 480. Leave it alone unless you know why. */
|
|
2028
3315
|
ticksPerBeat?: number;
|
|
3316
|
+
/**
|
|
3317
|
+
* Tempo changes, meter changes and fermatas (SDK 1.14.0), in any order:
|
|
3318
|
+
* `tempo()`, `ramp()`, `rit()`, `accel()`, `fermata()` and `meter()`.
|
|
3319
|
+
* `tempo` above stays the opening tempo and `meter` the opening meter.
|
|
3320
|
+
* `rit()` and `accel()` return two marks; list them as they come.
|
|
3321
|
+
*/
|
|
3322
|
+
time?: readonly (TimeMark | readonly TimeMark[])[];
|
|
2029
3323
|
/** Tracks in score order; each from `track()`. */
|
|
2030
3324
|
tracks: readonly TrackSpec[];
|
|
3325
|
+
/** Master chain and loudness target after every track and orbit bus (SDK 1.17.0); omit for none. */
|
|
3326
|
+
master?: MasterInput;
|
|
3327
|
+
/**
|
|
3328
|
+
* Named bar ranges (SDK 1.18.0): `{ name: "chorus", startBar: 8, bars: 8 }`,
|
|
3329
|
+
* optionally with `mute: ["pad"]` and `vary: { lead: { transpose: 12 } }`.
|
|
3330
|
+
*/
|
|
3331
|
+
sections?: readonly SongSection[];
|
|
3332
|
+
/**
|
|
3333
|
+
* The order sections play, with repeats (SDK 1.18.0): `"intro verse
|
|
3334
|
+
* chorus*2 outro"`, or `["intro", { section: "chorus", repeat: 2 }]`.
|
|
3335
|
+
* Absent plays the bars straight through.
|
|
3336
|
+
*/
|
|
3337
|
+
form?: string | readonly (string | SongFormEntry)[];
|
|
3338
|
+
/** The section playback loops (SDK 1.18.0); export ignores it. */
|
|
3339
|
+
loopSection?: string;
|
|
3340
|
+
}>;
|
|
3341
|
+
|
|
3342
|
+
/** A song section (SDK 1.18.0); bars are 0-based like beats. */
|
|
3343
|
+
export type SongSection = Readonly<{
|
|
3344
|
+
/** `intro`, `verse`, `chorus 2`, `A`: 1..32 characters, unique ignoring case. */
|
|
3345
|
+
name: string;
|
|
3346
|
+
/** First bar, 0-based. */
|
|
3347
|
+
startBar: number;
|
|
3348
|
+
/** Length in bars, at least 1. */
|
|
3349
|
+
bars: number;
|
|
3350
|
+
/** Track ids silent in this section. */
|
|
3351
|
+
mute?: readonly string[];
|
|
3352
|
+
/** Per-track changes in this section: semitones and a velocity multiplier. */
|
|
3353
|
+
vary?: Readonly<
|
|
3354
|
+
Record<string, Readonly<{ transpose?: number; gain?: number }>>
|
|
3355
|
+
>;
|
|
3356
|
+
}>;
|
|
3357
|
+
|
|
3358
|
+
/** One step of the song form (SDK 1.18.0). */
|
|
3359
|
+
export type SongFormEntry = Readonly<{ section: string; repeat?: number }>;
|
|
3360
|
+
|
|
3361
|
+
/**
|
|
3362
|
+
* The song master (SDK 1.17.0), processed in the fixed order
|
|
3363
|
+
* eq → glue → tape → width → limiter after the tracks and orbit buses are
|
|
3364
|
+
* summed. Each unit present is on; `{}` takes every default. `target` is
|
|
3365
|
+
* an integrated loudness in LUFS (ITU-R BS.1770-4), -40..-3: renders drive
|
|
3366
|
+
* the limiter (or, without one, a clean gain) to reach it. Streaming is
|
|
3367
|
+
* -14, club -8, loud hyperpop or gabber -6, classical -20, broadcast -23.
|
|
3368
|
+
* The SDK takes LUFS numbers only: a target name such as `master target
|
|
3369
|
+
* club` in the prompt also sets a limiter preset, so write that unit out.
|
|
3370
|
+
* DAWG.md "Master and loudness" lists every parameter with its range.
|
|
3371
|
+
*
|
|
3372
|
+
* ```ts
|
|
3373
|
+
* master: { glue: { ratio: 2 }, limiter: { ceiling: -1 }, target: -14 }
|
|
3374
|
+
* ```
|
|
3375
|
+
*/
|
|
3376
|
+
export type MasterInput = Readonly<{
|
|
3377
|
+
/** `low`/`high` shelves and `bell1`/`bell2` gains in dB, with `…freq` and `…q`. */
|
|
3378
|
+
eq?: EffectParams;
|
|
3379
|
+
/** Bus compressor: threshold, ratio, attack, release (ms), knee, makeup, mix, hpf. */
|
|
3380
|
+
glue?: EffectParams;
|
|
3381
|
+
/** Saturation: drive (dB), bias, tone (Hz), mix. */
|
|
3382
|
+
tape?: EffectParams;
|
|
3383
|
+
/** Stereo width 0..2 (1 unchanged) and `mono` bass below this many Hz. */
|
|
3384
|
+
width?: EffectParams;
|
|
3385
|
+
/** True-peak limiter: ceiling (dBTP), gain, release, lookahead (ms), truepeak. */
|
|
3386
|
+
limiter?: EffectParams;
|
|
3387
|
+
/** Integrated loudness target in LUFS (a negative number, -40..-3). */
|
|
3388
|
+
target?: number;
|
|
2031
3389
|
}>;
|
|
2032
3390
|
|
|
2033
|
-
/** A stored note: integer ticks. */
|
|
3391
|
+
/** A stored note: integer ticks; expression fields only when set. */
|
|
2034
3392
|
export type ScoreNote = Readonly<{
|
|
2035
3393
|
id: string;
|
|
2036
3394
|
trackId: string;
|
|
@@ -2038,6 +3396,13 @@ export type ScoreNote = Readonly<{
|
|
|
2038
3396
|
durationTicks: number;
|
|
2039
3397
|
pitch: number;
|
|
2040
3398
|
velocity: number;
|
|
3399
|
+
articulation?: Articulation;
|
|
3400
|
+
glide?: number;
|
|
3401
|
+
bend?: readonly Readonly<{ at: number; cents: number }>[];
|
|
3402
|
+
vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
|
|
3403
|
+
humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
|
|
3404
|
+
/** Static cents offset (SDK 1.16.0); absent is 0. */
|
|
3405
|
+
cents?: number;
|
|
2041
3406
|
}>;
|
|
2042
3407
|
|
|
2043
3408
|
/** A stored automation point: integer tick. */
|
|
@@ -2063,6 +3428,9 @@ export type ScoreSampleRef = Readonly<{
|
|
|
2063
3428
|
fit?: boolean;
|
|
2064
3429
|
accelerate?: number;
|
|
2065
3430
|
squiz?: number;
|
|
3431
|
+
bpm?: number;
|
|
3432
|
+
fitmode?: "repitch" | "beats" | "tones";
|
|
3433
|
+
len?: number;
|
|
2066
3434
|
}>;
|
|
2067
3435
|
|
|
2068
3436
|
/** A stored track; optional fields are present only when set. */
|
|
@@ -2086,6 +3454,7 @@ export type ScoreTrack = Readonly<{
|
|
|
2086
3454
|
fx?: FxInput;
|
|
2087
3455
|
fxAutomation?: Readonly<Record<string, readonly ScorePoint[]>>;
|
|
2088
3456
|
synth?: SynthInput;
|
|
3457
|
+
keys?: KeysInput;
|
|
2089
3458
|
sampler?: Readonly<{
|
|
2090
3459
|
voices: Readonly<Record<string, ScoreSampleRef>>;
|
|
2091
3460
|
mode: "oneshot" | "keyed";
|
|
@@ -2094,8 +3463,27 @@ export type ScoreTrack = Readonly<{
|
|
|
2094
3463
|
rhythm?: readonly Readonly<Record<string, unknown>>[];
|
|
2095
3464
|
/** Synth kit name; dawg validates it. */
|
|
2096
3465
|
kit?: string;
|
|
3466
|
+
/** `rate`, plus `phase` and `cycle` in ticks. */
|
|
3467
|
+
time?: Readonly<{
|
|
3468
|
+
rate?: number;
|
|
3469
|
+
phase?: number;
|
|
3470
|
+
cycle?: number;
|
|
3471
|
+
steps?: Readonly<{ shift: number; hold: number; drift: number }>;
|
|
3472
|
+
}>;
|
|
3473
|
+
/** Track tuning; dawg validates it (SDK 1.16.0). */
|
|
3474
|
+
tuning?: ScoreTuning;
|
|
2097
3475
|
wavetable?: Readonly<{ table: ScoreSampleRef } & WavetableParams>;
|
|
2098
3476
|
wtAutomation?: readonly ScorePoint[];
|
|
3477
|
+
/** String engine settings; dawg validates them (SDK 1.21.0). */
|
|
3478
|
+
string?: StringInput;
|
|
3479
|
+
/** Granular settings (SDK 1.23.0); present only when set. */
|
|
3480
|
+
granular?: GranularInput;
|
|
3481
|
+
/** Modal settings (SDK 1.25.0). */
|
|
3482
|
+
modal?: TrackSpec["modal"];
|
|
3483
|
+
glide?: TrackSpec["glide"];
|
|
3484
|
+
pedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
|
|
3485
|
+
velocityCurve?: TrackSpec["velocityCurve"];
|
|
3486
|
+
humanize?: TrackSpec["humanize"];
|
|
2099
3487
|
}>;
|
|
2100
3488
|
|
|
2101
3489
|
/**
|
|
@@ -2111,10 +3499,167 @@ export type Song = Readonly<{
|
|
|
2111
3499
|
bars: number;
|
|
2112
3500
|
ticksPerBeat: number;
|
|
2113
3501
|
key: string | null;
|
|
3502
|
+
/** Present only when `song({ time })` has marks. */
|
|
3503
|
+
time?: ScoreTime;
|
|
3504
|
+
/** Present only when the song sets one (SDK 1.16.0). */
|
|
3505
|
+
tuning?: ScoreTuning;
|
|
2114
3506
|
tracks: readonly ScoreTrack[];
|
|
2115
3507
|
notes: readonly ScoreNote[];
|
|
3508
|
+
master?: MasterInput;
|
|
3509
|
+
/** Present only when the song has sections (SDK 1.18.0). */
|
|
3510
|
+
sections?: readonly SongSection[];
|
|
3511
|
+
/** Present only when the song has a form (SDK 1.18.0). */
|
|
3512
|
+
form?: readonly SongFormEntry[];
|
|
3513
|
+
/** Present only when a section loops (SDK 1.18.0). */
|
|
3514
|
+
loopSection?: string;
|
|
2116
3515
|
}>;
|
|
2117
3516
|
|
|
3517
|
+
/** A stored song `time`: ticks, and 0-based bar indexes. */
|
|
3518
|
+
export type ScoreTime = Readonly<{
|
|
3519
|
+
tempo?: readonly Readonly<{
|
|
3520
|
+
tick: number;
|
|
3521
|
+
bpm: number;
|
|
3522
|
+
ramp?: "linear" | "exp";
|
|
3523
|
+
}>[];
|
|
3524
|
+
meter?: readonly Readonly<{
|
|
3525
|
+
bar: number;
|
|
3526
|
+
beatsPerBar: number;
|
|
3527
|
+
beatUnit?: number;
|
|
3528
|
+
}>[];
|
|
3529
|
+
fermatas?: readonly Readonly<{ tick: number; beats: number }>[];
|
|
3530
|
+
}>;
|
|
3531
|
+
|
|
3532
|
+
const MASTER_KEYS = ["eq", "glue", "tape", "width", "limiter", "target"];
|
|
3533
|
+
|
|
3534
|
+
/** Shape checks only; dawg validates every value when it loads the song. */
|
|
3535
|
+
function masterData(input: unknown): MasterInput | undefined {
|
|
3536
|
+
if (input === undefined || input === null) return undefined;
|
|
3537
|
+
if (!isRecord(input)) throw new DawgSdkError("song master must be an object");
|
|
3538
|
+
const out: Record<string, unknown> = {};
|
|
3539
|
+
for (const [key, value] of Object.entries(input)) {
|
|
3540
|
+
if (value === undefined) continue;
|
|
3541
|
+
if (!MASTER_KEYS.includes(key))
|
|
3542
|
+
throw new DawgSdkError(
|
|
3543
|
+
`song master has no "${key}"; use ${MASTER_KEYS.join(", ")}`,
|
|
3544
|
+
);
|
|
3545
|
+
if (key === "target") {
|
|
3546
|
+
if (typeof value === "string")
|
|
3547
|
+
throw new DawgSdkError(
|
|
3548
|
+
`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}"`,
|
|
3549
|
+
);
|
|
3550
|
+
out.target = finite(value as number, "song master target");
|
|
3551
|
+
continue;
|
|
3552
|
+
}
|
|
3553
|
+
if (!isRecord(value))
|
|
3554
|
+
throw new DawgSdkError(`song master ${key} must be an object`);
|
|
3555
|
+
out[key] = Object.freeze({ ...value });
|
|
3556
|
+
}
|
|
3557
|
+
return Object.keys(out).length > 0
|
|
3558
|
+
? (Object.freeze(out) as MasterInput)
|
|
3559
|
+
: undefined;
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
/** `"intro verse chorus*2"` (comma separated when a name has a space). */
|
|
3563
|
+
function parseSongForm(
|
|
3564
|
+
form: NonNullable<SongInput["form"]>,
|
|
3565
|
+
): readonly SongFormEntry[] {
|
|
3566
|
+
const items: (string | SongFormEntry)[] =
|
|
3567
|
+
typeof form === "string"
|
|
3568
|
+
? (form.includes(",") ? form.split(",") : form.trim().split(/\s+/u))
|
|
3569
|
+
.map((item) => item.trim())
|
|
3570
|
+
.filter((item) => item !== "")
|
|
3571
|
+
: Array.isArray(form)
|
|
3572
|
+
? [...form]
|
|
3573
|
+
: (() => {
|
|
3574
|
+
throw new DawgSdkError(
|
|
3575
|
+
"song form must be a string or an array of section names",
|
|
3576
|
+
);
|
|
3577
|
+
})();
|
|
3578
|
+
return Object.freeze(
|
|
3579
|
+
items.map((item, index) => {
|
|
3580
|
+
let entry: unknown = item;
|
|
3581
|
+
if (typeof item === "string") {
|
|
3582
|
+
const match = /^(.*?)\s*(?:\*|\bx|×)\s*(\d+)$/iu.exec(item);
|
|
3583
|
+
entry =
|
|
3584
|
+
match && match[1]!.length > 0
|
|
3585
|
+
? { section: match[1]!, repeat: Number(match[2]) }
|
|
3586
|
+
: { section: item };
|
|
3587
|
+
}
|
|
3588
|
+
if (!isRecord(entry) || typeof entry.section !== "string")
|
|
3589
|
+
throw new DawgSdkError(
|
|
3590
|
+
`song form[${index}] must be a section name or { section, repeat }`,
|
|
3591
|
+
);
|
|
3592
|
+
const repeat = entry.repeat ?? 1;
|
|
3593
|
+
if (
|
|
3594
|
+
!Number.isInteger(repeat) ||
|
|
3595
|
+
(repeat as number) < 1 ||
|
|
3596
|
+
(repeat as number) > 16
|
|
3597
|
+
)
|
|
3598
|
+
throw new DawgSdkError(`song form[${index}] repeat must be 1..16`);
|
|
3599
|
+
return Object.freeze(
|
|
3600
|
+
repeat === 1
|
|
3601
|
+
? { section: entry.section }
|
|
3602
|
+
: { section: entry.section, repeat: repeat as number },
|
|
3603
|
+
);
|
|
3604
|
+
}),
|
|
3605
|
+
);
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
function songSections(
|
|
3609
|
+
sections: NonNullable<SongInput["sections"]>,
|
|
3610
|
+
): readonly SongSection[] {
|
|
3611
|
+
if (!Array.isArray(sections))
|
|
3612
|
+
throw new DawgSdkError("song sections must be an array");
|
|
3613
|
+
if (sections.length > 64) throw new DawgSdkError("song has over 64 sections");
|
|
3614
|
+
return Object.freeze(
|
|
3615
|
+
sections.map((section, index) => {
|
|
3616
|
+
const where = `song sections[${index}]`;
|
|
3617
|
+
if (!isRecord(section) || typeof section.name !== "string")
|
|
3618
|
+
throw new DawgSdkError(`${where} needs a name`);
|
|
3619
|
+
const stored: Record<string, unknown> = {
|
|
3620
|
+
name: section.name,
|
|
3621
|
+
startBar: finite(section.startBar, `${where}.startBar`),
|
|
3622
|
+
bars: finite(section.bars, `${where}.bars`),
|
|
3623
|
+
};
|
|
3624
|
+
if (section.mute !== undefined) {
|
|
3625
|
+
if (
|
|
3626
|
+
!Array.isArray(section.mute) ||
|
|
3627
|
+
section.mute.some((id) => typeof id !== "string")
|
|
3628
|
+
)
|
|
3629
|
+
throw new DawgSdkError(`${where}.mute must be track ids`);
|
|
3630
|
+
if (section.mute.length > 0)
|
|
3631
|
+
stored.mute = Object.freeze([...section.mute]);
|
|
3632
|
+
}
|
|
3633
|
+
if (section.vary !== undefined) {
|
|
3634
|
+
if (!isRecord(section.vary))
|
|
3635
|
+
throw new DawgSdkError(`${where}.vary must be an object`);
|
|
3636
|
+
const vary = Object.entries(section.vary);
|
|
3637
|
+
if (vary.length > 0)
|
|
3638
|
+
stored.vary = Object.freeze(
|
|
3639
|
+
Object.fromEntries(
|
|
3640
|
+
vary.map(([id, change]) => {
|
|
3641
|
+
if (!isRecord(change))
|
|
3642
|
+
throw new DawgSdkError(
|
|
3643
|
+
`${where}.vary.${id} must be an object`,
|
|
3644
|
+
);
|
|
3645
|
+
const out: Record<string, number> = {};
|
|
3646
|
+
if (change.transpose !== undefined)
|
|
3647
|
+
out.transpose = finite(
|
|
3648
|
+
change.transpose,
|
|
3649
|
+
`${where}.vary.${id}.transpose`,
|
|
3650
|
+
);
|
|
3651
|
+
if (change.gain !== undefined)
|
|
3652
|
+
out.gain = finite(change.gain, `${where}.vary.${id}.gain`);
|
|
3653
|
+
return [id, Object.freeze(out)];
|
|
3654
|
+
}),
|
|
3655
|
+
),
|
|
3656
|
+
);
|
|
3657
|
+
}
|
|
3658
|
+
return Object.freeze(stored) as SongSection;
|
|
3659
|
+
}),
|
|
3660
|
+
);
|
|
3661
|
+
}
|
|
3662
|
+
|
|
2118
3663
|
/**
|
|
2119
3664
|
* Assemble the song. Beats become ticks (`Math.round(beat * ticksPerBeat)`,
|
|
2120
3665
|
* lengths at least one tick), and every note gets a deterministic id from
|
|
@@ -2127,7 +3672,11 @@ export function song(input: SongInput): Song {
|
|
|
2127
3672
|
const beatsPerBar = Array.isArray(meter)
|
|
2128
3673
|
? finite(meter[0], "song meter[0]")
|
|
2129
3674
|
: finite(meter as number, "song meter");
|
|
2130
|
-
|
|
3675
|
+
const beatUnit = Array.isArray(meter) ? finite(meter[1], "song meter[1]") : 4;
|
|
3676
|
+
if (![1, 2, 4, 8, 16, 32].includes(beatUnit))
|
|
3677
|
+
throw new DawgSdkError(
|
|
3678
|
+
"song meter note value must be 1, 2, 4, 8, 16 or 32",
|
|
3679
|
+
);
|
|
2131
3680
|
const bars = finite(input.bars ?? 4, "song bars");
|
|
2132
3681
|
const ticksPerBeat = input.ticksPerBeat ?? DEFAULT_TICKS_PER_BEAT;
|
|
2133
3682
|
if (
|
|
@@ -2139,6 +3688,8 @@ export function song(input: SongInput): Song {
|
|
|
2139
3688
|
const key = input.key ?? null;
|
|
2140
3689
|
if (key !== null && typeof key !== "string")
|
|
2141
3690
|
throw new DawgSdkError("song key must be a string or null");
|
|
3691
|
+
const songTuning = tuningSpec(input.tuning, "song");
|
|
3692
|
+
const master = masterData(input.master);
|
|
2142
3693
|
if (!Array.isArray(input.tracks))
|
|
2143
3694
|
throw new DawgSdkError("song tracks must be an array of track()");
|
|
2144
3695
|
if (input.tracks.length > 64)
|
|
@@ -2196,12 +3747,46 @@ export function song(input: SongInput): Song {
|
|
|
2196
3747
|
stored.wavetable = Object.freeze(fields);
|
|
2197
3748
|
}
|
|
2198
3749
|
if (wtAutomation.length > 0) stored.wtAutomation = wtAutomation;
|
|
3750
|
+
if (t.granular) stored.granular = t.granular;
|
|
2199
3751
|
if (t.sampler)
|
|
2200
3752
|
stored.sampler = Object.freeze({
|
|
2201
3753
|
voices: t.sampler.voices,
|
|
2202
3754
|
mode: t.sampler.mode,
|
|
2203
3755
|
});
|
|
2204
3756
|
if (t.kit) stored.kit = t.kit;
|
|
3757
|
+
if (t.time) {
|
|
3758
|
+
const time: Record<string, unknown> = {};
|
|
3759
|
+
if (t.time.rate !== undefined) time.rate = t.time.rate;
|
|
3760
|
+
if (t.time.phase !== undefined) {
|
|
3761
|
+
const phase = ticks(t.time.phase);
|
|
3762
|
+
if (phase !== 0) time.phase = phase;
|
|
3763
|
+
}
|
|
3764
|
+
if (t.time.cycle !== undefined)
|
|
3765
|
+
time.cycle = Math.max(1, ticks(t.time.cycle));
|
|
3766
|
+
if (t.time.steps)
|
|
3767
|
+
time.steps = Object.freeze({
|
|
3768
|
+
...t.time.steps,
|
|
3769
|
+
shift: Math.max(1, ticks(t.time.steps.shift)),
|
|
3770
|
+
});
|
|
3771
|
+
if (Object.keys(time).length > 0) stored.time = Object.freeze(time);
|
|
3772
|
+
}
|
|
3773
|
+
if (t.glide) stored.glide = t.glide;
|
|
3774
|
+
if (t.pedal && t.pedal.length > 0) {
|
|
3775
|
+
// One event per tick (the last wins), in tick order, as dawg stores it.
|
|
3776
|
+
const byTick = new Map<number, PedalState>();
|
|
3777
|
+
for (const [at, state] of t.pedal) byTick.set(ticks(at), state);
|
|
3778
|
+
stored.pedal = Object.freeze(
|
|
3779
|
+
[...byTick.entries()]
|
|
3780
|
+
.sort((a, b) => a[0] - b[0])
|
|
3781
|
+
.map(([tick, state]) => Object.freeze({ tick, state })),
|
|
3782
|
+
);
|
|
3783
|
+
}
|
|
3784
|
+
if (t.velocityCurve) stored.velocityCurve = t.velocityCurve;
|
|
3785
|
+
if (t.humanize) stored.humanize = t.humanize;
|
|
3786
|
+
if (t.tuning) stored.tuning = t.tuning;
|
|
3787
|
+
if (t.string) stored.string = t.string;
|
|
3788
|
+
if (t.keys) stored.keys = t.keys;
|
|
3789
|
+
if (t.modal) stored.modal = t.modal;
|
|
2205
3790
|
if (t.rhythm && t.rhythm.length > 0)
|
|
2206
3791
|
stored.rhythm = Object.freeze(
|
|
2207
3792
|
t.rhythm.map((row) => {
|
|
@@ -2228,10 +3813,42 @@ export function song(input: SongInput): Song {
|
|
|
2228
3813
|
durationTicks,
|
|
2229
3814
|
pitch: n.pitch,
|
|
2230
3815
|
velocity: n.velocity,
|
|
3816
|
+
...(n.articulation ? { articulation: n.articulation } : {}),
|
|
3817
|
+
...(n.glide !== undefined ? { glide: n.glide } : {}),
|
|
3818
|
+
...(n.bend
|
|
3819
|
+
? {
|
|
3820
|
+
bend: Object.freeze(
|
|
3821
|
+
[...n.bend]
|
|
3822
|
+
.sort((a, b) => a[0] - b[0])
|
|
3823
|
+
.map(([at, cents]) => Object.freeze({ at, cents })),
|
|
3824
|
+
),
|
|
3825
|
+
}
|
|
3826
|
+
: {}),
|
|
3827
|
+
...(n.vibrato ? { vibrato: n.vibrato } : {}),
|
|
3828
|
+
...(n.humanize ? { humanize: n.humanize } : {}),
|
|
3829
|
+
...(n.cents ? { cents: n.cents } : {}),
|
|
2231
3830
|
}),
|
|
2232
3831
|
);
|
|
2233
3832
|
}
|
|
2234
3833
|
});
|
|
3834
|
+
const arrangement: {
|
|
3835
|
+
sections?: readonly SongSection[];
|
|
3836
|
+
form?: readonly SongFormEntry[];
|
|
3837
|
+
loopSection?: string;
|
|
3838
|
+
} = {};
|
|
3839
|
+
if (input.sections !== undefined) {
|
|
3840
|
+
const sections = songSections(input.sections);
|
|
3841
|
+
if (sections.length > 0) arrangement.sections = sections;
|
|
3842
|
+
}
|
|
3843
|
+
if (input.form !== undefined) {
|
|
3844
|
+
const form = parseSongForm(input.form);
|
|
3845
|
+
if (form.length > 0) arrangement.form = form;
|
|
3846
|
+
}
|
|
3847
|
+
if (input.loopSection !== undefined) {
|
|
3848
|
+
if (typeof input.loopSection !== "string")
|
|
3849
|
+
throw new DawgSdkError("song loopSection must be a section name");
|
|
3850
|
+
arrangement.loopSection = input.loopSection;
|
|
3851
|
+
}
|
|
2235
3852
|
return Object.freeze({
|
|
2236
3853
|
format: "track.loop/v1",
|
|
2237
3854
|
version: 1,
|
|
@@ -2240,11 +3857,624 @@ export function song(input: SongInput): Song {
|
|
|
2240
3857
|
bars,
|
|
2241
3858
|
ticksPerBeat,
|
|
2242
3859
|
key,
|
|
3860
|
+
...songTime(
|
|
3861
|
+
input.time,
|
|
3862
|
+
tempoBpm,
|
|
3863
|
+
ticks,
|
|
3864
|
+
beatsPerBar,
|
|
3865
|
+
ticksPerBeat,
|
|
3866
|
+
beatUnit,
|
|
3867
|
+
bars,
|
|
3868
|
+
),
|
|
3869
|
+
...(songTuning ? { tuning: songTuning } : {}),
|
|
2243
3870
|
tracks: Object.freeze(tracks),
|
|
2244
3871
|
notes: Object.freeze(notes),
|
|
3872
|
+
...(master ? { master } : {}),
|
|
3873
|
+
...arrangement,
|
|
2245
3874
|
});
|
|
2246
3875
|
}
|
|
2247
3876
|
|
|
3877
|
+
// ---------------------------------------------------------------------------
|
|
3878
|
+
// Time (SDK 1.14.0)
|
|
3879
|
+
|
|
3880
|
+
/** One entry of `song({ time })`; build them with the helpers below. */
|
|
3881
|
+
export type TimeMark =
|
|
3882
|
+
| Readonly<{
|
|
3883
|
+
kind: "tempo";
|
|
3884
|
+
/** Beat the change lands on. */
|
|
3885
|
+
at: number;
|
|
3886
|
+
/** Absent: keep the tempo in effect there, so a ramp can start from it. */
|
|
3887
|
+
bpm?: number;
|
|
3888
|
+
/** Glide into `bpm` from the previous mark instead of stepping. */
|
|
3889
|
+
ramp?: "linear" | "exp";
|
|
3890
|
+
/** Set by `rit()`/`accel()`: the direction `song()` checks. */
|
|
3891
|
+
gradual?: "rit" | "accel";
|
|
3892
|
+
/**
|
|
3893
|
+
* Set by `aTempo()` (the tempo before the last rit/accel) and
|
|
3894
|
+
* `tempoPrimo()` (the song's opening tempo) instead of `bpm`.
|
|
3895
|
+
*/
|
|
3896
|
+
back?: "a-tempo" | "primo";
|
|
3897
|
+
}>
|
|
3898
|
+
| Readonly<{
|
|
3899
|
+
kind: "meter";
|
|
3900
|
+
/** Beat of the bar line where the meter starts. */
|
|
3901
|
+
at: number;
|
|
3902
|
+
beatsPerBar: number;
|
|
3903
|
+
beatUnit: number;
|
|
3904
|
+
}>
|
|
3905
|
+
| Readonly<{
|
|
3906
|
+
kind: "fermata";
|
|
3907
|
+
at: number;
|
|
3908
|
+
/** Extra beats the held beat lasts. */
|
|
3909
|
+
beats: number;
|
|
3910
|
+
}>;
|
|
3911
|
+
|
|
3912
|
+
/** `ramp()` curves: `linear` adds the same BPM each beat, `exp` the same ratio. */
|
|
3913
|
+
export type TempoCurve = "linear" | "exp";
|
|
3914
|
+
|
|
3915
|
+
/**
|
|
3916
|
+
* Tempo change at beat `at`: `tempo(32, 140)`. Omit `bpm` to pin the tempo
|
|
3917
|
+
* in effect there, the start of a ramp.
|
|
3918
|
+
*/
|
|
3919
|
+
export function tempo(at: number, bpm?: number): TimeMark {
|
|
3920
|
+
const start = beat(at, "tempo() at");
|
|
3921
|
+
if (start <= 0)
|
|
3922
|
+
throw new DawgSdkError("tempo() at must be > 0; song({ tempo }) is beat 0");
|
|
3923
|
+
if (bpm === undefined) return Object.freeze({ kind: "tempo", at: start });
|
|
3924
|
+
return Object.freeze({ kind: "tempo", at: start, bpm: songBpm(bpm) });
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
/**
|
|
3928
|
+
* `a tempo` at beat `at` (SDK 1.19.0): step back to the tempo in effect
|
|
3929
|
+
* before the last `rit()`/`accel()` (or ramp) ending before `at`.
|
|
3930
|
+
*/
|
|
3931
|
+
export function aTempo(at: number): TimeMark {
|
|
3932
|
+
const start = beat(at, "aTempo() at");
|
|
3933
|
+
if (start <= 0) throw new DawgSdkError("aTempo() at must be > 0");
|
|
3934
|
+
return Object.freeze({ kind: "tempo", at: start, back: "a-tempo" });
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
/** `tempo primo` at beat `at` (SDK 1.19.0): step back to `song({ tempo })`. */
|
|
3938
|
+
export function tempoPrimo(at: number): TimeMark {
|
|
3939
|
+
const start = beat(at, "tempoPrimo() at");
|
|
3940
|
+
if (start <= 0) throw new DawgSdkError("tempoPrimo() at must be > 0");
|
|
3941
|
+
return Object.freeze({ kind: "tempo", at: start, back: "primo" });
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
/**
|
|
3945
|
+
* Glide from the previous tempo mark (or the song's opening tempo) to `bpm`
|
|
3946
|
+
* at beat `at`: `ramp(64, 90)`. `exp` changes by the same ratio each beat.
|
|
3947
|
+
*/
|
|
3948
|
+
export function ramp(
|
|
3949
|
+
at: number,
|
|
3950
|
+
bpm: number,
|
|
3951
|
+
curve: TempoCurve = "linear",
|
|
3952
|
+
): TimeMark {
|
|
3953
|
+
const end = beat(at, "ramp() at");
|
|
3954
|
+
if (end <= 0) throw new DawgSdkError("ramp() at must be > 0");
|
|
3955
|
+
return Object.freeze({
|
|
3956
|
+
kind: "tempo",
|
|
3957
|
+
at: end,
|
|
3958
|
+
bpm: songBpm(bpm),
|
|
3959
|
+
ramp: tempoCurve(curve, "ramp()"),
|
|
3960
|
+
});
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
/**
|
|
3964
|
+
* Ritardando: slow from the tempo at beat `at` to `bpm` over `beats` beats,
|
|
3965
|
+
* `rit(48, 16, 80)`. Same as `[tempo(at), ramp(at + beats, bpm, curve)]`.
|
|
3966
|
+
*/
|
|
3967
|
+
export function rit(
|
|
3968
|
+
at: number,
|
|
3969
|
+
beats: number,
|
|
3970
|
+
bpm: number,
|
|
3971
|
+
curve: TempoCurve = "linear",
|
|
3972
|
+
): readonly TimeMark[] {
|
|
3973
|
+
return gradual("rit", at, beats, bpm, curve);
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
/** Accelerando: like `rit()`, toward a faster `bpm`. */
|
|
3977
|
+
export function accel(
|
|
3978
|
+
at: number,
|
|
3979
|
+
beats: number,
|
|
3980
|
+
bpm: number,
|
|
3981
|
+
curve: TempoCurve = "linear",
|
|
3982
|
+
): readonly TimeMark[] {
|
|
3983
|
+
return gradual("accel", at, beats, bpm, curve);
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
function gradual(
|
|
3987
|
+
direction: "rit" | "accel",
|
|
3988
|
+
at: number,
|
|
3989
|
+
beats: number,
|
|
3990
|
+
bpm: number,
|
|
3991
|
+
curve: TempoCurve,
|
|
3992
|
+
): readonly TimeMark[] {
|
|
3993
|
+
const label = `${direction}()`;
|
|
3994
|
+
const start = beat(at, `${label} at`);
|
|
3995
|
+
const length = positive(beats, `${label} beats`);
|
|
3996
|
+
const target = songBpm(bpm);
|
|
3997
|
+
const shape = tempoCurve(curve, label);
|
|
3998
|
+
const marks: TimeMark[] = [];
|
|
3999
|
+
if (start > 0) marks.push(Object.freeze({ kind: "tempo", at: start }));
|
|
4000
|
+
marks.push(
|
|
4001
|
+
Object.freeze({
|
|
4002
|
+
kind: "tempo",
|
|
4003
|
+
at: start + length,
|
|
4004
|
+
bpm: target,
|
|
4005
|
+
ramp: shape,
|
|
4006
|
+
gradual: direction,
|
|
4007
|
+
}),
|
|
4008
|
+
);
|
|
4009
|
+
return Object.freeze(marks);
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
/** Fermata: the beat at `at` lasts `beats` extra beats (default 2). */
|
|
4013
|
+
export function fermata(at: number, beats = 2): TimeMark {
|
|
4014
|
+
const hold = positive(beats, "fermata() beats");
|
|
4015
|
+
if (hold > 64) throw new DawgSdkError("fermata() beats must be at most 64");
|
|
4016
|
+
return Object.freeze({
|
|
4017
|
+
kind: "fermata",
|
|
4018
|
+
at: beat(at, "fermata() at"),
|
|
4019
|
+
beats: hold,
|
|
4020
|
+
});
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
/**
|
|
4024
|
+
* Meter change on the bar line at beat `at`: `meter(16, [7, 8])` or
|
|
4025
|
+
* `meter(16, 3)` (quarter-note beats). It lasts until the next one.
|
|
4026
|
+
*/
|
|
4027
|
+
export function meter(
|
|
4028
|
+
at: number,
|
|
4029
|
+
value: readonly [number, number] | number,
|
|
4030
|
+
): TimeMark {
|
|
4031
|
+
const start = beat(at, "meter() at");
|
|
4032
|
+
const [beatsPerBar, beatUnit] = Array.isArray(value)
|
|
4033
|
+
? [value[0], value[1]]
|
|
4034
|
+
: [value as number, 4];
|
|
4035
|
+
if (
|
|
4036
|
+
typeof beatsPerBar !== "number" ||
|
|
4037
|
+
!Number.isInteger(beatsPerBar) ||
|
|
4038
|
+
beatsPerBar < 1 ||
|
|
4039
|
+
beatsPerBar > 16
|
|
4040
|
+
)
|
|
4041
|
+
throw new DawgSdkError("meter() beats per bar must be an integer 1..16");
|
|
4042
|
+
if (![1, 2, 4, 8, 16, 32].includes(beatUnit as number))
|
|
4043
|
+
throw new DawgSdkError("meter() note value must be 1, 2, 4, 8, 16 or 32");
|
|
4044
|
+
return Object.freeze({
|
|
4045
|
+
kind: "meter",
|
|
4046
|
+
at: start,
|
|
4047
|
+
beatsPerBar,
|
|
4048
|
+
beatUnit: beatUnit as number,
|
|
4049
|
+
});
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
/**
|
|
4053
|
+
* Track time for continuous phasing, the tape drift of Reich's It's Gonna
|
|
4054
|
+
* Rain and Come Out: the track's first `cycle` beats repeat a little fast,
|
|
4055
|
+
* gaining `cycles` whole cycles every `over` beats, so it drifts away from
|
|
4056
|
+
* an identical track and lines up again. `over` should divide the song
|
|
4057
|
+
* loop. `track({ ..., time: phasing(3, 48) })`. For Piano Phase's
|
|
4058
|
+
* shift-and-hold, use `stepPhasing()`.
|
|
4059
|
+
*/
|
|
4060
|
+
export function phasing(
|
|
4061
|
+
cycle: number,
|
|
4062
|
+
over: number,
|
|
4063
|
+
cycles = 1,
|
|
4064
|
+
): TrackTimeInput {
|
|
4065
|
+
const length = positive(cycle, "phasing() cycle");
|
|
4066
|
+
const span = positive(over, "phasing() over");
|
|
4067
|
+
const gain = finite(cycles, "phasing() cycles");
|
|
4068
|
+
const repeats = span / length;
|
|
4069
|
+
const rate = (repeats + gain) / repeats;
|
|
4070
|
+
if (!(rate >= 0.125 && rate <= 8))
|
|
4071
|
+
throw new DawgSdkError("phasing() needs a rate between 0.125 and 8");
|
|
4072
|
+
return Object.freeze({ cycle: length, rate });
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
/**
|
|
4076
|
+
* Stepped phasing (SDK 1.19.0), as in Reich's Piano Phase: the track's first `cycle`
|
|
4077
|
+
* beats hold in step with a twin for `hold` cycles, then move `shift`
|
|
4078
|
+
* beats ahead over `drift` cycles, and repeat until a whole cycle ahead.
|
|
4079
|
+
* `track({ ..., time: stepPhasing(3, { hold: 8 }) })`.
|
|
4080
|
+
*/
|
|
4081
|
+
export function stepPhasing(
|
|
4082
|
+
cycle: number,
|
|
4083
|
+
options: Readonly<{ shift?: number; hold?: number; drift?: number }> = {},
|
|
4084
|
+
): TrackTimeInput {
|
|
4085
|
+
const length = positive(cycle, "stepPhasing() cycle");
|
|
4086
|
+
const steps = phaseSteps(
|
|
4087
|
+
{ shift: 0.25, hold: 8, drift: 2, ...options },
|
|
4088
|
+
length,
|
|
4089
|
+
"stepPhasing()",
|
|
4090
|
+
);
|
|
4091
|
+
return Object.freeze({ cycle: length, steps });
|
|
4092
|
+
}
|
|
4093
|
+
|
|
4094
|
+
function songBpm(value: unknown): number {
|
|
4095
|
+
const bpm = finite(value, "tempo bpm");
|
|
4096
|
+
if (bpm < 20 || bpm > 300)
|
|
4097
|
+
throw new DawgSdkError("tempo bpm must be 20..300");
|
|
4098
|
+
return bpm;
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
function tempoCurve(value: unknown, label: string): TempoCurve {
|
|
4102
|
+
if (value !== "linear" && value !== "exp")
|
|
4103
|
+
throw new DawgSdkError(`${label} curve must be "linear" or "exp"`);
|
|
4104
|
+
return value;
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
/** Same as `TIME_LIMITS.maxFermataSeconds` in core/tempo.ts. */
|
|
4108
|
+
const MAX_FERMATA_SECONDS = 16.777;
|
|
4109
|
+
|
|
4110
|
+
/** Tempo at `tick` through resolved tempo events (ramps glide into theirs). */
|
|
4111
|
+
function bpmAtTick(
|
|
4112
|
+
tempo: readonly { tick: number; bpm: number; ramp?: TempoCurve }[],
|
|
4113
|
+
start: number,
|
|
4114
|
+
tick: number,
|
|
4115
|
+
): number {
|
|
4116
|
+
let from = { tick: 0, bpm: start };
|
|
4117
|
+
for (const event of tempo) {
|
|
4118
|
+
if (event.tick <= tick) {
|
|
4119
|
+
from = event;
|
|
4120
|
+
continue;
|
|
4121
|
+
}
|
|
4122
|
+
if (!event.ramp) break;
|
|
4123
|
+
const t = (tick - from.tick) / (event.tick - from.tick);
|
|
4124
|
+
return event.ramp === "exp"
|
|
4125
|
+
? from.bpm * Math.pow(event.bpm / from.bpm, t)
|
|
4126
|
+
: from.bpm + (event.bpm - from.bpm) * t;
|
|
4127
|
+
}
|
|
4128
|
+
return from.bpm;
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
/** Resolves `song({ time })` marks into the stored ticks and bar indexes. */
|
|
4132
|
+
function songTime(
|
|
4133
|
+
input: unknown,
|
|
4134
|
+
tempoBpm: number,
|
|
4135
|
+
ticks: (beats: number) => number,
|
|
4136
|
+
beatsPerBar: number,
|
|
4137
|
+
ticksPerBeat: number,
|
|
4138
|
+
beatUnit = 4,
|
|
4139
|
+
bars = Infinity,
|
|
4140
|
+
): { time?: ScoreTime } {
|
|
4141
|
+
if ((input === undefined || input === null) && beatUnit === 4) return {};
|
|
4142
|
+
input ??= [];
|
|
4143
|
+
if (!Array.isArray(input))
|
|
4144
|
+
throw new DawgSdkError("song time must be an array of time marks");
|
|
4145
|
+
const marks: TimeMark[] = [];
|
|
4146
|
+
for (const [index, entry] of (input as unknown[]).entries()) {
|
|
4147
|
+
for (const mark of Array.isArray(entry) ? entry : [entry]) {
|
|
4148
|
+
if (
|
|
4149
|
+
!isRecord(mark) ||
|
|
4150
|
+
(mark.kind !== "tempo" &&
|
|
4151
|
+
mark.kind !== "meter" &&
|
|
4152
|
+
mark.kind !== "fermata")
|
|
4153
|
+
)
|
|
4154
|
+
throw new DawgSdkError(
|
|
4155
|
+
`song time[${index}] must come from tempo(), ramp(), rit(), accel(), fermata() or meter()`,
|
|
4156
|
+
);
|
|
4157
|
+
marks.push(mark as TimeMark);
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
// Tempo: sort by tick, resolve pins against their neighbours.
|
|
4161
|
+
type Event = {
|
|
4162
|
+
tick: number;
|
|
4163
|
+
bpm?: number;
|
|
4164
|
+
ramp?: TempoCurve;
|
|
4165
|
+
back?: "a-tempo" | "primo";
|
|
4166
|
+
};
|
|
4167
|
+
const byTick = new Map<number, Event>();
|
|
4168
|
+
for (const mark of marks) {
|
|
4169
|
+
if (mark.kind !== "tempo") continue;
|
|
4170
|
+
const tick = ticks(mark.at);
|
|
4171
|
+
const previous = byTick.get(tick);
|
|
4172
|
+
const sets = (e: { bpm?: number; back?: unknown }) =>
|
|
4173
|
+
e.bpm !== undefined || e.back !== undefined;
|
|
4174
|
+
if (previous && sets(previous) && sets(mark)) {
|
|
4175
|
+
// A rit or ramp ending where a tempo change starts (`rit(18, 6, 52)`
|
|
4176
|
+
// with `aTempo(24)`): the ramp lands a tick early, then the step.
|
|
4177
|
+
const ramped = previous.ramp ? previous : mark.ramp ? mark : undefined;
|
|
4178
|
+
const other = ramped === previous ? mark : previous;
|
|
4179
|
+
if (ramped && !other.ramp && tick > 1 && !byTick.has(tick - 1)) {
|
|
4180
|
+
byTick.set(tick - 1, {
|
|
4181
|
+
tick: tick - 1,
|
|
4182
|
+
...(ramped.bpm !== undefined ? { bpm: ramped.bpm } : {}),
|
|
4183
|
+
ramp: ramped.ramp!,
|
|
4184
|
+
});
|
|
4185
|
+
byTick.set(tick, {
|
|
4186
|
+
tick,
|
|
4187
|
+
...(other.bpm !== undefined ? { bpm: other.bpm } : {}),
|
|
4188
|
+
...(other.back ? { back: other.back } : {}),
|
|
4189
|
+
});
|
|
4190
|
+
continue;
|
|
4191
|
+
}
|
|
4192
|
+
throw new DawgSdkError(
|
|
4193
|
+
`song time has two tempo changes at beat ${mark.at}; move one, or end a rit() where the next tempo starts`,
|
|
4194
|
+
);
|
|
4195
|
+
}
|
|
4196
|
+
// A pin and a change on the same beat: the change wins.
|
|
4197
|
+
if (previous && !sets(mark)) continue;
|
|
4198
|
+
byTick.set(tick, {
|
|
4199
|
+
tick,
|
|
4200
|
+
...(mark.bpm !== undefined ? { bpm: mark.bpm } : {}),
|
|
4201
|
+
...(mark.ramp ? { ramp: mark.ramp } : {}),
|
|
4202
|
+
...(mark.back ? { back: mark.back } : {}),
|
|
4203
|
+
});
|
|
4204
|
+
}
|
|
4205
|
+
const events = [...byTick.values()].sort((a, b) => a.tick - b.tick);
|
|
4206
|
+
const tempo: { tick: number; bpm: number; ramp?: TempoCurve }[] = [];
|
|
4207
|
+
events.forEach((event, index) => {
|
|
4208
|
+
if (event.back) {
|
|
4209
|
+
let bpm = tempoBpm;
|
|
4210
|
+
if (event.back === "a-tempo") {
|
|
4211
|
+
let last = -1;
|
|
4212
|
+
tempo.forEach((e, i) => {
|
|
4213
|
+
if (e.ramp !== undefined) last = i;
|
|
4214
|
+
});
|
|
4215
|
+
if (last < 0)
|
|
4216
|
+
throw new DawgSdkError(
|
|
4217
|
+
`aTempo() at beat ${event.tick / ticksPerBeat} has no rit() or accel() before it`,
|
|
4218
|
+
);
|
|
4219
|
+
bpm = last > 0 ? tempo[last - 1]!.bpm : tempoBpm;
|
|
4220
|
+
}
|
|
4221
|
+
tempo.push(Object.freeze({ tick: event.tick, bpm }));
|
|
4222
|
+
return;
|
|
4223
|
+
}
|
|
4224
|
+
if (event.bpm !== undefined) {
|
|
4225
|
+
// rit() and accel() check their direction against the tempo they
|
|
4226
|
+
// start from, as the prompt's `rit` and `accel` do.
|
|
4227
|
+
const mark = marks.find(
|
|
4228
|
+
(m) => m.kind === "tempo" && ticks(m.at) === event.tick && m.gradual,
|
|
4229
|
+
) as Extract<TimeMark, { kind: "tempo" }> | undefined;
|
|
4230
|
+
if (mark?.gradual) {
|
|
4231
|
+
const from = tempo[tempo.length - 1]?.bpm ?? tempoBpm;
|
|
4232
|
+
if (mark.gradual === "rit" && event.bpm > from)
|
|
4233
|
+
throw new DawgSdkError(
|
|
4234
|
+
`rit() target ${event.bpm} BPM is faster than ${from}; use accel()`,
|
|
4235
|
+
);
|
|
4236
|
+
if (mark.gradual === "accel" && event.bpm < from)
|
|
4237
|
+
throw new DawgSdkError(
|
|
4238
|
+
`accel() target ${event.bpm} BPM is slower than ${from}; use rit()`,
|
|
4239
|
+
);
|
|
4240
|
+
}
|
|
4241
|
+
tempo.push(
|
|
4242
|
+
Object.freeze({
|
|
4243
|
+
tick: event.tick,
|
|
4244
|
+
bpm: event.bpm,
|
|
4245
|
+
...(event.ramp ? { ramp: event.ramp } : {}),
|
|
4246
|
+
}),
|
|
4247
|
+
);
|
|
4248
|
+
return;
|
|
4249
|
+
}
|
|
4250
|
+
// A pin holds the tempo of the marks before it, and the next ramp
|
|
4251
|
+
// starts from it. Without a ramp after it, it changes nothing.
|
|
4252
|
+
const after = events
|
|
4253
|
+
.slice(index + 1)
|
|
4254
|
+
.find((e) => e.bpm !== undefined || e.back !== undefined);
|
|
4255
|
+
if (!after?.ramp) return;
|
|
4256
|
+
const before = tempo[tempo.length - 1]?.bpm ?? tempoBpm;
|
|
4257
|
+
tempo.push(Object.freeze({ tick: event.tick, bpm: before }));
|
|
4258
|
+
});
|
|
4259
|
+
// Meter: beats to bar indexes, checking each lands on a bar line.
|
|
4260
|
+
const meters = marks
|
|
4261
|
+
.filter((mark) => mark.kind === "meter")
|
|
4262
|
+
.sort((a, b) => a.at - b.at);
|
|
4263
|
+
// `song({ meter: [6, 8] })`: the song meter's note value as a bar-1 change.
|
|
4264
|
+
if (beatUnit !== 4 && !meters.some((mark) => ticks(mark.at) === 0))
|
|
4265
|
+
meters.unshift({ kind: "meter", at: 0, beatsPerBar, beatUnit });
|
|
4266
|
+
const meterOut: { bar: number; beatsPerBar: number; beatUnit?: number }[] =
|
|
4267
|
+
[];
|
|
4268
|
+
let barTick = 0;
|
|
4269
|
+
let barIndex = 0;
|
|
4270
|
+
let barLength = beatsPerBar * ticksPerBeat;
|
|
4271
|
+
for (const mark of meters) {
|
|
4272
|
+
const tick = ticks(mark.at);
|
|
4273
|
+
const bars = (tick - barTick) / barLength;
|
|
4274
|
+
if (!Number.isInteger(bars) || bars < 0)
|
|
4275
|
+
throw new DawgSdkError(`meter() at beat ${mark.at} is not on a bar line`);
|
|
4276
|
+
if (meterOut.length > 0 && bars === 0)
|
|
4277
|
+
throw new DawgSdkError(`song time has two meters at beat ${mark.at}`);
|
|
4278
|
+
barIndex += bars;
|
|
4279
|
+
barTick = tick;
|
|
4280
|
+
barLength = (mark.beatsPerBar * ticksPerBeat * 4) / mark.beatUnit;
|
|
4281
|
+
meterOut.push(
|
|
4282
|
+
Object.freeze({
|
|
4283
|
+
bar: barIndex,
|
|
4284
|
+
beatsPerBar: mark.beatsPerBar,
|
|
4285
|
+
...(mark.beatUnit !== 4 ? { beatUnit: mark.beatUnit } : {}),
|
|
4286
|
+
}),
|
|
4287
|
+
);
|
|
4288
|
+
}
|
|
4289
|
+
const fermatas = marks
|
|
4290
|
+
.filter((mark) => mark.kind === "fermata")
|
|
4291
|
+
.map((mark) => Object.freeze({ tick: ticks(mark.at), beats: mark.beats }))
|
|
4292
|
+
.sort((a, b) => a.tick - b.tick);
|
|
4293
|
+
for (let i = 1; i < fermatas.length; i += 1)
|
|
4294
|
+
if (fermatas[i]!.tick === fermatas[i - 1]!.tick)
|
|
4295
|
+
throw new DawgSdkError("song time has two fermatas on one beat");
|
|
4296
|
+
// Marks past the song end are never heard; the prompt refuses them too.
|
|
4297
|
+
if (Number.isFinite(bars)) {
|
|
4298
|
+
let end = 0;
|
|
4299
|
+
let fromBar = 0;
|
|
4300
|
+
let length = beatsPerBar * ticksPerBeat;
|
|
4301
|
+
for (const change of meterOut) {
|
|
4302
|
+
if (change.bar >= bars) break;
|
|
4303
|
+
end += (change.bar - fromBar) * length;
|
|
4304
|
+
fromBar = change.bar;
|
|
4305
|
+
length = (change.beatsPerBar * ticksPerBeat * 4) / (change.beatUnit ?? 4);
|
|
4306
|
+
}
|
|
4307
|
+
end += (bars - fromBar) * length;
|
|
4308
|
+
const beatOf = (tick: number) => tick / ticksPerBeat;
|
|
4309
|
+
// A ramp to the final barline (`rit()` over the last bars) lands on
|
|
4310
|
+
// the last tick, the tempo the song ends at.
|
|
4311
|
+
tempo.forEach((event, index) => {
|
|
4312
|
+
if (
|
|
4313
|
+
event.tick === end &&
|
|
4314
|
+
event.ramp &&
|
|
4315
|
+
end - 1 > (tempo[index - 1]?.tick ?? 0)
|
|
4316
|
+
)
|
|
4317
|
+
tempo[index] = Object.freeze({ ...event, tick: end - 1 });
|
|
4318
|
+
});
|
|
4319
|
+
for (const event of tempo)
|
|
4320
|
+
if (event.tick >= end)
|
|
4321
|
+
throw new DawgSdkError(
|
|
4322
|
+
`tempo at beat ${beatOf(event.tick)} is past the song end (${beatOf(end)} beats); add bars`,
|
|
4323
|
+
);
|
|
4324
|
+
for (const change of meterOut)
|
|
4325
|
+
if (change.bar >= bars)
|
|
4326
|
+
throw new DawgSdkError(
|
|
4327
|
+
`meter() at bar ${change.bar + 1} is past the song end (${bars} bars); add bars`,
|
|
4328
|
+
);
|
|
4329
|
+
for (const hold of fermatas)
|
|
4330
|
+
if (hold.tick >= end)
|
|
4331
|
+
throw new DawgSdkError(
|
|
4332
|
+
`fermata() at beat ${beatOf(hold.tick)} is past the song end (${beatOf(end)} beats); add bars`,
|
|
4333
|
+
);
|
|
4334
|
+
}
|
|
4335
|
+
// A fermata may hold its beat at most as long as a MIDI file can write.
|
|
4336
|
+
// The held beat is the meter's felt beat (a dotted quarter in 6/8), as
|
|
4337
|
+
// core/tempo.ts fermataSpan has it.
|
|
4338
|
+
const feltBeats = (tick: number): number => {
|
|
4339
|
+
let at = 0;
|
|
4340
|
+
let fromBar = 0;
|
|
4341
|
+
let meter = { beatsPerBar, beatUnit: 4 };
|
|
4342
|
+
let length = beatsPerBar * ticksPerBeat;
|
|
4343
|
+
for (const change of meterOut) {
|
|
4344
|
+
const start = at + (change.bar - fromBar) * length;
|
|
4345
|
+
if (start > tick) break;
|
|
4346
|
+
at = start;
|
|
4347
|
+
fromBar = change.bar;
|
|
4348
|
+
meter = {
|
|
4349
|
+
beatsPerBar: change.beatsPerBar,
|
|
4350
|
+
beatUnit: change.beatUnit ?? 4,
|
|
4351
|
+
};
|
|
4352
|
+
length = (meter.beatsPerBar * ticksPerBeat * 4) / meter.beatUnit;
|
|
4353
|
+
}
|
|
4354
|
+
if (meterOut.length === 0) return 1;
|
|
4355
|
+
const unit = 4 / meter.beatUnit;
|
|
4356
|
+
const compound =
|
|
4357
|
+
meter.beatUnit >= 8 &&
|
|
4358
|
+
meter.beatsPerBar > 3 &&
|
|
4359
|
+
meter.beatsPerBar % 3 === 0;
|
|
4360
|
+
return Math.max(1, compound ? unit * 3 : unit);
|
|
4361
|
+
};
|
|
4362
|
+
for (const hold of fermatas) {
|
|
4363
|
+
const bpm = bpmAtTick(tempo, tempoBpm, hold.tick);
|
|
4364
|
+
const held = ((1 + hold.beats) * feltBeats(hold.tick) * 60) / bpm;
|
|
4365
|
+
if (held > MAX_FERMATA_SECONDS + 1e-9)
|
|
4366
|
+
throw new DawgSdkError(
|
|
4367
|
+
`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`,
|
|
4368
|
+
);
|
|
4369
|
+
}
|
|
4370
|
+
const time: Record<string, unknown> = {};
|
|
4371
|
+
if (tempo.length > 0) time.tempo = Object.freeze(tempo);
|
|
4372
|
+
if (meterOut.length > 0) time.meter = Object.freeze(meterOut);
|
|
4373
|
+
if (fermatas.length > 0) time.fermatas = Object.freeze(fermatas);
|
|
4374
|
+
return Object.keys(time).length > 0
|
|
4375
|
+
? { time: Object.freeze(time) as ScoreTime }
|
|
4376
|
+
: {};
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
// Tuning (SDK 1.16.0)
|
|
4380
|
+
|
|
4381
|
+
/**
|
|
4382
|
+
* A tuning for `song({ tuning })` or `track({ tuning })`: a library name or
|
|
4383
|
+
* an object with at most one table source (`edo`, `ratios`, `cents` or
|
|
4384
|
+
* `scl`). Library names: `12-tet`, `19-edo`, `24-edo`, `31-edo`,
|
|
4385
|
+
* `pythagorean`, `just` (5-limit), `7-limit`, `well-tuned-piano`, `pelog`,
|
|
4386
|
+
* `slendro`, `nyamaropa`, `shruti`, maqam and dastgah sets (`bayati`,
|
|
4387
|
+
* `rast`, `saba`, `shur`, `homayoun`, `chahargah`) and raga intonations
|
|
4388
|
+
* (`yaman`, `bhairav`, `kafi`, `todi`, …); `dawg` lists them with
|
|
4389
|
+
* `/tuning list`. dawg checks every value when the song loads.
|
|
4390
|
+
*/
|
|
4391
|
+
export type TuningInput =
|
|
4392
|
+
| string
|
|
4393
|
+
| Readonly<{
|
|
4394
|
+
/** A library tuning, or a label for the table given here. */
|
|
4395
|
+
name?: string;
|
|
4396
|
+
/** Equal divisions of the octave, 1..128. */
|
|
4397
|
+
edo?: number;
|
|
4398
|
+
/** Ratios for degrees 1..n, the last the period: `["9/8", "5/4", "2/1"]`. */
|
|
4399
|
+
ratios?: readonly (string | number)[];
|
|
4400
|
+
/** Cents for degrees 1..n, the last the period: `[240, 480, 720, 960, 1200]`. */
|
|
4401
|
+
cents?: readonly number[];
|
|
4402
|
+
/** A Scala `.scl` file in the project, e.g. `"tunings/slendro.scl"`. */
|
|
4403
|
+
scl?: string;
|
|
4404
|
+
/** A Scala `.kbm` keyboard mapping in the project; it sets its own root and A4. */
|
|
4405
|
+
kbm?: string;
|
|
4406
|
+
/** A4 in Hz, 220..880, default 440. */
|
|
4407
|
+
ref?: number;
|
|
4408
|
+
/** Key of degree 0, `"D4"` or 62; default the song key's tonic in octave 4. */
|
|
4409
|
+
root?: Pitch;
|
|
4410
|
+
/** `linear` (default): one key per step. `nearest`: every key plays the step nearest its 12-TET pitch. */
|
|
4411
|
+
map?: "linear" | "nearest";
|
|
4412
|
+
}>;
|
|
4413
|
+
|
|
4414
|
+
/** A stored tuning: `root` is a MIDI number, `ratios` are strings. */
|
|
4415
|
+
export type ScoreTuning = Readonly<{
|
|
4416
|
+
name?: string;
|
|
4417
|
+
edo?: number;
|
|
4418
|
+
ratios?: readonly string[];
|
|
4419
|
+
cents?: readonly number[];
|
|
4420
|
+
scl?: string;
|
|
4421
|
+
kbm?: string;
|
|
4422
|
+
ref?: number;
|
|
4423
|
+
root?: number;
|
|
4424
|
+
map?: "linear" | "nearest";
|
|
4425
|
+
}>;
|
|
4426
|
+
|
|
4427
|
+
const TUNING_FIELDS: readonly string[] = Object.freeze([
|
|
4428
|
+
"name",
|
|
4429
|
+
"edo",
|
|
4430
|
+
"ratios",
|
|
4431
|
+
"cents",
|
|
4432
|
+
"scl",
|
|
4433
|
+
"kbm",
|
|
4434
|
+
"ref",
|
|
4435
|
+
"root",
|
|
4436
|
+
"map",
|
|
4437
|
+
]);
|
|
4438
|
+
|
|
4439
|
+
/** Checks a tuning's shape; dawg validates the values when the song loads. */
|
|
4440
|
+
function tuningSpec(
|
|
4441
|
+
input: TuningInput | null | undefined,
|
|
4442
|
+
where: string,
|
|
4443
|
+
): ScoreTuning | null {
|
|
4444
|
+
if (input === undefined || input === null) return null;
|
|
4445
|
+
if (typeof input === "string") {
|
|
4446
|
+
if (input.trim() === "")
|
|
4447
|
+
throw new DawgSdkError(`${where} tuning must be a name or an object`);
|
|
4448
|
+
return Object.freeze({ name: input.trim() });
|
|
4449
|
+
}
|
|
4450
|
+
if (!isRecord(input))
|
|
4451
|
+
throw new DawgSdkError(`${where} tuning must be a name or an object`);
|
|
4452
|
+
const out: Record<string, unknown> = {};
|
|
4453
|
+
for (const [field, value] of Object.entries(input)) {
|
|
4454
|
+
if (!TUNING_FIELDS.includes(field))
|
|
4455
|
+
throw new DawgSdkError(
|
|
4456
|
+
`${where} tuning has an unknown field "${field}" (use ${TUNING_FIELDS.join(", ")})`,
|
|
4457
|
+
);
|
|
4458
|
+
if (value === undefined || value === null) continue;
|
|
4459
|
+
if (field === "root") out.root = midi(value as Pitch);
|
|
4460
|
+
else if (field === "ratios" || field === "cents") {
|
|
4461
|
+
if (!Array.isArray(value))
|
|
4462
|
+
throw new DawgSdkError(`${where} tuning ${field} must be a list`);
|
|
4463
|
+
out[field] = Object.freeze(
|
|
4464
|
+
field === "ratios" ? value.map((ratio) => String(ratio)) : [...value],
|
|
4465
|
+
);
|
|
4466
|
+
} else out[field] = value;
|
|
4467
|
+
}
|
|
4468
|
+
const sources = ["edo", "ratios", "cents", "scl"].filter(
|
|
4469
|
+
(field) => out[field] !== undefined,
|
|
4470
|
+
);
|
|
4471
|
+
if (sources.length > 1)
|
|
4472
|
+
throw new DawgSdkError(
|
|
4473
|
+
`${where} tuning has ${sources.join(" and ")}; give one table`,
|
|
4474
|
+
);
|
|
4475
|
+
return Object.freeze(out as ScoreTuning);
|
|
4476
|
+
}
|
|
4477
|
+
|
|
2248
4478
|
// ---------------------------------------------------------------------------
|
|
2249
4479
|
// Chords
|
|
2250
4480
|
|
|
@@ -2429,6 +4659,274 @@ export function progression(
|
|
|
2429
4659
|
);
|
|
2430
4660
|
}
|
|
2431
4661
|
|
|
4662
|
+
// BEGIN instrument words: generated from core/instruments.ts by core/sdk/sync-instruments.ts
|
|
4663
|
+
/** What an instrument word stores on a track. */
|
|
4664
|
+
type InstrumentWord = Readonly<{
|
|
4665
|
+
/** The `Track.instrument` value. */
|
|
4666
|
+
instrument: string;
|
|
4667
|
+
/** The optional Track field the engine reads (created with defaults). */
|
|
4668
|
+
field?: string;
|
|
4669
|
+
/** A preset of that engine to apply. */
|
|
4670
|
+
preset?: string;
|
|
4671
|
+
/** An insert-effect preset to apply with it (rig aliases). */
|
|
4672
|
+
fx?: string;
|
|
4673
|
+
}>;
|
|
4674
|
+
|
|
4675
|
+
/** A word and what it means. */
|
|
4676
|
+
type InstrumentWordRow = Readonly<{
|
|
4677
|
+
word: string;
|
|
4678
|
+
/**
|
|
4679
|
+
* Borrow the voice (instrument, field, preset) of this other word's row
|
|
4680
|
+
* when it exists; `instrument` is the fallback when it does not.
|
|
4681
|
+
*/
|
|
4682
|
+
voice?: string;
|
|
4683
|
+
}> &
|
|
4684
|
+
InstrumentWord;
|
|
4685
|
+
|
|
4686
|
+
/**
|
|
4687
|
+
* Words that keep their pre-0.6 meaning forever: they resolve to
|
|
4688
|
+
* themselves, whatever rows the lanes add.
|
|
4689
|
+
*/
|
|
4690
|
+
const LEGACY_WORDS: readonly string[] = Object.freeze([
|
|
4691
|
+
"piano",
|
|
4692
|
+
"pluck",
|
|
4693
|
+
"bass",
|
|
4694
|
+
"saw",
|
|
4695
|
+
"square",
|
|
4696
|
+
"triangle",
|
|
4697
|
+
"marimba",
|
|
4698
|
+
"wind",
|
|
4699
|
+
"cello",
|
|
4700
|
+
"contrabass",
|
|
4701
|
+
"ebass",
|
|
4702
|
+
"sitar",
|
|
4703
|
+
"organ",
|
|
4704
|
+
"strings",
|
|
4705
|
+
"bell",
|
|
4706
|
+
"keys",
|
|
4707
|
+
"lead",
|
|
4708
|
+
]);
|
|
4709
|
+
|
|
4710
|
+
/** 0.6 instrument words; each lane appends its own block. */
|
|
4711
|
+
const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
|
|
4712
|
+
// strings (f06-strings): plucked presets of the string engine. Legacy
|
|
4713
|
+
// sitar/ebass keep today's voice (`string preset sitar` reaches the
|
|
4714
|
+
// engine), jangle is the rig alias (the guitar lane maps its 12string to the
|
|
4715
|
+
// preset; `string jangle` reaches it) and
|
|
4716
|
+
// upright is the keys lane's piano (doublebass reaches the preset).
|
|
4717
|
+
{ word: "nylon", instrument: "string", field: "string", preset: "nylon" },
|
|
4718
|
+
{ word: "steel", instrument: "string", field: "string", preset: "steel" },
|
|
4719
|
+
{
|
|
4720
|
+
word: "electric",
|
|
4721
|
+
instrument: "string",
|
|
4722
|
+
field: "string",
|
|
4723
|
+
preset: "electric",
|
|
4724
|
+
},
|
|
4725
|
+
{ word: "slap", instrument: "string", field: "string", preset: "slap" },
|
|
4726
|
+
{ word: "motown", instrument: "string", field: "string", preset: "motown" },
|
|
4727
|
+
{ word: "tanpura", instrument: "string", field: "string", preset: "tanpura" },
|
|
4728
|
+
{
|
|
4729
|
+
word: "harpsichord",
|
|
4730
|
+
instrument: "string",
|
|
4731
|
+
field: "string",
|
|
4732
|
+
preset: "harpsichord",
|
|
4733
|
+
},
|
|
4734
|
+
{ word: "lute", instrument: "string", field: "string", preset: "lute" },
|
|
4735
|
+
{ word: "oud", instrument: "string", field: "string", preset: "oud" },
|
|
4736
|
+
{ word: "setar", instrument: "string", field: "string", preset: "setar" },
|
|
4737
|
+
{ word: "tar", instrument: "string", field: "string", preset: "tar" },
|
|
4738
|
+
{ word: "santur", instrument: "string", field: "string", preset: "santur" },
|
|
4739
|
+
{
|
|
4740
|
+
word: "dulcimer",
|
|
4741
|
+
instrument: "string",
|
|
4742
|
+
field: "string",
|
|
4743
|
+
preset: "dulcimer",
|
|
4744
|
+
},
|
|
4745
|
+
{ word: "koto", instrument: "string", field: "string", preset: "koto" },
|
|
4746
|
+
{ word: "harp", instrument: "string", field: "string", preset: "harp" },
|
|
4747
|
+
{ word: "banjo", instrument: "string", field: "string", preset: "banjo" },
|
|
4748
|
+
{ word: "tres", instrument: "string", field: "string", preset: "tres" },
|
|
4749
|
+
{
|
|
4750
|
+
word: "requinto",
|
|
4751
|
+
instrument: "string",
|
|
4752
|
+
field: "string",
|
|
4753
|
+
preset: "requinto",
|
|
4754
|
+
},
|
|
4755
|
+
{ word: "acoustic", instrument: "string", field: "string", preset: "steel" },
|
|
4756
|
+
{ word: "classical", instrument: "string", field: "string", preset: "nylon" },
|
|
4757
|
+
{
|
|
4758
|
+
word: "bassguitar",
|
|
4759
|
+
instrument: "string",
|
|
4760
|
+
field: "string",
|
|
4761
|
+
preset: "ebass",
|
|
4762
|
+
},
|
|
4763
|
+
{ word: "fender", instrument: "string", field: "string", preset: "ebass" },
|
|
4764
|
+
{
|
|
4765
|
+
word: "doublebass",
|
|
4766
|
+
instrument: "string",
|
|
4767
|
+
field: "string",
|
|
4768
|
+
preset: "upright",
|
|
4769
|
+
},
|
|
4770
|
+
{
|
|
4771
|
+
word: "cembalo",
|
|
4772
|
+
instrument: "string",
|
|
4773
|
+
field: "string",
|
|
4774
|
+
preset: "harpsichord",
|
|
4775
|
+
},
|
|
4776
|
+
{
|
|
4777
|
+
word: "hammered",
|
|
4778
|
+
instrument: "string",
|
|
4779
|
+
field: "string",
|
|
4780
|
+
preset: "dulcimer",
|
|
4781
|
+
},
|
|
4782
|
+
{ word: "sehtar", instrument: "string", field: "string", preset: "setar" },
|
|
4783
|
+
// f06-rig: guitar track aliases, a guitar voice plus a whole rig. The
|
|
4784
|
+
// voice is the strings lane's `electric` row (jangle: its 12-string
|
|
4785
|
+
// `jangle` preset); the pluck only while that row is absent. Never `lead`
|
|
4786
|
+
// or `bass`.
|
|
4787
|
+
{
|
|
4788
|
+
word: "jangle",
|
|
4789
|
+
instrument: "string",
|
|
4790
|
+
field: "string",
|
|
4791
|
+
preset: "jangle",
|
|
4792
|
+
fx: "jangle",
|
|
4793
|
+
},
|
|
4794
|
+
{ word: "punk", instrument: "pluck", voice: "electric", fx: "punk" },
|
|
4795
|
+
{ word: "funk", instrument: "pluck", voice: "electric", fx: "funk" },
|
|
4796
|
+
{ word: "ragged", instrument: "pluck", voice: "electric", fx: "ragged" },
|
|
4797
|
+
{ word: "gtr-lead", instrument: "pluck", voice: "electric", fx: "lead" },
|
|
4798
|
+
{ word: "gtr-metal", instrument: "pluck", voice: "electric", fx: "metal" },
|
|
4799
|
+
{ word: "bachata", instrument: "pluck", voice: "electric", fx: "bachata" },
|
|
4800
|
+
// granular (f06-granular): the instrument and its texture presets. Each
|
|
4801
|
+
// starts from a built-in synth source, so nothing downloads.
|
|
4802
|
+
{
|
|
4803
|
+
word: "granular",
|
|
4804
|
+
instrument: "granular",
|
|
4805
|
+
field: "granular",
|
|
4806
|
+
preset: "cloud",
|
|
4807
|
+
},
|
|
4808
|
+
{
|
|
4809
|
+
word: "grains",
|
|
4810
|
+
instrument: "granular",
|
|
4811
|
+
field: "granular",
|
|
4812
|
+
preset: "cloud",
|
|
4813
|
+
},
|
|
4814
|
+
{ word: "cloud", instrument: "granular", field: "granular", preset: "cloud" },
|
|
4815
|
+
{
|
|
4816
|
+
word: "sparkle",
|
|
4817
|
+
instrument: "granular",
|
|
4818
|
+
field: "granular",
|
|
4819
|
+
preset: "sparkle",
|
|
4820
|
+
},
|
|
4821
|
+
{ word: "swarm", instrument: "granular", field: "granular", preset: "swarm" },
|
|
4822
|
+
{
|
|
4823
|
+
word: "microloop",
|
|
4824
|
+
instrument: "granular",
|
|
4825
|
+
field: "granular",
|
|
4826
|
+
preset: "microloop",
|
|
4827
|
+
},
|
|
4828
|
+
// keys (f06-piano): modelled pianos. `piano` stays legacy here; the typed
|
|
4829
|
+
// surfaces store a new `piano` as `grand` (core/keys.ts `pianoWrite`).
|
|
4830
|
+
{ word: "grand", instrument: "grand", field: "keys", preset: "grand" },
|
|
4831
|
+
{ word: "ballad", instrument: "grand", field: "keys", preset: "ballad" },
|
|
4832
|
+
{ word: "upright", instrument: "upright", field: "keys", preset: "upright" },
|
|
4833
|
+
{ word: "felt", instrument: "felt", field: "keys", preset: "felt" },
|
|
4834
|
+
{ word: "lofi", instrument: "felt", field: "keys", preset: "lofi" },
|
|
4835
|
+
{
|
|
4836
|
+
word: "honkytonk",
|
|
4837
|
+
instrument: "honkytonk",
|
|
4838
|
+
field: "keys",
|
|
4839
|
+
preset: "honkytonk",
|
|
4840
|
+
},
|
|
4841
|
+
{
|
|
4842
|
+
word: "prepared",
|
|
4843
|
+
instrument: "prepared",
|
|
4844
|
+
field: "keys",
|
|
4845
|
+
preset: "prepared",
|
|
4846
|
+
},
|
|
4847
|
+
// f06-modal: mallets and bells (core/resonators.ts). `marimba` is legacy;
|
|
4848
|
+
// `modal` alone gives the modal marimba.
|
|
4849
|
+
{ word: "modal", instrument: "modal", field: "modal", preset: "marimba" },
|
|
4850
|
+
{ word: "vibes", instrument: "modal", field: "modal", preset: "vibes" },
|
|
4851
|
+
{ word: "vibraphone", instrument: "modal", field: "modal", preset: "vibes" },
|
|
4852
|
+
{
|
|
4853
|
+
word: "xylophone",
|
|
4854
|
+
instrument: "modal",
|
|
4855
|
+
field: "modal",
|
|
4856
|
+
preset: "xylophone",
|
|
4857
|
+
},
|
|
4858
|
+
{ word: "glock", instrument: "modal", field: "modal", preset: "glock" },
|
|
4859
|
+
{
|
|
4860
|
+
word: "glockenspiel",
|
|
4861
|
+
instrument: "modal",
|
|
4862
|
+
field: "modal",
|
|
4863
|
+
preset: "glock",
|
|
4864
|
+
},
|
|
4865
|
+
{ word: "celesta", instrument: "modal", field: "modal", preset: "celesta" },
|
|
4866
|
+
{ word: "chimes", instrument: "modal", field: "modal", preset: "chimes" },
|
|
4867
|
+
{ word: "tubular", instrument: "modal", field: "modal", preset: "chimes" },
|
|
4868
|
+
{ word: "kalimba", instrument: "modal", field: "modal", preset: "kalimba" },
|
|
4869
|
+
{
|
|
4870
|
+
word: "thumbpiano",
|
|
4871
|
+
instrument: "modal",
|
|
4872
|
+
field: "modal",
|
|
4873
|
+
preset: "kalimba",
|
|
4874
|
+
},
|
|
4875
|
+
{ word: "mbira", instrument: "modal", field: "modal", preset: "mbira" },
|
|
4876
|
+
{ word: "steelpan", instrument: "modal", field: "modal", preset: "steelpan" },
|
|
4877
|
+
{ word: "bowl", instrument: "modal", field: "modal", preset: "bowl" },
|
|
4878
|
+
{ word: "gong", instrument: "modal", field: "modal", preset: "gong" },
|
|
4879
|
+
{ word: "gongageng", instrument: "modal", field: "modal", preset: "gong" },
|
|
4880
|
+
{ word: "timpani", instrument: "modal", field: "modal", preset: "timpani" },
|
|
4881
|
+
{
|
|
4882
|
+
word: "steeldrum",
|
|
4883
|
+
instrument: "modal",
|
|
4884
|
+
field: "modal",
|
|
4885
|
+
preset: "steelpan",
|
|
4886
|
+
},
|
|
4887
|
+
{ word: "singingbowl", instrument: "modal", field: "modal", preset: "bowl" },
|
|
4888
|
+
{
|
|
4889
|
+
word: "kettledrum",
|
|
4890
|
+
instrument: "modal",
|
|
4891
|
+
field: "modal",
|
|
4892
|
+
preset: "timpani",
|
|
4893
|
+
},
|
|
4894
|
+
{
|
|
4895
|
+
word: "tubularbells",
|
|
4896
|
+
instrument: "modal",
|
|
4897
|
+
field: "modal",
|
|
4898
|
+
preset: "chimes",
|
|
4899
|
+
},
|
|
4900
|
+
]);
|
|
4901
|
+
|
|
4902
|
+
/**
|
|
4903
|
+
* What an instrument word means: a legacy word is itself, a row word is its
|
|
4904
|
+
* row, anything else is undefined (callers keep the word as typed).
|
|
4905
|
+
*/
|
|
4906
|
+
function resolveInstrumentWord(word: string): InstrumentWord | undefined {
|
|
4907
|
+
if (LEGACY_WORDS.includes(word)) return Object.freeze({ instrument: word });
|
|
4908
|
+
const row = INSTRUMENT_WORDS.find((entry) => entry.word === word);
|
|
4909
|
+
if (!row) return undefined;
|
|
4910
|
+
const { word: _word, voice, ...meaning } = row;
|
|
4911
|
+
const borrowed =
|
|
4912
|
+
voice === undefined
|
|
4913
|
+
? undefined
|
|
4914
|
+
: INSTRUMENT_WORDS.find((entry) => entry.word === voice && !entry.voice);
|
|
4915
|
+
if (!borrowed) return Object.freeze(meaning);
|
|
4916
|
+
return Object.freeze({
|
|
4917
|
+
instrument: borrowed.instrument,
|
|
4918
|
+
...(borrowed.field === undefined ? {} : { field: borrowed.field }),
|
|
4919
|
+
...(borrowed.preset === undefined ? {} : { preset: borrowed.preset }),
|
|
4920
|
+
...(meaning.fx === undefined ? {} : { fx: meaning.fx }),
|
|
4921
|
+
});
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4924
|
+
/** The `Track.instrument` value a word stores (the word itself if unknown). */
|
|
4925
|
+
function instrumentForWord(word: string): string {
|
|
4926
|
+
return resolveInstrumentWord(word)?.instrument ?? word;
|
|
4927
|
+
}
|
|
4928
|
+
// END instrument words
|
|
4929
|
+
|
|
2432
4930
|
// BEGIN chord engine: generated from core/chords.ts by core/sdk/sync-chords.ts
|
|
2433
4931
|
// ---------------------------------------------------------------------------
|
|
2434
4932
|
// Vocabulary
|
|
@@ -2815,6 +5313,8 @@ const MODES = Object.freeze({
|
|
|
2815
5313
|
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
|
2816
5314
|
locrian: [0, 1, 3, 5, 6, 8, 10],
|
|
2817
5315
|
"harmonic-minor": [0, 2, 3, 5, 7, 8, 11],
|
|
5316
|
+
"melodic-minor": [0, 2, 3, 5, 7, 9, 11],
|
|
5317
|
+
"phrygian-dominant": [0, 1, 4, 5, 7, 8, 10],
|
|
2818
5318
|
} as const);
|
|
2819
5319
|
type ModeName = keyof typeof MODES;
|
|
2820
5320
|
const MODE_NAMES = Object.keys(MODES) as ModeName[];
|
|
@@ -2837,25 +5337,302 @@ const MODE_ALIASES: Readonly<Record<string, ModeName>> = Object.freeze({
|
|
|
2837
5337
|
"harmonic-minor": "harmonic-minor",
|
|
2838
5338
|
"harmonic minor": "harmonic-minor",
|
|
2839
5339
|
harmonic: "harmonic-minor",
|
|
5340
|
+
"melodic-minor": "melodic-minor",
|
|
5341
|
+
"melodic minor": "melodic-minor",
|
|
5342
|
+
melodic: "melodic-minor",
|
|
5343
|
+
"jazz minor": "melodic-minor",
|
|
5344
|
+
"phrygian-dominant": "phrygian-dominant",
|
|
5345
|
+
"phrygian dominant": "phrygian-dominant",
|
|
5346
|
+
freygish: "phrygian-dominant",
|
|
5347
|
+
spanish: "phrygian-dominant",
|
|
5348
|
+
ajam: "major",
|
|
5349
|
+
mahur: "major",
|
|
5350
|
+
bilawal: "major",
|
|
2840
5351
|
});
|
|
2841
5352
|
|
|
2842
|
-
type
|
|
5353
|
+
type ScaleFamily =
|
|
5354
|
+
"pentatonic" | "blues" | "maqam" | "dastgah" | "raga" | "messiaen";
|
|
5355
|
+
|
|
5356
|
+
/**
|
|
5357
|
+
* Scales beyond the chord modes, for keys such as `D bayati`, `C yaman` or
|
|
5358
|
+
* `C messiaen-3`. `steps` are semitones above the tonic and may be
|
|
5359
|
+
* fractional (a quarter tone is .5); `mode` is the seven-note mode the
|
|
5360
|
+
* chord engine harmonizes with (the closest one; see DAWG.md). A raga's
|
|
5361
|
+
* `intonation` is each step's traditional just pitch in cents (shruti
|
|
5362
|
+
* offsets), which its named tuning applies (`tuning yaman`): Pythagorean
|
|
5363
|
+
* ati-komal re and dha for Bhairavi, Bhairav, Purvi and Todi, the high
|
|
5364
|
+
* tivra ma (729/512) for Yaman, 9/5 komal ni for Kafi, after Daniélou and
|
|
5365
|
+
* Jairazbhoy. Maqam and dastgah quarter tones follow the 24-tone convention;
|
|
5366
|
+
* Segah and Sikah start on a half-flat note, so their tonic is the key.
|
|
5367
|
+
*/
|
|
5368
|
+
type ScaleInfo = Readonly<{
|
|
5369
|
+
steps: readonly number[];
|
|
5370
|
+
mode: ModeName;
|
|
5371
|
+
family: ScaleFamily;
|
|
5372
|
+
intonation?: readonly number[];
|
|
5373
|
+
aliases?: readonly string[];
|
|
5374
|
+
}>;
|
|
5375
|
+
|
|
5376
|
+
const SCALES = Object.freeze({
|
|
5377
|
+
"major-pentatonic": {
|
|
5378
|
+
steps: [0, 2, 4, 7, 9],
|
|
5379
|
+
mode: "major",
|
|
5380
|
+
family: "pentatonic",
|
|
5381
|
+
aliases: ["pentatonic", "major pentatonic", "pent"],
|
|
5382
|
+
},
|
|
5383
|
+
"minor-pentatonic": {
|
|
5384
|
+
steps: [0, 3, 5, 7, 10],
|
|
5385
|
+
mode: "minor",
|
|
5386
|
+
family: "pentatonic",
|
|
5387
|
+
aliases: ["minor pentatonic", "m pentatonic", "min pentatonic"],
|
|
5388
|
+
},
|
|
5389
|
+
blues: {
|
|
5390
|
+
steps: [0, 3, 5, 6, 7, 10],
|
|
5391
|
+
mode: "minor",
|
|
5392
|
+
family: "blues",
|
|
5393
|
+
aliases: ["minor blues"],
|
|
5394
|
+
},
|
|
5395
|
+
"major-blues": {
|
|
5396
|
+
steps: [0, 2, 3, 4, 7, 9],
|
|
5397
|
+
mode: "major",
|
|
5398
|
+
family: "blues",
|
|
5399
|
+
aliases: ["major blues"],
|
|
5400
|
+
},
|
|
5401
|
+
hijaz: {
|
|
5402
|
+
steps: [0, 1, 4, 5, 7, 8, 10],
|
|
5403
|
+
mode: "phrygian-dominant",
|
|
5404
|
+
family: "maqam",
|
|
5405
|
+
},
|
|
5406
|
+
bayati: {
|
|
5407
|
+
steps: [0, 1.5, 3, 5, 7, 8, 10],
|
|
5408
|
+
mode: "phrygian",
|
|
5409
|
+
family: "maqam",
|
|
5410
|
+
},
|
|
5411
|
+
rast: { steps: [0, 2, 3.5, 5, 7, 9, 10.5], mode: "major", family: "maqam" },
|
|
5412
|
+
saba: { steps: [0, 1.5, 3, 4, 7, 8, 10], mode: "phrygian", family: "maqam" },
|
|
5413
|
+
kurd: { steps: [0, 1, 3, 5, 7, 8, 10], mode: "phrygian", family: "maqam" },
|
|
5414
|
+
nahawand: {
|
|
5415
|
+
steps: [0, 2, 3, 5, 7, 8, 11],
|
|
5416
|
+
mode: "harmonic-minor",
|
|
5417
|
+
family: "maqam",
|
|
5418
|
+
},
|
|
5419
|
+
sikah: {
|
|
5420
|
+
steps: [0, 1.5, 3.5, 5.5, 7, 8.5, 10.5],
|
|
5421
|
+
mode: "phrygian",
|
|
5422
|
+
family: "maqam",
|
|
5423
|
+
aliases: ["sika"],
|
|
5424
|
+
},
|
|
5425
|
+
huzam: {
|
|
5426
|
+
steps: [0, 1.5, 3.5, 4.5, 7.5, 8.5, 10.5],
|
|
5427
|
+
mode: "phrygian",
|
|
5428
|
+
family: "maqam",
|
|
5429
|
+
aliases: ["houzam"],
|
|
5430
|
+
},
|
|
5431
|
+
nikriz: { steps: [0, 2, 3, 6, 7, 9, 10], mode: "dorian", family: "maqam" },
|
|
5432
|
+
shur: {
|
|
5433
|
+
steps: [0, 1.5, 3, 5, 7, 8, 10],
|
|
5434
|
+
mode: "phrygian",
|
|
5435
|
+
family: "dastgah",
|
|
5436
|
+
},
|
|
5437
|
+
homayoun: {
|
|
5438
|
+
steps: [0, 1.5, 4, 5, 7, 8, 10],
|
|
5439
|
+
mode: "phrygian-dominant",
|
|
5440
|
+
family: "dastgah",
|
|
5441
|
+
aliases: ["homayun"],
|
|
5442
|
+
},
|
|
5443
|
+
chahargah: {
|
|
5444
|
+
steps: [0, 1.5, 4, 5, 7, 8.5, 11],
|
|
5445
|
+
mode: "phrygian-dominant",
|
|
5446
|
+
family: "dastgah",
|
|
5447
|
+
aliases: ["chahar-gah"],
|
|
5448
|
+
},
|
|
5449
|
+
segah: {
|
|
5450
|
+
steps: [0, 1.5, 3.5, 5, 6.5, 8.5, 10.5],
|
|
5451
|
+
mode: "phrygian",
|
|
5452
|
+
family: "dastgah",
|
|
5453
|
+
aliases: ["sehgah", "se-gah"],
|
|
5454
|
+
},
|
|
5455
|
+
nava: {
|
|
5456
|
+
steps: [0, 2, 3.5, 5, 7, 8, 10],
|
|
5457
|
+
mode: "minor",
|
|
5458
|
+
family: "dastgah",
|
|
5459
|
+
},
|
|
5460
|
+
yaman: {
|
|
5461
|
+
steps: [0, 2, 4, 6, 7, 9, 11],
|
|
5462
|
+
mode: "lydian",
|
|
5463
|
+
family: "raga",
|
|
5464
|
+
intonation: [0, 203.91, 386.31, 611.73, 701.96, 884.36, 1088.27],
|
|
5465
|
+
aliases: ["kalyan", "yaman kalyan"],
|
|
5466
|
+
},
|
|
5467
|
+
bhairav: {
|
|
5468
|
+
steps: [0, 1, 4, 5, 7, 8, 11],
|
|
5469
|
+
mode: "phrygian-dominant",
|
|
5470
|
+
family: "raga",
|
|
5471
|
+
intonation: [0, 90.22, 386.31, 498.04, 701.96, 792.18, 1088.27],
|
|
5472
|
+
},
|
|
5473
|
+
kafi: {
|
|
5474
|
+
steps: [0, 2, 3, 5, 7, 9, 10],
|
|
5475
|
+
mode: "dorian",
|
|
5476
|
+
family: "raga",
|
|
5477
|
+
intonation: [0, 203.91, 315.64, 498.04, 701.96, 884.36, 1017.6],
|
|
5478
|
+
},
|
|
5479
|
+
bhairavi: {
|
|
5480
|
+
steps: [0, 1, 3, 5, 7, 8, 10],
|
|
5481
|
+
mode: "phrygian",
|
|
5482
|
+
family: "raga",
|
|
5483
|
+
intonation: [0, 90.22, 294.13, 498.04, 701.96, 792.18, 996.09],
|
|
5484
|
+
},
|
|
5485
|
+
asavari: {
|
|
5486
|
+
steps: [0, 2, 3, 5, 7, 8, 10],
|
|
5487
|
+
mode: "minor",
|
|
5488
|
+
family: "raga",
|
|
5489
|
+
intonation: [0, 203.91, 315.64, 498.04, 701.96, 813.69, 996.09],
|
|
5490
|
+
},
|
|
5491
|
+
khamaj: {
|
|
5492
|
+
steps: [0, 2, 4, 5, 7, 9, 10],
|
|
5493
|
+
mode: "mixolydian",
|
|
5494
|
+
family: "raga",
|
|
5495
|
+
intonation: [0, 203.91, 386.31, 498.04, 701.96, 884.36, 996.09],
|
|
5496
|
+
},
|
|
5497
|
+
todi: {
|
|
5498
|
+
steps: [0, 1, 3, 6, 7, 8, 11],
|
|
5499
|
+
mode: "phrygian",
|
|
5500
|
+
family: "raga",
|
|
5501
|
+
intonation: [0, 95, 294, 606, 702, 792, 1107],
|
|
5502
|
+
},
|
|
5503
|
+
purvi: {
|
|
5504
|
+
steps: [0, 1, 4, 6, 7, 8, 11],
|
|
5505
|
+
mode: "phrygian-dominant",
|
|
5506
|
+
family: "raga",
|
|
5507
|
+
intonation: [0, 90.22, 386.31, 590.22, 701.96, 792.18, 1088.27],
|
|
5508
|
+
},
|
|
5509
|
+
marwa: {
|
|
5510
|
+
steps: [0, 1, 4, 6, 9, 11],
|
|
5511
|
+
mode: "lydian",
|
|
5512
|
+
family: "raga",
|
|
5513
|
+
intonation: [0, 111.73, 386.31, 590.22, 884.36, 1088.27],
|
|
5514
|
+
},
|
|
5515
|
+
darbari: {
|
|
5516
|
+
steps: [0, 2, 3, 5, 7, 8, 10],
|
|
5517
|
+
mode: "minor",
|
|
5518
|
+
family: "raga",
|
|
5519
|
+
intonation: [0, 203.91, 294.13, 498.04, 701.96, 792.18, 996.09],
|
|
5520
|
+
aliases: ["darbari kanada"],
|
|
5521
|
+
},
|
|
5522
|
+
malkauns: {
|
|
5523
|
+
steps: [0, 3, 5, 8, 10],
|
|
5524
|
+
mode: "minor",
|
|
5525
|
+
family: "raga",
|
|
5526
|
+
intonation: [0, 315.64, 498.04, 813.69, 996.09],
|
|
5527
|
+
},
|
|
5528
|
+
bhupali: {
|
|
5529
|
+
steps: [0, 2, 4, 7, 9],
|
|
5530
|
+
mode: "major",
|
|
5531
|
+
family: "raga",
|
|
5532
|
+
intonation: [0, 203.91, 386.31, 701.96, 884.36],
|
|
5533
|
+
},
|
|
5534
|
+
durga: {
|
|
5535
|
+
steps: [0, 2, 5, 7, 9],
|
|
5536
|
+
mode: "major",
|
|
5537
|
+
family: "raga",
|
|
5538
|
+
intonation: [0, 203.91, 498.04, 701.96, 884.36],
|
|
5539
|
+
},
|
|
5540
|
+
"messiaen-1": {
|
|
5541
|
+
steps: [0, 2, 4, 6, 8, 10],
|
|
5542
|
+
mode: "lydian",
|
|
5543
|
+
family: "messiaen",
|
|
5544
|
+
aliases: ["whole-tone", "whole tone", "wholetone"],
|
|
5545
|
+
},
|
|
5546
|
+
"messiaen-2": {
|
|
5547
|
+
steps: [0, 1, 3, 4, 6, 7, 9, 10],
|
|
5548
|
+
mode: "mixolydian",
|
|
5549
|
+
family: "messiaen",
|
|
5550
|
+
aliases: ["octatonic", "diminished", "half-whole"],
|
|
5551
|
+
},
|
|
5552
|
+
"messiaen-3": {
|
|
5553
|
+
steps: [0, 2, 3, 4, 6, 7, 8, 10, 11],
|
|
5554
|
+
mode: "minor",
|
|
5555
|
+
family: "messiaen",
|
|
5556
|
+
},
|
|
5557
|
+
"messiaen-4": {
|
|
5558
|
+
steps: [0, 1, 2, 5, 6, 7, 8, 11],
|
|
5559
|
+
mode: "harmonic-minor",
|
|
5560
|
+
family: "messiaen",
|
|
5561
|
+
},
|
|
5562
|
+
"messiaen-5": {
|
|
5563
|
+
steps: [0, 1, 5, 6, 7, 11],
|
|
5564
|
+
mode: "lydian",
|
|
5565
|
+
family: "messiaen",
|
|
5566
|
+
},
|
|
5567
|
+
"messiaen-6": {
|
|
5568
|
+
steps: [0, 2, 4, 5, 6, 8, 10, 11],
|
|
5569
|
+
mode: "major",
|
|
5570
|
+
family: "messiaen",
|
|
5571
|
+
},
|
|
5572
|
+
"messiaen-7": {
|
|
5573
|
+
steps: [0, 1, 2, 3, 5, 6, 7, 8, 9, 11],
|
|
5574
|
+
mode: "harmonic-minor",
|
|
5575
|
+
family: "messiaen",
|
|
5576
|
+
},
|
|
5577
|
+
} as const satisfies Record<string, ScaleInfo>);
|
|
5578
|
+
type ScaleName = keyof typeof SCALES;
|
|
5579
|
+
const SCALE_NAMES = Object.keys(SCALES) as ScaleName[];
|
|
5580
|
+
|
|
5581
|
+
const SCALE_ALIASES: Readonly<Record<string, ScaleName>> = (() => {
|
|
5582
|
+
const aliases: Record<string, ScaleName> = {};
|
|
5583
|
+
for (const name of SCALE_NAMES) {
|
|
5584
|
+
const info: ScaleInfo = SCALES[name];
|
|
5585
|
+
aliases[name] = name;
|
|
5586
|
+
aliases[name.replace(/-/g, " ")] = name;
|
|
5587
|
+
for (const alias of info.aliases ?? []) aliases[alias] = name;
|
|
5588
|
+
}
|
|
5589
|
+
return Object.freeze(aliases);
|
|
5590
|
+
})();
|
|
5591
|
+
|
|
5592
|
+
/** The library scale named `text` (case and `-`/space insensitive). */
|
|
5593
|
+
function scaleNamed(text: string): ScaleName | undefined {
|
|
5594
|
+
const word = text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
5595
|
+
return SCALE_ALIASES[word] ?? SCALE_ALIASES[word.replace(/ /g, "-")];
|
|
5596
|
+
}
|
|
5597
|
+
|
|
5598
|
+
/**
|
|
5599
|
+
* A key: a tonic and the seven-note `mode` the chord engine uses, plus the
|
|
5600
|
+
* library `scale` when the key names one (`D bayati`, `C yaman`).
|
|
5601
|
+
*/
|
|
5602
|
+
type Key = Readonly<{
|
|
5603
|
+
tonic: number;
|
|
5604
|
+
mode: ModeName;
|
|
5605
|
+
scale?: ScaleName;
|
|
5606
|
+
}>;
|
|
2843
5607
|
|
|
2844
5608
|
/**
|
|
2845
|
-
* Parse a key: `C`, `c major`, `Am`, `a minor`, `F# dorian`, `Eb mixo
|
|
2846
|
-
* Accepts the `<note> <mode>` form
|
|
5609
|
+
* Parse a key: `C`, `c major`, `Am`, `a minor`, `F# dorian`, `Eb mixo`,
|
|
5610
|
+
* `D bayati`, `C messiaen-3`. Accepts the `<note> <mode>` form
|
|
5611
|
+
* `core/key.ts` writes.
|
|
2847
5612
|
*/
|
|
2848
5613
|
function parseKey(text: string | null | undefined): Key | undefined {
|
|
2849
5614
|
if (typeof text !== "string" || text.length > 40) return undefined;
|
|
2850
5615
|
const match = text
|
|
2851
5616
|
.trim()
|
|
2852
|
-
.match(/^([a-gA-G])(#|b|♯|♭)?\s*(m(?![a-z])|[a-zA-Z][a-zA-
|
|
5617
|
+
.match(/^([a-gA-G])(#|b|♯|♭)?\s*(m(?![a-z])|[a-zA-Z][a-zA-Z0-9 -]*)?$/);
|
|
2853
5618
|
if (!match) return undefined;
|
|
2854
5619
|
const tonic = parsePitchClass(`${match[1]}${match[2] ?? ""}`);
|
|
5620
|
+
if (tonic === undefined) return undefined;
|
|
2855
5621
|
const word = (match[3] ?? "").trim();
|
|
2856
5622
|
const mode = MODE_ALIASES[word === "m" ? "m" : word.toLowerCase()];
|
|
2857
|
-
if (
|
|
2858
|
-
|
|
5623
|
+
if (mode !== undefined) return Object.freeze({ tonic, mode });
|
|
5624
|
+
const scale = scaleNamed(word);
|
|
5625
|
+
if (scale === undefined) return undefined;
|
|
5626
|
+
return Object.freeze({ tonic, mode: SCALES[scale].mode, scale });
|
|
5627
|
+
}
|
|
5628
|
+
|
|
5629
|
+
/**
|
|
5630
|
+
* Steps of the key's whole scale in semitones above the tonic (fractional
|
|
5631
|
+
* for quarter tones): the library scale when the key names one, else the
|
|
5632
|
+
* mode.
|
|
5633
|
+
*/
|
|
5634
|
+
function scaleSteps(key: Key): number[] {
|
|
5635
|
+
return [...(key.scale ? SCALES[key.scale].steps : MODES[key.mode])];
|
|
2859
5636
|
}
|
|
2860
5637
|
|
|
2861
5638
|
/** True when names in the key read better with flats (F, Bb, Eb, d minor…). */
|
|
@@ -2870,6 +5647,8 @@ function keyUsesFlats(key: Key): boolean {
|
|
|
2870
5647
|
minor: 9,
|
|
2871
5648
|
locrian: 11,
|
|
2872
5649
|
"harmonic-minor": 9,
|
|
5650
|
+
"melodic-minor": 9,
|
|
5651
|
+
"phrygian-dominant": 4,
|
|
2873
5652
|
};
|
|
2874
5653
|
const parent = mod12(key.tonic - parentOffset[key.mode]);
|
|
2875
5654
|
return [5, 10, 3, 8, 1].includes(parent);
|
|
@@ -2877,7 +5656,7 @@ function keyUsesFlats(key: Key): boolean {
|
|
|
2877
5656
|
|
|
2878
5657
|
/** `C major`, `F# dorian`, `Bb minor`. */
|
|
2879
5658
|
function keyName(key: Key): string {
|
|
2880
|
-
return `${noteName(key.tonic, keyUsesFlats(key))} ${key.mode}`;
|
|
5659
|
+
return `${noteName(key.tonic, keyUsesFlats(key))} ${key.scale ?? key.mode}`;
|
|
2881
5660
|
}
|
|
2882
5661
|
|
|
2883
5662
|
/** Pitch classes of the key's scale, tonic first. */
|