@hraness/dawg 0.6.1 → 0.8.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 +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +31 -0
- package/guides/audio.md +31 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tape.md +32 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +360 -40
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +82 -32
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +617 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +987 -179
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
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.34.0";
|
|
31
31
|
/** Major of `SDK_VERSION`; `dawg.json` records it as `sdk`. */
|
|
32
32
|
export const SDK_MAJOR = 1;
|
|
33
33
|
|
|
@@ -242,6 +242,15 @@ export type Expression = Readonly<{
|
|
|
242
242
|
* Humanize just bars 5-8 with `expr({ humanize: { timing: 10 } }, ...)`.
|
|
243
243
|
*/
|
|
244
244
|
humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
|
|
245
|
+
/** The sung vowel on a `sing()` track (SDK 1.32.0): `"a"` .. `"u"` or a morph `"a>o"`. */
|
|
246
|
+
vowel?: string;
|
|
247
|
+
/** The syllable sung on this note (SDK 1.32.0): no spaces, `_` holds the previous one. */
|
|
248
|
+
lyric?: string;
|
|
249
|
+
/**
|
|
250
|
+
* On an autotune guide note (SDK 1.33.0): the share of slow pitch drift
|
|
251
|
+
* removed, 0..1, overriding the track's `autotune` drift.
|
|
252
|
+
*/
|
|
253
|
+
drift?: number;
|
|
245
254
|
}>;
|
|
246
255
|
|
|
247
256
|
/** The expression a built note carries; fields are present only when set. */
|
|
@@ -251,6 +260,9 @@ export type NoteExpressionSpec = Readonly<{
|
|
|
251
260
|
bend?: readonly BendPoint[];
|
|
252
261
|
vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
|
|
253
262
|
humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
|
|
263
|
+
vowel?: string;
|
|
264
|
+
lyric?: string;
|
|
265
|
+
drift?: number;
|
|
254
266
|
}>;
|
|
255
267
|
|
|
256
268
|
const DEFAULT_VIBRATO_RATE = 5.5;
|
|
@@ -265,12 +277,20 @@ function expression(
|
|
|
265
277
|
throw new DawgSdkError(`${label} expression must be an object`);
|
|
266
278
|
for (const key of Object.keys(input))
|
|
267
279
|
if (
|
|
268
|
-
![
|
|
269
|
-
|
|
270
|
-
|
|
280
|
+
![
|
|
281
|
+
"articulation",
|
|
282
|
+
"art",
|
|
283
|
+
"glide",
|
|
284
|
+
"bend",
|
|
285
|
+
"vibrato",
|
|
286
|
+
"humanize",
|
|
287
|
+
"vowel",
|
|
288
|
+
"lyric",
|
|
289
|
+
"drift",
|
|
290
|
+
].includes(key)
|
|
271
291
|
)
|
|
272
292
|
throw new DawgSdkError(
|
|
273
|
-
`${label} expression has an unknown field "${key.slice(0, 32)}" (articulation glide bend vibrato humanize)`,
|
|
293
|
+
`${label} expression has an unknown field "${key.slice(0, 32)}" (articulation glide bend vibrato humanize vowel lyric drift)`,
|
|
274
294
|
);
|
|
275
295
|
const out: {
|
|
276
296
|
articulation?: Articulation;
|
|
@@ -282,6 +302,9 @@ function expression(
|
|
|
282
302
|
velocity?: number;
|
|
283
303
|
length?: number;
|
|
284
304
|
}>;
|
|
305
|
+
vowel?: string;
|
|
306
|
+
lyric?: string;
|
|
307
|
+
drift?: number;
|
|
285
308
|
} = {};
|
|
286
309
|
const articulation = input.articulation ?? input.art;
|
|
287
310
|
if (articulation !== undefined) {
|
|
@@ -352,9 +375,32 @@ function expression(
|
|
|
352
375
|
}
|
|
353
376
|
out.humanize = Object.freeze(amounts);
|
|
354
377
|
}
|
|
378
|
+
if (input.vowel !== undefined)
|
|
379
|
+
out.vowel = singVowel(input.vowel, `${label} vowel`);
|
|
380
|
+
if (input.lyric !== undefined) out.lyric = lyricInput(input.lyric, label);
|
|
381
|
+
if (input.drift !== undefined) {
|
|
382
|
+
const drift = finite(input.drift, `${label} drift`);
|
|
383
|
+
if (drift < 0 || drift > 1)
|
|
384
|
+
throw new DawgSdkError(`${label} drift must be 0..1`);
|
|
385
|
+
out.drift = drift;
|
|
386
|
+
}
|
|
355
387
|
return out;
|
|
356
388
|
}
|
|
357
389
|
|
|
390
|
+
/** A note's lyric: one syllable, no whitespace, at most 32 characters. */
|
|
391
|
+
function lyricInput(value: unknown, label: string): string {
|
|
392
|
+
if (
|
|
393
|
+
typeof value !== "string" ||
|
|
394
|
+
value.length === 0 ||
|
|
395
|
+
value.length > LYRIC_LIMIT ||
|
|
396
|
+
/\s/u.test(value)
|
|
397
|
+
)
|
|
398
|
+
throw new DawgSdkError(
|
|
399
|
+
`${label} lyric must be one syllable of 1..${LYRIC_LIMIT} characters without spaces`,
|
|
400
|
+
);
|
|
401
|
+
return value;
|
|
402
|
+
}
|
|
403
|
+
|
|
358
404
|
/**
|
|
359
405
|
* The same expression on many notes or hits (SDK 1.15.0); a note's own
|
|
360
406
|
* fields win.
|
|
@@ -1166,16 +1212,36 @@ export const DRUM_PATTERNS: readonly DrumPattern[] = Object.freeze([
|
|
|
1166
1212
|
euclid("hat", 16, 16, 0, { velocity: 0.35, accent: 0.4, accents: 4 }),
|
|
1167
1213
|
],
|
|
1168
1214
|
),
|
|
1215
|
+
// The blues and rock shuffle is a triplet-8th feel: each beat is three
|
|
1216
|
+
// 8th-note triplets with the first and third struck (long-short).
|
|
1169
1217
|
drumPattern(
|
|
1170
1218
|
"shuffle",
|
|
1171
|
-
"Shuffle",
|
|
1219
|
+
"Shuffle (triplet 8ths)",
|
|
1172
1220
|
["blues", "shuffle", "rock"],
|
|
1173
1221
|
[90, 130, 110],
|
|
1174
1222
|
"acoustic",
|
|
1223
|
+
0,
|
|
1224
|
+
[
|
|
1225
|
+
grid("kick", "x.....x.....", { division: "1/8t" }),
|
|
1226
|
+
grid("snare", "...x.....x..", { division: "1/8t" }),
|
|
1227
|
+
grid("hat", "X.xx.xX.xx.x", {
|
|
1228
|
+
division: "1/8t",
|
|
1229
|
+
velocity: 0.35,
|
|
1230
|
+
accent: 0.5,
|
|
1231
|
+
}),
|
|
1232
|
+
],
|
|
1233
|
+
),
|
|
1234
|
+
// The half-time shuffle swings 16ths against a backbeat on 3.
|
|
1235
|
+
drumPattern(
|
|
1236
|
+
"half-time-shuffle",
|
|
1237
|
+
"Half-time shuffle (swung 16ths)",
|
|
1238
|
+
["shuffle", "rock", "funk"],
|
|
1239
|
+
[70, 100, 86],
|
|
1240
|
+
"acoustic",
|
|
1175
1241
|
0.33,
|
|
1176
1242
|
[
|
|
1177
|
-
grid("kick", "x
|
|
1178
|
-
grid("snare", "
|
|
1243
|
+
grid("kick", "x.........x....."),
|
|
1244
|
+
grid("snare", "........x......."),
|
|
1179
1245
|
euclid("hat", 16, 16, 0, { velocity: 0.35, accent: 0.5, accents: 8 }),
|
|
1180
1246
|
],
|
|
1181
1247
|
),
|
|
@@ -1218,7 +1284,7 @@ export function pattern(name: string): readonly RhythmSpec[] {
|
|
|
1218
1284
|
const found = findPattern(name);
|
|
1219
1285
|
if (!found)
|
|
1220
1286
|
throw new DawgSdkError(
|
|
1221
|
-
`unknown
|
|
1287
|
+
`unknown groove "${String(name).slice(0, 40)}" (${DRUM_PATTERNS.map((entry) => entry.name).join(" ")})`,
|
|
1222
1288
|
);
|
|
1223
1289
|
return found.rows;
|
|
1224
1290
|
}
|
|
@@ -1989,6 +2055,226 @@ export function wind(
|
|
|
1989
2055
|
return Object.freeze(out) as WindSpec;
|
|
1990
2056
|
}
|
|
1991
2057
|
|
|
2058
|
+
// ---- vocoder (f07-vocoder, SDK 1.32.0) ----
|
|
2059
|
+
|
|
2060
|
+
/** The instrument value of the built-in vocoder carrier (core/vocoder.ts). */
|
|
2061
|
+
export const VOCODER_INSTRUMENT = "vocoder";
|
|
2062
|
+
|
|
2063
|
+
/** Vocoder presets (core/vocoder.ts VOCODER_PRESET_NAMES). */
|
|
2064
|
+
export type VocoderPresetName =
|
|
2065
|
+
| "classic"
|
|
2066
|
+
| "robot"
|
|
2067
|
+
| "talkbox"
|
|
2068
|
+
| "choir"
|
|
2069
|
+
| "glass"
|
|
2070
|
+
| "whisper"
|
|
2071
|
+
| "smear"
|
|
2072
|
+
| "lofi";
|
|
2073
|
+
|
|
2074
|
+
const VOCODER_PRESET_WORDS: readonly string[] = Object.freeze([
|
|
2075
|
+
"classic",
|
|
2076
|
+
"robot",
|
|
2077
|
+
"talkbox",
|
|
2078
|
+
"choir",
|
|
2079
|
+
"glass",
|
|
2080
|
+
"whisper",
|
|
2081
|
+
"smear",
|
|
2082
|
+
"lofi",
|
|
2083
|
+
]);
|
|
2084
|
+
|
|
2085
|
+
/** Vocoder overrides (core/vocoder.ts VOCODER_PARAMS). */
|
|
2086
|
+
export type VocoderParams = Readonly<{
|
|
2087
|
+
/** The modulator track: an id or a name slug (`"vox"`, `"lead-vox"`). */
|
|
2088
|
+
src?: string;
|
|
2089
|
+
tap?: "chain" | "dry";
|
|
2090
|
+
mode?: "channel" | "talkbox";
|
|
2091
|
+
carrier?: "saw" | "supersaw" | "pulse" | "noise";
|
|
2092
|
+
follow?: "notes" | "chords" | "drone";
|
|
2093
|
+
root?: number;
|
|
2094
|
+
spread?: number;
|
|
2095
|
+
bands?: number;
|
|
2096
|
+
lo?: number;
|
|
2097
|
+
hi?: number;
|
|
2098
|
+
width?: number;
|
|
2099
|
+
attack?: number;
|
|
2100
|
+
release?: number;
|
|
2101
|
+
formant?: number;
|
|
2102
|
+
unvoiced?: number;
|
|
2103
|
+
sens?: number;
|
|
2104
|
+
hiss?: number;
|
|
2105
|
+
gate?: number | "auto";
|
|
2106
|
+
enhance?: boolean;
|
|
2107
|
+
depth?: number;
|
|
2108
|
+
freeze?: boolean;
|
|
2109
|
+
mix?: number;
|
|
2110
|
+
gain?: number;
|
|
2111
|
+
seed?: number;
|
|
2112
|
+
}>;
|
|
2113
|
+
|
|
2114
|
+
const VOCODER_ENUMS: Readonly<Record<string, readonly string[]>> =
|
|
2115
|
+
Object.freeze({
|
|
2116
|
+
tap: Object.freeze(["chain", "dry"]),
|
|
2117
|
+
mode: Object.freeze(["channel", "talkbox"]),
|
|
2118
|
+
carrier: Object.freeze(["saw", "supersaw", "pulse", "noise"]),
|
|
2119
|
+
follow: Object.freeze(["notes", "chords", "drone"]),
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
const VOCODER_RANGES: Readonly<Record<string, readonly [number, number]>> =
|
|
2123
|
+
Object.freeze({
|
|
2124
|
+
root: [24, 96],
|
|
2125
|
+
spread: [0, 1],
|
|
2126
|
+
bands: [4, 40],
|
|
2127
|
+
lo: [50, 1000],
|
|
2128
|
+
hi: [2000, 12000],
|
|
2129
|
+
width: [0.25, 4],
|
|
2130
|
+
attack: [0.0005, 0.2],
|
|
2131
|
+
release: [0.005, 2],
|
|
2132
|
+
formant: [-24, 24],
|
|
2133
|
+
unvoiced: [0, 1],
|
|
2134
|
+
sens: [0, 1],
|
|
2135
|
+
hiss: [0, 1],
|
|
2136
|
+
gate: [-90, 0],
|
|
2137
|
+
depth: [0, 1],
|
|
2138
|
+
mix: [0, 1],
|
|
2139
|
+
gain: [-24, 24],
|
|
2140
|
+
seed: [0, 2 ** 31],
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
const VOCODER_INTEGERS: readonly string[] = Object.freeze([
|
|
2144
|
+
"root",
|
|
2145
|
+
"bands",
|
|
2146
|
+
"seed",
|
|
2147
|
+
]);
|
|
2148
|
+
|
|
2149
|
+
/** Result of `vocoder()`: a track's `instrument` or its `vocoder` field. */
|
|
2150
|
+
export type VocoderSpec = Readonly<
|
|
2151
|
+
{ kind: "vocoder"; preset?: VocoderPresetName } & VocoderParams
|
|
2152
|
+
>;
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* A vocoder (SDK 1.32.0): another track's voice (`src`) shapes this track's
|
|
2156
|
+
* sound. As the `instrument` it plays the built-in carrier (a supersaw
|
|
2157
|
+
* following the notes); as the `vocoder` field it vocodes the track's own
|
|
2158
|
+
* synth or sampler. `src` takes a track id or a name slug.
|
|
2159
|
+
*
|
|
2160
|
+
* ```ts
|
|
2161
|
+
* instrument: vocoder("talkbox", { src: "vox", formant: 2 })
|
|
2162
|
+
* vocoder: vocoder({ src: "t-vox", bands: 24 })
|
|
2163
|
+
* ```
|
|
2164
|
+
*/
|
|
2165
|
+
export function vocoder(
|
|
2166
|
+
preset?: VocoderPresetName | VocoderParams,
|
|
2167
|
+
params: VocoderParams = {},
|
|
2168
|
+
): VocoderSpec {
|
|
2169
|
+
const overrides = isRecord(preset) ? preset : params;
|
|
2170
|
+
const name = isRecord(preset) ? undefined : preset;
|
|
2171
|
+
if (!isRecord(overrides))
|
|
2172
|
+
throw new DawgSdkError("vocoder params must be an object");
|
|
2173
|
+
const out: Record<string, unknown> = { kind: "vocoder" };
|
|
2174
|
+
if (name !== undefined) {
|
|
2175
|
+
if (typeof name !== "string" || !VOCODER_PRESET_WORDS.includes(name))
|
|
2176
|
+
throw new DawgSdkError(
|
|
2177
|
+
`vocoder preset "${String(name).slice(0, 32)}" is not one of ${VOCODER_PRESET_WORDS.join(" ")}`,
|
|
2178
|
+
);
|
|
2179
|
+
out.preset = name;
|
|
2180
|
+
}
|
|
2181
|
+
for (const key of Object.keys(overrides)) {
|
|
2182
|
+
const value = (overrides as Record<string, unknown>)[key];
|
|
2183
|
+
if (value === undefined) continue;
|
|
2184
|
+
const words = VOCODER_ENUMS[key];
|
|
2185
|
+
if (key === "src") {
|
|
2186
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 64)
|
|
2187
|
+
throw new DawgSdkError("vocoder src must be a track id or name slug");
|
|
2188
|
+
out.src = value;
|
|
2189
|
+
} else if (words) {
|
|
2190
|
+
if (typeof value !== "string" || !words.includes(value))
|
|
2191
|
+
throw new DawgSdkError(
|
|
2192
|
+
`vocoder ${key} "${String(value).slice(0, 32)}" is not one of ${words.join(" ")}`,
|
|
2193
|
+
);
|
|
2194
|
+
out[key] = value;
|
|
2195
|
+
} else if (key === "enhance" || key === "freeze") {
|
|
2196
|
+
if (typeof value !== "boolean")
|
|
2197
|
+
throw new DawgSdkError(`vocoder ${key} must be true or false`);
|
|
2198
|
+
out[key] = value;
|
|
2199
|
+
} else if (key === "gate" && value === "auto") {
|
|
2200
|
+
out.gate = "auto";
|
|
2201
|
+
} else if (VOCODER_RANGES[key]) {
|
|
2202
|
+
const number = finite(value, `vocoder ${key}`);
|
|
2203
|
+
const [min, max] = VOCODER_RANGES[key]!;
|
|
2204
|
+
if (number < min || number > max)
|
|
2205
|
+
throw new DawgSdkError(`vocoder ${key} must be ${min}..${max}`);
|
|
2206
|
+
if (VOCODER_INTEGERS.includes(key) && !Number.isInteger(number))
|
|
2207
|
+
throw new DawgSdkError(`vocoder ${key} must be a whole number`);
|
|
2208
|
+
out[key] = number;
|
|
2209
|
+
} else
|
|
2210
|
+
throw new DawgSdkError(
|
|
2211
|
+
`vocoder has no parameter "${key.slice(0, 32)}" (src ${[...Object.keys(VOCODER_ENUMS), "enhance", "freeze", ...Object.keys(VOCODER_RANGES)].join(" ")})`,
|
|
2212
|
+
);
|
|
2213
|
+
}
|
|
2214
|
+
return Object.freeze(out) as VocoderSpec;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/**
|
|
2218
|
+
* The vocoder field a track gets: its `vocoder` property, else a
|
|
2219
|
+
* `vocoder(...)` instrument, else `{}` for the bare word `"vocoder"` (the
|
|
2220
|
+
* built-in carrier with every default). `null` removes it.
|
|
2221
|
+
*/
|
|
2222
|
+
function trackVocoder(
|
|
2223
|
+
rawInstrument: unknown,
|
|
2224
|
+
field: unknown,
|
|
2225
|
+
): Readonly<{ preset?: VocoderPresetName } & VocoderParams> | undefined {
|
|
2226
|
+
const strip = (spec: unknown) => {
|
|
2227
|
+
if (!isRecord(spec) || spec.kind !== "vocoder")
|
|
2228
|
+
throw new DawgSdkError("track vocoder must come from vocoder()");
|
|
2229
|
+
const { kind: _kind, ...fields } = spec as VocoderSpec;
|
|
2230
|
+
return Object.freeze(fields);
|
|
2231
|
+
};
|
|
2232
|
+
if (field !== undefined && field !== null) return strip(field);
|
|
2233
|
+
if (isRecord(rawInstrument) && rawInstrument.kind === "vocoder")
|
|
2234
|
+
return strip(rawInstrument);
|
|
2235
|
+
if (rawInstrument === VOCODER_INSTRUMENT) return Object.freeze({});
|
|
2236
|
+
return undefined;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
/** Lowercase dash slug of a track name (core/slug.ts trackSlug). */
|
|
2240
|
+
function vocoderSlug(text: string): string {
|
|
2241
|
+
const slug = text
|
|
2242
|
+
.normalize("NFKD")
|
|
2243
|
+
.replace(/\p{M}+/gu, "")
|
|
2244
|
+
.toLowerCase()
|
|
2245
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
2246
|
+
.replace(/^-+|-+$/g, "")
|
|
2247
|
+
.slice(0, 64)
|
|
2248
|
+
.replace(/-+$/g, "");
|
|
2249
|
+
return slug.length > 0 ? slug : "track";
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/**
|
|
2253
|
+
* `src` as a track id: an id wins, then a unique name slug. Errors when
|
|
2254
|
+
* nothing matches, two tracks share the slug, or the track names itself.
|
|
2255
|
+
*/
|
|
2256
|
+
function resolveVocoderSrc(
|
|
2257
|
+
tracks: readonly Readonly<{ id: string; name: string }>[],
|
|
2258
|
+
self: string,
|
|
2259
|
+
src: string,
|
|
2260
|
+
): string {
|
|
2261
|
+
const byId = tracks.find((track) => track.id === src);
|
|
2262
|
+
const matches = byId
|
|
2263
|
+
? [byId]
|
|
2264
|
+
: tracks.filter((track) => vocoderSlug(track.name) === vocoderSlug(src));
|
|
2265
|
+
if (matches.length === 0)
|
|
2266
|
+
throw new DawgSdkError(
|
|
2267
|
+
`track ${self}: vocoder src "${src.slice(0, 64)}" names no track; delete src (the carrier plays alone) or point it at a track in this song`,
|
|
2268
|
+
);
|
|
2269
|
+
if (matches.length > 1)
|
|
2270
|
+
throw new DawgSdkError(
|
|
2271
|
+
`track ${self}: vocoder src "${src.slice(0, 64)}" matches ${matches.length} tracks; use an id`,
|
|
2272
|
+
);
|
|
2273
|
+
if (matches[0]!.id === self)
|
|
2274
|
+
throw new DawgSdkError(`track ${self}: a track cannot vocode itself`);
|
|
2275
|
+
return matches[0]!.id;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
1992
2278
|
/**
|
|
1993
2279
|
* The wind field an instrument makes: `wind(...)`, or a wind preset word
|
|
1994
2280
|
* (`"flute"`, `"saxophone"`). The bare word `"wind"` keeps its pre-0.6.1
|
|
@@ -2008,6 +2294,354 @@ function trackWind(
|
|
|
2008
2294
|
return Object.freeze({ preset: meaning.preset as WindPresetName });
|
|
2009
2295
|
}
|
|
2010
2296
|
|
|
2297
|
+
// ---- sing (f07-sing, SDK 1.32.0) ----
|
|
2298
|
+
|
|
2299
|
+
/** The instrument value of the singing voice (core/sing.ts). */
|
|
2300
|
+
export const SING_INSTRUMENT = "sing";
|
|
2301
|
+
|
|
2302
|
+
/** Sing presets (core/sing.ts SING_PRESET_NAMES). */
|
|
2303
|
+
export type SingPresetName =
|
|
2304
|
+
| "aah"
|
|
2305
|
+
| "ooh"
|
|
2306
|
+
| "choir"
|
|
2307
|
+
| "oohchoir"
|
|
2308
|
+
| "chorale"
|
|
2309
|
+
| "airy"
|
|
2310
|
+
| "glass"
|
|
2311
|
+
| "lament"
|
|
2312
|
+
| "soprano"
|
|
2313
|
+
| "basso"
|
|
2314
|
+
| "drone"
|
|
2315
|
+
| "khoomei"
|
|
2316
|
+
| "sygyt"
|
|
2317
|
+
| "kargyraa";
|
|
2318
|
+
|
|
2319
|
+
const SING_PRESET_WORDS: readonly string[] = Object.freeze([
|
|
2320
|
+
"aah",
|
|
2321
|
+
"ooh",
|
|
2322
|
+
"choir",
|
|
2323
|
+
"oohchoir",
|
|
2324
|
+
"chorale",
|
|
2325
|
+
"airy",
|
|
2326
|
+
"glass",
|
|
2327
|
+
"lament",
|
|
2328
|
+
"soprano",
|
|
2329
|
+
"basso",
|
|
2330
|
+
"drone",
|
|
2331
|
+
"khoomei",
|
|
2332
|
+
"sygyt",
|
|
2333
|
+
"kargyraa",
|
|
2334
|
+
]);
|
|
2335
|
+
|
|
2336
|
+
/** Sing overrides (core/sing.ts SING_PARAMS). */
|
|
2337
|
+
export type SingParams = Readonly<{
|
|
2338
|
+
voice?: "auto" | "soprano" | "alto" | "tenor" | "bass";
|
|
2339
|
+
/** `"a"`, `"e"`, `"i"`, `"o"`, `"u"` or a morph `"a>o"`. */
|
|
2340
|
+
vowel?: string;
|
|
2341
|
+
morph?: number;
|
|
2342
|
+
formant?: number;
|
|
2343
|
+
bright?: number;
|
|
2344
|
+
breath?: number;
|
|
2345
|
+
jitter?: number;
|
|
2346
|
+
shimmer?: number;
|
|
2347
|
+
attack?: number;
|
|
2348
|
+
release?: number;
|
|
2349
|
+
vib?: number;
|
|
2350
|
+
vibmod?: number;
|
|
2351
|
+
vibdelay?: number;
|
|
2352
|
+
voices?: number;
|
|
2353
|
+
spread?: number;
|
|
2354
|
+
ring?: number;
|
|
2355
|
+
/** Throat drone: a note name (`"D3"`) or MIDI 36..67. */
|
|
2356
|
+
drone?: string | number;
|
|
2357
|
+
overtone?: number;
|
|
2358
|
+
/** Throat melody harmonic range `[lo, hi]`, 2..24. */
|
|
2359
|
+
harmonics?: readonly [number, number];
|
|
2360
|
+
sub?: number;
|
|
2361
|
+
gain?: number;
|
|
2362
|
+
}>;
|
|
2363
|
+
|
|
2364
|
+
const SING_VOWEL_LETTERS: readonly string[] = Object.freeze([
|
|
2365
|
+
"a",
|
|
2366
|
+
"e",
|
|
2367
|
+
"i",
|
|
2368
|
+
"o",
|
|
2369
|
+
"u",
|
|
2370
|
+
]);
|
|
2371
|
+
|
|
2372
|
+
/** Numeric ranges (core/sing.ts SING_PARAMS min..max). */
|
|
2373
|
+
const SING_RANGES: Readonly<Record<string, readonly [number, number]>> =
|
|
2374
|
+
Object.freeze({
|
|
2375
|
+
morph: [0, 1],
|
|
2376
|
+
formant: [-12, 12],
|
|
2377
|
+
bright: [0, 1],
|
|
2378
|
+
breath: [0, 1],
|
|
2379
|
+
jitter: [0, 3],
|
|
2380
|
+
shimmer: [0, 1],
|
|
2381
|
+
attack: [0.005, 2],
|
|
2382
|
+
release: [0.01, 4],
|
|
2383
|
+
vib: [0, 9],
|
|
2384
|
+
vibmod: [0, 1],
|
|
2385
|
+
vibdelay: [0, 2],
|
|
2386
|
+
voices: [1, 8],
|
|
2387
|
+
spread: [0, 40],
|
|
2388
|
+
ring: [0, 1],
|
|
2389
|
+
overtone: [0, 1],
|
|
2390
|
+
sub: [0, 1],
|
|
2391
|
+
gain: [0, 2],
|
|
2392
|
+
});
|
|
2393
|
+
|
|
2394
|
+
/** Result of `sing()`; pass it as a track's `instrument`. */
|
|
2395
|
+
export type SingSpec = Readonly<
|
|
2396
|
+
{ kind: "sing"; preset?: SingPresetName } & SingParams
|
|
2397
|
+
>;
|
|
2398
|
+
|
|
2399
|
+
/** `"a"` or `"a>o"`, lower-cased; throws otherwise. */
|
|
2400
|
+
function singVowel(value: unknown, label: string): string {
|
|
2401
|
+
const parts =
|
|
2402
|
+
typeof value === "string" ? value.trim().toLowerCase().split(">") : [];
|
|
2403
|
+
if (
|
|
2404
|
+
(parts.length === 1 || parts.length === 2) &&
|
|
2405
|
+
parts.every((part) => SING_VOWEL_LETTERS.includes(part))
|
|
2406
|
+
)
|
|
2407
|
+
return parts.join(">");
|
|
2408
|
+
throw new DawgSdkError(
|
|
2409
|
+
`${label} must be one of ${SING_VOWEL_LETTERS.join(" ")} or a morph like a>o`,
|
|
2410
|
+
);
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/**
|
|
2414
|
+
* The built-in singing voice (SDK 1.32.0): an LF glottal source through
|
|
2415
|
+
* SATB formants, choirs, and Tuvan throat singing. A preset word alone
|
|
2416
|
+
* (`instrument: "choir"`) is the same as `sing("choir")`. Notes sing their
|
|
2417
|
+
* `vowel` (`note("A3", 0, 2, 0.8, { vowel: "a>o" })`), else their lyric's
|
|
2418
|
+
* vowel, else the track's.
|
|
2419
|
+
*
|
|
2420
|
+
* ```ts
|
|
2421
|
+
* instrument: sing("choir", { vowel: "o" })
|
|
2422
|
+
* instrument: sing("khoomei", { drone: "D3" }) // notes pick the overtone
|
|
2423
|
+
* instrument: sing({ voices: 4, breath: 0.3 }) // default preset (aah)
|
|
2424
|
+
* ```
|
|
2425
|
+
*/
|
|
2426
|
+
export function sing(
|
|
2427
|
+
preset?: SingPresetName | SingParams,
|
|
2428
|
+
params: SingParams = {},
|
|
2429
|
+
): SingSpec {
|
|
2430
|
+
const overrides = isRecord(preset) ? preset : params;
|
|
2431
|
+
const name = isRecord(preset) ? undefined : preset;
|
|
2432
|
+
if (!isRecord(overrides))
|
|
2433
|
+
throw new DawgSdkError("sing params must be an object");
|
|
2434
|
+
const out: Record<string, unknown> = { kind: "sing" };
|
|
2435
|
+
if (name !== undefined) {
|
|
2436
|
+
if (typeof name !== "string" || !SING_PRESET_WORDS.includes(name))
|
|
2437
|
+
throw new DawgSdkError(
|
|
2438
|
+
`sing preset "${String(name).slice(0, 32)}" is not one of ${SING_PRESET_WORDS.join(" ")}`,
|
|
2439
|
+
);
|
|
2440
|
+
out.preset = name;
|
|
2441
|
+
}
|
|
2442
|
+
for (const key of Object.keys(overrides)) {
|
|
2443
|
+
const value = (overrides as Record<string, unknown>)[key];
|
|
2444
|
+
if (value === undefined) continue;
|
|
2445
|
+
if (key === "voice") {
|
|
2446
|
+
const voices = ["auto", "soprano", "alto", "tenor", "bass"];
|
|
2447
|
+
if (typeof value !== "string" || !voices.includes(value))
|
|
2448
|
+
throw new DawgSdkError(`sing voice must be one of ${voices.join(" ")}`);
|
|
2449
|
+
out.voice = value;
|
|
2450
|
+
} else if (key === "vowel") out.vowel = singVowel(value, "sing vowel");
|
|
2451
|
+
else if (key === "drone") {
|
|
2452
|
+
const midiValue =
|
|
2453
|
+
typeof value === "number" ? value : midi(value as Pitch);
|
|
2454
|
+
if (!Number.isInteger(midiValue) || midiValue < 36 || midiValue > 67)
|
|
2455
|
+
throw new DawgSdkError(
|
|
2456
|
+
'sing drone must be a note name C2..G4 (e.g. "D3") or MIDI 36..67',
|
|
2457
|
+
);
|
|
2458
|
+
out.drone = midiValue;
|
|
2459
|
+
} else if (key === "harmonics") {
|
|
2460
|
+
if (
|
|
2461
|
+
!Array.isArray(value) ||
|
|
2462
|
+
value.length !== 2 ||
|
|
2463
|
+
!value.every((h) => Number.isInteger(h) && h >= 2 && h <= 24) ||
|
|
2464
|
+
value[0] >= value[1]
|
|
2465
|
+
)
|
|
2466
|
+
throw new DawgSdkError(
|
|
2467
|
+
"sing harmonics must be [lo, hi], whole numbers 2..24 with lo < hi",
|
|
2468
|
+
);
|
|
2469
|
+
out.harmonics = Object.freeze([value[0], value[1]]);
|
|
2470
|
+
} else if (SING_RANGES[key]) {
|
|
2471
|
+
const number = finite(value, `sing ${key}`);
|
|
2472
|
+
const [min, max] = SING_RANGES[key]!;
|
|
2473
|
+
if (number < min || number > max)
|
|
2474
|
+
throw new DawgSdkError(`sing ${key} must be ${min}..${max}`);
|
|
2475
|
+
if (key === "voices" && !Number.isInteger(number))
|
|
2476
|
+
throw new DawgSdkError("sing voices must be a whole number");
|
|
2477
|
+
out[key] = number;
|
|
2478
|
+
} else
|
|
2479
|
+
throw new DawgSdkError(
|
|
2480
|
+
`sing has no parameter "${key.slice(0, 32)}" (voice vowel drone harmonics ${Object.keys(SING_RANGES).join(" ")})`,
|
|
2481
|
+
);
|
|
2482
|
+
}
|
|
2483
|
+
return Object.freeze(out) as SingSpec;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* The sing field an instrument makes: `sing(...)`, or a sing preset word
|
|
2488
|
+
* (`"choir"`, `"khoomei"`); `"sing"` alone is the default preset.
|
|
2489
|
+
*/
|
|
2490
|
+
function trackSing(
|
|
2491
|
+
raw: unknown,
|
|
2492
|
+
): Readonly<{ preset?: SingPresetName } & SingParams> | undefined {
|
|
2493
|
+
if (isRecord(raw) && raw.kind === "sing") {
|
|
2494
|
+
const { kind: _kind, ...fields } = raw as SingSpec;
|
|
2495
|
+
return Object.freeze(fields);
|
|
2496
|
+
}
|
|
2497
|
+
if (typeof raw !== "string") return undefined;
|
|
2498
|
+
const meaning = resolveInstrumentWord(raw);
|
|
2499
|
+
if (meaning?.instrument !== SING_INSTRUMENT) return undefined;
|
|
2500
|
+
return Object.freeze(
|
|
2501
|
+
meaning.preset ? { preset: meaning.preset as SingPresetName } : {},
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// ---- autotune (f07-autotune, SDK 1.33.0) ----
|
|
2506
|
+
|
|
2507
|
+
/** Autotune presets, gentle to hard (core/autotune.ts AUTOTUNE_PRESETS). */
|
|
2508
|
+
export type AutotunePresetName =
|
|
2509
|
+
| "hard"
|
|
2510
|
+
| "robot"
|
|
2511
|
+
| "warble"
|
|
2512
|
+
| "trap"
|
|
2513
|
+
| "pop"
|
|
2514
|
+
| "natural"
|
|
2515
|
+
| "gentle"
|
|
2516
|
+
| "guided"
|
|
2517
|
+
| "locked";
|
|
2518
|
+
|
|
2519
|
+
const AUTOTUNE_PRESET_WORDS: readonly string[] = Object.freeze([
|
|
2520
|
+
"hard",
|
|
2521
|
+
"robot",
|
|
2522
|
+
"warble",
|
|
2523
|
+
"trap",
|
|
2524
|
+
"pop",
|
|
2525
|
+
"natural",
|
|
2526
|
+
"gentle",
|
|
2527
|
+
"guided",
|
|
2528
|
+
"locked",
|
|
2529
|
+
]);
|
|
2530
|
+
|
|
2531
|
+
/** Autotune fields; dawg checks the ranges (core/autotune.ts AUTOTUNE_PARAMS). */
|
|
2532
|
+
export type AutotuneParams = Readonly<{
|
|
2533
|
+
/** Targets: `scale` (song or track key, else chromatic), `chromatic`, `chord`, `notes`. */
|
|
2534
|
+
to?: "scale" | "chromatic" | "chord" | "notes";
|
|
2535
|
+
/** Guide track id for `to: "notes"` (a vocal track may use its own notes). */
|
|
2536
|
+
from?: string;
|
|
2537
|
+
/** Scale for this track, e.g. `"D bayati"`; default the song key. */
|
|
2538
|
+
key?: string;
|
|
2539
|
+
/** Retune time in ms, 0..400; 0 is instant and stepped. */
|
|
2540
|
+
speed?: number;
|
|
2541
|
+
/** 0..1 slower retune on held notes. */
|
|
2542
|
+
relax?: number;
|
|
2543
|
+
/** ms 50..1000 before a note counts as held. */
|
|
2544
|
+
hold?: number;
|
|
2545
|
+
/** 0..100: higher only pulls notes already near a target. */
|
|
2546
|
+
flex?: number;
|
|
2547
|
+
/** Seconds 0..0.5 to move between targets. */
|
|
2548
|
+
glide?: number;
|
|
2549
|
+
/** 0..1 correction strength. */
|
|
2550
|
+
amount?: number;
|
|
2551
|
+
/** Added vibrato rate in Hz, 0..12 (0 off). */
|
|
2552
|
+
vib?: number;
|
|
2553
|
+
/** Added vibrato depth in semitones, 0..1. */
|
|
2554
|
+
vibmod?: number;
|
|
2555
|
+
/** Notes mode: 0..1 how far each note's middle moves to the written pitch. */
|
|
2556
|
+
center?: number;
|
|
2557
|
+
/** Notes mode: 0..1 share of slow drift removed. */
|
|
2558
|
+
drift?: number;
|
|
2559
|
+
/** Tracker range: `auto`, `bass`, `tenor`, `alto`, `soprano`. */
|
|
2560
|
+
voice?: "auto" | "bass" | "tenor" | "alto" | "soprano";
|
|
2561
|
+
}>;
|
|
2562
|
+
|
|
2563
|
+
/** Stored autotune settings: a preset and any fields that override it. */
|
|
2564
|
+
export type AutotuneSettings = Readonly<
|
|
2565
|
+
{ preset?: AutotunePresetName } & AutotuneParams
|
|
2566
|
+
>;
|
|
2567
|
+
|
|
2568
|
+
/** Result of `autotune()`; pass it as a track's `autotune`. */
|
|
2569
|
+
export type AutotuneSpec = Readonly<{ kind: "autotune" } & AutotuneSettings>;
|
|
2570
|
+
|
|
2571
|
+
const AUTOTUNE_PARAM_KEYS: readonly string[] = Object.freeze([
|
|
2572
|
+
"to",
|
|
2573
|
+
"from",
|
|
2574
|
+
"key",
|
|
2575
|
+
"speed",
|
|
2576
|
+
"relax",
|
|
2577
|
+
"hold",
|
|
2578
|
+
"flex",
|
|
2579
|
+
"glide",
|
|
2580
|
+
"amount",
|
|
2581
|
+
"vib",
|
|
2582
|
+
"vibmod",
|
|
2583
|
+
"center",
|
|
2584
|
+
"drift",
|
|
2585
|
+
"voice",
|
|
2586
|
+
]);
|
|
2587
|
+
|
|
2588
|
+
/**
|
|
2589
|
+
* Pitch correction (SDK 1.33.0), from `gentle` to `hard`; the preset word
|
|
2590
|
+
* alone also works as a track's `autotune`. Fields override the preset.
|
|
2591
|
+
*
|
|
2592
|
+
* autotune: "hard"
|
|
2593
|
+
* autotune: autotune("pop", { speed: 40, key: "D bayati" })
|
|
2594
|
+
* autotune: autotune("guided", { from: "lead" })
|
|
2595
|
+
*/
|
|
2596
|
+
export function autotune(
|
|
2597
|
+
preset?: AutotunePresetName | AutotuneParams,
|
|
2598
|
+
params?: AutotuneParams,
|
|
2599
|
+
): AutotuneSpec {
|
|
2600
|
+
const fields =
|
|
2601
|
+
typeof preset === "object" && preset !== null ? preset : (params ?? {});
|
|
2602
|
+
const name = typeof preset === "string" ? preset : undefined;
|
|
2603
|
+
return Object.freeze({
|
|
2604
|
+
kind: "autotune" as const,
|
|
2605
|
+
...autotuneInput(
|
|
2606
|
+
{ ...(name ? { preset: name } : {}), ...fields },
|
|
2607
|
+
"autotune",
|
|
2608
|
+
),
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
function autotuneInput(raw: unknown, where: string): AutotuneSettings {
|
|
2613
|
+
if (typeof raw === "string") raw = { preset: raw };
|
|
2614
|
+
if (!isRecord(raw))
|
|
2615
|
+
throw new DawgSdkError(
|
|
2616
|
+
`${where} autotune must be a preset word or autotune()`,
|
|
2617
|
+
);
|
|
2618
|
+
const out: Record<string, unknown> = {};
|
|
2619
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
2620
|
+
if (key === "kind" || value === undefined) continue;
|
|
2621
|
+
if (key === "preset") {
|
|
2622
|
+
if (typeof value !== "string" || !AUTOTUNE_PRESET_WORDS.includes(value))
|
|
2623
|
+
throw new DawgSdkError(
|
|
2624
|
+
`${where} autotune preset "${String(value).slice(0, 32)}" is not one of ${AUTOTUNE_PRESET_WORDS.join(" ")}`,
|
|
2625
|
+
);
|
|
2626
|
+
out.preset = value;
|
|
2627
|
+
} else if (!AUTOTUNE_PARAM_KEYS.includes(key)) {
|
|
2628
|
+
throw new DawgSdkError(
|
|
2629
|
+
`${where} autotune has no field "${key.slice(0, 32)}" (preset ${AUTOTUNE_PARAM_KEYS.join(" ")})`,
|
|
2630
|
+
);
|
|
2631
|
+
} else if (["to", "from", "key", "voice"].includes(key)) {
|
|
2632
|
+
if (typeof value !== "string")
|
|
2633
|
+
throw new DawgSdkError(`${where} autotune ${key} must be a string`);
|
|
2634
|
+
out[key] = value;
|
|
2635
|
+
} else out[key] = finite(value, `${where} autotune ${key}`);
|
|
2636
|
+
}
|
|
2637
|
+
if (Object.keys(out).length === 0)
|
|
2638
|
+
throw new DawgSdkError(`${where} autotune needs a preset or fields`);
|
|
2639
|
+
const ordered: Record<string, unknown> = {};
|
|
2640
|
+
for (const key of ["preset", ...AUTOTUNE_PARAM_KEYS])
|
|
2641
|
+
if (out[key] !== undefined) ordered[key] = out[key];
|
|
2642
|
+
return Object.freeze(ordered) as AutotuneSettings;
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2011
2645
|
/**
|
|
2012
2646
|
* `count` equal slices of one file as voices `prefix0 … prefixN-1`, for
|
|
2013
2647
|
* chopped breaks: `sampler(slices("samples/break.wav", 8, "brk"))`, then
|
|
@@ -2645,12 +3279,25 @@ export type TrackInput = Readonly<{
|
|
|
2645
3279
|
| StringSpec
|
|
2646
3280
|
| GranularSpec
|
|
2647
3281
|
| ModalSpec
|
|
2648
|
-
| WindSpec
|
|
3282
|
+
| WindSpec
|
|
3283
|
+
| SingSpec
|
|
3284
|
+
| VocoderSpec;
|
|
3285
|
+
/**
|
|
3286
|
+
* A vocoder on this track (SDK 1.32.0): `vocoder({ src: "vox" })` lets
|
|
3287
|
+
* the vox track's voice shape this track's sound; `null` removes it.
|
|
3288
|
+
*/
|
|
3289
|
+
vocoder?: VocoderSpec | null;
|
|
2649
3290
|
/**
|
|
2650
3291
|
* The sampler a `granular(...)` track keeps while it grains one of its
|
|
2651
3292
|
* voices (SDK 1.23.0); `grain off` plays it again.
|
|
2652
3293
|
*/
|
|
2653
3294
|
sampler?: SamplerSpec | null;
|
|
3295
|
+
/**
|
|
3296
|
+
* The `wavetable(...)` a track keeps after it left the wavetable
|
|
3297
|
+
* instrument (SDK 1.32.0), so switching back restores it. A wavetable
|
|
3298
|
+
* track takes its table from `instrument` instead.
|
|
3299
|
+
*/
|
|
3300
|
+
wavetable?: WavetableSpec | null;
|
|
2654
3301
|
/**
|
|
2655
3302
|
* Granular engine (SDK 1.23.0) for an `instrument: "granular"` track, or
|
|
2656
3303
|
* use `instrument: granular("cloud", {...})` or a word (`"cloud"`).
|
|
@@ -2677,6 +3324,19 @@ export type TrackInput = Readonly<{
|
|
|
2677
3324
|
* `{ ref: 432 }` alone keeps the song's table at another pitch.
|
|
2678
3325
|
*/
|
|
2679
3326
|
tuning?: TuningInput | null;
|
|
3327
|
+
/**
|
|
3328
|
+
* Wind engine settings as a field (SDK 1.32.0): a preset word or
|
|
3329
|
+
* `{ preset, ...params }`, the same as `instrument: wind(...)`. Use with
|
|
3330
|
+
* `instrument` omitted or `"wind"`.
|
|
3331
|
+
*/
|
|
3332
|
+
wind?:
|
|
3333
|
+
WindPresetName | Readonly<{ preset?: WindPresetName } & WindParams> | null;
|
|
3334
|
+
/**
|
|
3335
|
+
* Singing voice settings as a field (SDK 1.32.0), the same as
|
|
3336
|
+
* `instrument: sing(...)`. Use with `instrument` omitted or `"sing"`.
|
|
3337
|
+
*/
|
|
3338
|
+
sing?:
|
|
3339
|
+
SingPresetName | Readonly<{ preset?: SingPresetName } & SingParams> | null;
|
|
2680
3340
|
/** Synth voice parameters, Strudel names (`{ attack: 0.01, lpf: 800 }`). */
|
|
2681
3341
|
synth?: SynthInput;
|
|
2682
3342
|
/**
|
|
@@ -2726,6 +3386,14 @@ export type TrackInput = Readonly<{
|
|
|
2726
3386
|
* holds only the keys already down when it presses.
|
|
2727
3387
|
*/
|
|
2728
3388
|
sostenuto?: readonly (readonly [number, "down" | "up"])[];
|
|
3389
|
+
/**
|
|
3390
|
+
* Audio clips on the timeline (SDK 1.32.0): `audio("samples/lead.wav",
|
|
3391
|
+
* { at: 8 })`, or `...repeatAudio(audio(...), { every: 8, until: 64 })`.
|
|
3392
|
+
* They sound on any instrument; on `vocal` the notes are silent guides.
|
|
3393
|
+
*/
|
|
3394
|
+
clips?: readonly (AudioSpec | readonly AudioSpec[])[];
|
|
3395
|
+
/** Takes the clips play from (SDK 1.32.0): `take("take-1", "takes/take-1.wav", {...})`. */
|
|
3396
|
+
takes?: readonly TakeSpec[];
|
|
2729
3397
|
/**
|
|
2730
3398
|
* Velocity response (SDK 1.15.0): `soft` (quiet notes louder), `hard`
|
|
2731
3399
|
* (needs a firm touch), `fixed` (every note at 0.8, like an organ) or
|
|
@@ -2750,6 +3418,11 @@ export type TrackInput = Readonly<{
|
|
|
2750
3418
|
* it changes no sound by itself.
|
|
2751
3419
|
*/
|
|
2752
3420
|
guitar?: GuitarInput;
|
|
3421
|
+
/**
|
|
3422
|
+
* Pitch correction on this track's clips and samples (SDK 1.32.0): a
|
|
3423
|
+
* preset word (`"hard"`), or `autotune("pop", { speed: 40 })`.
|
|
3424
|
+
*/
|
|
3425
|
+
autotune?: AutotunePresetName | AutotuneSpec;
|
|
2753
3426
|
/** `note()`/`seq()` for pitched tracks, `hit()`/`hits()` for kits and one-shot samplers. */
|
|
2754
3427
|
notes?: readonly (NoteSpec | HitSpec)[];
|
|
2755
3428
|
/**
|
|
@@ -2882,6 +3555,16 @@ export type TrackSpec = Readonly<{
|
|
|
2882
3555
|
guitar?: GuitarSetup;
|
|
2883
3556
|
/** Wind settings (SDK 1.30.0); present only on a wind-engine track. */
|
|
2884
3557
|
wind?: Readonly<{ preset?: WindPresetName } & WindParams>;
|
|
3558
|
+
/** Sing settings (SDK 1.32.0); present only on a sing track. */
|
|
3559
|
+
sing?: Readonly<{ preset?: SingPresetName } & SingParams>;
|
|
3560
|
+
/** Audio clips (SDK 1.32.0), flattened, paths project-relative; present only when set. */
|
|
3561
|
+
clips?: readonly AudioSpec[];
|
|
3562
|
+
/** Takes (SDK 1.32.0); present only when set. */
|
|
3563
|
+
takes?: readonly TakeSpec[];
|
|
3564
|
+
/** Vocoder settings (SDK 1.32.0); `src` as written until `song()`. */
|
|
3565
|
+
vocoder?: Readonly<{ preset?: VocoderPresetName } & VocoderParams>;
|
|
3566
|
+
/** Pitch correction (SDK 1.32.0); present only when set. */
|
|
3567
|
+
autotune?: AutotuneSettings;
|
|
2885
3568
|
}>;
|
|
2886
3569
|
|
|
2887
3570
|
export type GlideMode = "legato" | "mono" | "poly";
|
|
@@ -3087,6 +3770,40 @@ const ZZFX_SHAPES = Object.freeze([
|
|
|
3087
3770
|
* Sample paths without a `tracks/` prefix are made project-relative under
|
|
3088
3771
|
* this track's `tracks/<slug>/`.
|
|
3089
3772
|
*/
|
|
3773
|
+
/**
|
|
3774
|
+
* `wind:` or `sing:` on track() as the engine spec, so the field is never
|
|
3775
|
+
* silently dropped: it needs `instrument` omitted or the engine's own word.
|
|
3776
|
+
*/
|
|
3777
|
+
function engineField(
|
|
3778
|
+
input: TrackInput,
|
|
3779
|
+
name: string,
|
|
3780
|
+
): WindSpec | SingSpec | undefined {
|
|
3781
|
+
const fields = [
|
|
3782
|
+
["wind", WIND_INSTRUMENT, wind] as const,
|
|
3783
|
+
["sing", SING_INSTRUMENT, sing] as const,
|
|
3784
|
+
].filter(([key]) => input[key] !== undefined && input[key] !== null);
|
|
3785
|
+
if (fields.length === 0) return undefined;
|
|
3786
|
+
if (fields.length > 1)
|
|
3787
|
+
throw new DawgSdkError(`track ${name}: use wind: or sing:, not both`);
|
|
3788
|
+
const [key, word, make] = fields[0]!;
|
|
3789
|
+
if (input.instrument !== undefined && input.instrument !== word)
|
|
3790
|
+
throw new DawgSdkError(
|
|
3791
|
+
`track ${name}: ${key}: needs instrument "${word}" or none (got ${typeof input.instrument === "string" ? `"${input.instrument.slice(0, 32)}"` : "an engine spec"})`,
|
|
3792
|
+
);
|
|
3793
|
+
const value = input[key] as unknown;
|
|
3794
|
+
if (typeof value === "string")
|
|
3795
|
+
return (make as (p: string) => WindSpec | SingSpec)(value);
|
|
3796
|
+
if (!isRecord(value))
|
|
3797
|
+
throw new DawgSdkError(
|
|
3798
|
+
`track ${name}: ${key}: must be a preset word or an object`,
|
|
3799
|
+
);
|
|
3800
|
+
const { preset, ...params } = value as Record<string, unknown>;
|
|
3801
|
+
return (make as (p: unknown, q: object) => WindSpec | SingSpec)(
|
|
3802
|
+
preset ?? params,
|
|
3803
|
+
preset === undefined ? {} : params,
|
|
3804
|
+
);
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3090
3807
|
export function track(input: TrackInput): TrackSpec {
|
|
3091
3808
|
if (!isRecord(input)) throw new DawgSdkError("track() needs an object");
|
|
3092
3809
|
if (typeof input.name !== "string" || input.name.trim().length === 0)
|
|
@@ -3098,7 +3815,7 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
3098
3815
|
const id = input.id ?? slug;
|
|
3099
3816
|
if (typeof id !== "string" || id.length === 0 || id.length > 64)
|
|
3100
3817
|
throw new DawgSdkError(`track ${name}: id must be 1..64 characters`);
|
|
3101
|
-
const rawInstrument = input.instrument ?? "sine";
|
|
3818
|
+
const rawInstrument = engineField(input, name) ?? input.instrument ?? "sine";
|
|
3102
3819
|
// A granular track may keep the sampler it grains (`grain off` goes back).
|
|
3103
3820
|
const keptSampler =
|
|
3104
3821
|
isRecord(input.sampler) &&
|
|
@@ -3115,9 +3832,24 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
3115
3832
|
isRecord(rawInstrument) && rawInstrument.kind === "sampler"
|
|
3116
3833
|
? localizeSampler(rawInstrument as SamplerSpec, slug)
|
|
3117
3834
|
: keptSampler;
|
|
3118
|
-
const
|
|
3119
|
-
isRecord(rawInstrument) && rawInstrument.kind === "wavetable"
|
|
3120
|
-
|
|
3835
|
+
const playedWavetable =
|
|
3836
|
+
isRecord(rawInstrument) && rawInstrument.kind === "wavetable";
|
|
3837
|
+
if (input.wavetable !== undefined && input.wavetable !== null) {
|
|
3838
|
+
if (!isRecord(input.wavetable) || input.wavetable.kind !== "wavetable")
|
|
3839
|
+
throw new DawgSdkError(`track ${name}: wavetable: takes wavetable(...)`);
|
|
3840
|
+
if (
|
|
3841
|
+
playedWavetable ||
|
|
3842
|
+
(typeof rawInstrument === "string" &&
|
|
3843
|
+
rawInstrument.trim().toLowerCase() === WAVETABLE_INSTRUMENT)
|
|
3844
|
+
)
|
|
3845
|
+
throw new DawgSdkError(
|
|
3846
|
+
`track ${name}: a wavetable track sets its table in instrument: wavetable(...)`,
|
|
3847
|
+
);
|
|
3848
|
+
}
|
|
3849
|
+
const wavetableSpec = playedWavetable
|
|
3850
|
+
? localizeWavetable(rawInstrument as WavetableSpec, slug)
|
|
3851
|
+
: isRecord(input.wavetable)
|
|
3852
|
+
? localizeWavetable(input.wavetable as WavetableSpec, slug)
|
|
3121
3853
|
: null;
|
|
3122
3854
|
const stringFromInstrument =
|
|
3123
3855
|
isRecord(rawInstrument) && rawInstrument.kind === "string"
|
|
@@ -3134,11 +3866,13 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
3134
3866
|
const modalSpec = trackModal(rawInstrument);
|
|
3135
3867
|
const guitarSpec = guitarInput(input.guitar, `track ${name}`);
|
|
3136
3868
|
const windSpec = trackWind(rawInstrument);
|
|
3869
|
+
const singSpec = trackSing(rawInstrument);
|
|
3870
|
+
const vocoderSpec = trackVocoder(rawInstrument, input.vocoder);
|
|
3137
3871
|
const instrument = granularFromInstrument
|
|
3138
3872
|
? GRANULAR_INSTRUMENT
|
|
3139
3873
|
: samplerSpec
|
|
3140
3874
|
? SAMPLER_INSTRUMENT
|
|
3141
|
-
:
|
|
3875
|
+
: playedWavetable
|
|
3142
3876
|
? WAVETABLE_INSTRUMENT
|
|
3143
3877
|
: stringFromInstrument
|
|
3144
3878
|
? STRING_INSTRUMENT
|
|
@@ -3146,9 +3880,13 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
3146
3880
|
? MODAL_INSTRUMENT
|
|
3147
3881
|
: windSpec
|
|
3148
3882
|
? WIND_INSTRUMENT
|
|
3149
|
-
:
|
|
3150
|
-
?
|
|
3151
|
-
:
|
|
3883
|
+
: singSpec
|
|
3884
|
+
? SING_INSTRUMENT
|
|
3885
|
+
: isRecord(rawInstrument) && rawInstrument.kind === "vocoder"
|
|
3886
|
+
? VOCODER_INSTRUMENT
|
|
3887
|
+
: typeof rawInstrument === "string"
|
|
3888
|
+
? (word?.instrument ?? rawInstrument)
|
|
3889
|
+
: undefined;
|
|
3152
3890
|
// A granular word (`"cloud"`) turns the engine on with its preset.
|
|
3153
3891
|
const granularSpec =
|
|
3154
3892
|
granularInput(input.granular, name, slug) ??
|
|
@@ -3191,7 +3929,7 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
3191
3929
|
if (pitch === undefined)
|
|
3192
3930
|
throw new DawgSdkError(
|
|
3193
3931
|
kit
|
|
3194
|
-
? `track ${name}: unknown drum
|
|
3932
|
+
? `track ${name}: unknown drum "${spec.voice}" (kick snare clap rim tom hat openhat)`
|
|
3195
3933
|
: slots
|
|
3196
3934
|
? `track ${name}: unknown sampler voice "${spec.voice}" (${[...slots.keys()].join(" ")})`
|
|
3197
3935
|
: `track ${name}: hit("${spec.voice}") needs instrument "kit" or sampler(...)`,
|
|
@@ -3373,6 +4111,12 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
3373
4111
|
...(modalSpec ? { modal: modalSpec } : {}),
|
|
3374
4112
|
...(guitarSpec ? { guitar: guitarSpec } : {}),
|
|
3375
4113
|
...(windSpec ? { wind: windSpec } : {}),
|
|
4114
|
+
...(singSpec ? { sing: singSpec } : {}),
|
|
4115
|
+
...trackClips(input, name, slug),
|
|
4116
|
+
...(vocoderSpec ? { vocoder: vocoderSpec } : {}),
|
|
4117
|
+
...(input.autotune !== undefined
|
|
4118
|
+
? { autotune: autotuneInput(input.autotune, `track ${name}`) }
|
|
4119
|
+
: {}),
|
|
3376
4120
|
});
|
|
3377
4121
|
}
|
|
3378
4122
|
|
|
@@ -3803,6 +4547,12 @@ export type SongInput = Readonly<{
|
|
|
3803
4547
|
tracks: readonly TrackSpec[];
|
|
3804
4548
|
/** Master chain and loudness target after every track and orbit bus (SDK 1.17.0); omit for none. */
|
|
3805
4549
|
master?: MasterInput;
|
|
4550
|
+
/**
|
|
4551
|
+
* Which style and seed made the song (SDK 1.33.0), from `style()`:
|
|
4552
|
+
* `style: style("deep-house", { seed: 3, bars: 8 })`. A record only: the
|
|
4553
|
+
* notes are the tracks above; `/style` in dawg generates them.
|
|
4554
|
+
*/
|
|
4555
|
+
style?: StyleSpec;
|
|
3806
4556
|
/**
|
|
3807
4557
|
* Named bar ranges (SDK 1.18.0): `{ name: "chorus", startBar: 8, bars: 8 }`,
|
|
3808
4558
|
* optionally with `mute: ["pad"]` and `vary: { lead: { transpose: 12 } }`.
|
|
@@ -3814,8 +4564,20 @@ export type SongInput = Readonly<{
|
|
|
3814
4564
|
* Absent plays the bars straight through.
|
|
3815
4565
|
*/
|
|
3816
4566
|
form?: string | readonly (string | SongFormEntry)[];
|
|
3817
|
-
/** The section playback loops (SDK 1.18.0);
|
|
4567
|
+
/** The section playback loops (SDK 1.18.0); `loop: "chorus"` says the same. */
|
|
3818
4568
|
loopSection?: string;
|
|
4569
|
+
/**
|
|
4570
|
+
* What playback loops (SDK 1.34.0): bars `"5-6"` (1-based, as the prompt
|
|
4571
|
+
* shows them) or a section `"chorus"`. Export ignores it.
|
|
4572
|
+
*/
|
|
4573
|
+
loop?: string;
|
|
4574
|
+
/**
|
|
4575
|
+
* Sound calibration (SDK 1.32.0): `1` renders the 0.7 level, pitch and
|
|
4576
|
+
* drum-kit fixes (hat choke, tuned toms, crash and ride, level keys,
|
|
4577
|
+
* steady brass). Omit it to keep an older song's sound byte-identical;
|
|
4578
|
+
* `dawg init` writes the latest.
|
|
4579
|
+
*/
|
|
4580
|
+
calibration?: number;
|
|
3819
4581
|
}>;
|
|
3820
4582
|
|
|
3821
4583
|
/** A song section (SDK 1.18.0); bars are 0-based like beats. */
|
|
@@ -3880,8 +4642,12 @@ export type ScoreNote = Readonly<{
|
|
|
3880
4642
|
bend?: readonly Readonly<{ at: number; cents: number }>[];
|
|
3881
4643
|
vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
|
|
3882
4644
|
humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
|
|
4645
|
+
/** Autotune guide drift share (SDK 1.32.0). */
|
|
4646
|
+
drift?: number;
|
|
3883
4647
|
/** Static cents offset (SDK 1.16.0); absent is 0. */
|
|
3884
4648
|
cents?: number;
|
|
4649
|
+
/** Sung vowel (SDK 1.32.0); absent sings the lyric's or the track's. */
|
|
4650
|
+
vowel?: string;
|
|
3885
4651
|
}>;
|
|
3886
4652
|
|
|
3887
4653
|
/** A stored automation point: integer tick. */
|
|
@@ -3968,6 +4734,12 @@ export type ScoreTrack = Readonly<{
|
|
|
3968
4734
|
guitar?: GuitarSetup;
|
|
3969
4735
|
/** Wind settings (SDK 1.30.0). */
|
|
3970
4736
|
wind?: TrackSpec["wind"];
|
|
4737
|
+
/** Sing settings (SDK 1.32.0). */
|
|
4738
|
+
sing?: TrackSpec["sing"];
|
|
4739
|
+
/** Vocoder settings (SDK 1.32.0); `src` is a track id. */
|
|
4740
|
+
vocoder?: TrackSpec["vocoder"];
|
|
4741
|
+
/** Pitch correction (SDK 1.32.0). */
|
|
4742
|
+
autotune?: AutotuneSettings;
|
|
3971
4743
|
glide?: TrackSpec["glide"];
|
|
3972
4744
|
pedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
|
|
3973
4745
|
softPedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
|
|
@@ -3996,14 +4768,82 @@ export type Song = Readonly<{
|
|
|
3996
4768
|
tracks: readonly ScoreTrack[];
|
|
3997
4769
|
notes: readonly ScoreNote[];
|
|
3998
4770
|
master?: MasterInput;
|
|
4771
|
+
/** Present only when the song names a style (SDK 1.33.0). */
|
|
4772
|
+
style?: StyleSpec;
|
|
3999
4773
|
/** Present only when the song has sections (SDK 1.18.0). */
|
|
4000
4774
|
sections?: readonly SongSection[];
|
|
4001
4775
|
/** Present only when the song has a form (SDK 1.18.0). */
|
|
4002
4776
|
form?: readonly SongFormEntry[];
|
|
4003
4777
|
/** Present only when a section loops (SDK 1.18.0). */
|
|
4004
4778
|
loopSection?: string;
|
|
4779
|
+
/** Present only when bars loop (SDK 1.34.0): 0-based `startBar`. */
|
|
4780
|
+
loop?: Readonly<{ startBar: number; bars: number }>;
|
|
4781
|
+
/** Present only when the song sets one (SDK 1.32.0). */
|
|
4782
|
+
calibration?: number;
|
|
4783
|
+
}>;
|
|
4784
|
+
|
|
4785
|
+
/** A song's style provenance (SDK 1.33.0); see `style()`. */
|
|
4786
|
+
export type StyleSpec = Readonly<{
|
|
4787
|
+
id: string;
|
|
4788
|
+
seed: number;
|
|
4789
|
+
bars: number;
|
|
4790
|
+
blend?: Readonly<{ id: string; weight: number }>;
|
|
4791
|
+
}>;
|
|
4792
|
+
|
|
4793
|
+
export type StyleOptions = Readonly<{
|
|
4794
|
+
/** Generator seed, integer 0..2147483647, default 1. */
|
|
4795
|
+
seed?: number;
|
|
4796
|
+
/** Bars generated, 1..256, default 8. */
|
|
4797
|
+
bars?: number;
|
|
4798
|
+
/** Blend partner and its weight 0..1: `blend: ["bebop", 0.3]`. */
|
|
4799
|
+
blend?: readonly [string, number];
|
|
4005
4800
|
}>;
|
|
4006
4801
|
|
|
4802
|
+
const STYLE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
4803
|
+
|
|
4804
|
+
/**
|
|
4805
|
+
* Which style and seed made a song (SDK 1.33.0), for `song({ style })`:
|
|
4806
|
+
*
|
|
4807
|
+
* ```ts
|
|
4808
|
+
* style("deep-house", { seed: 3, bars: 8 })
|
|
4809
|
+
* style("bebop", { seed: 7, blend: ["bossa-nova", 0.3] })
|
|
4810
|
+
* ```
|
|
4811
|
+
*
|
|
4812
|
+
* The id is a taxonomy id (`dawg` lists them with `/style list`). It is a
|
|
4813
|
+
* record of where the song came from; the notes live in the tracks.
|
|
4814
|
+
*/
|
|
4815
|
+
export function style(id: string, options: StyleOptions = {}): StyleSpec {
|
|
4816
|
+
if (typeof id !== "string" || !STYLE_ID_PATTERN.test(id))
|
|
4817
|
+
throw new DawgSdkError(
|
|
4818
|
+
`style id must be lowercase words joined by hyphens, like "deep-house"; got ${JSON.stringify(id)}`,
|
|
4819
|
+
);
|
|
4820
|
+
if (!isRecord(options))
|
|
4821
|
+
throw new DawgSdkError("style options must be an object");
|
|
4822
|
+
const seed = options.seed ?? 1;
|
|
4823
|
+
if (!Number.isInteger(seed) || seed < 0 || seed > 2147483647)
|
|
4824
|
+
throw new DawgSdkError("style seed must be an integer 0..2147483647");
|
|
4825
|
+
const bars = options.bars ?? 8;
|
|
4826
|
+
if (!Number.isInteger(bars) || bars < 1 || bars > 256)
|
|
4827
|
+
throw new DawgSdkError("style bars must be an integer 1..256");
|
|
4828
|
+
let blend: StyleSpec["blend"];
|
|
4829
|
+
if (options.blend !== undefined) {
|
|
4830
|
+
const pair = options.blend;
|
|
4831
|
+
if (
|
|
4832
|
+
!Array.isArray(pair) ||
|
|
4833
|
+
pair.length !== 2 ||
|
|
4834
|
+
typeof pair[0] !== "string" ||
|
|
4835
|
+
!STYLE_ID_PATTERN.test(pair[0]) ||
|
|
4836
|
+
typeof pair[1] !== "number" ||
|
|
4837
|
+
!(pair[1] >= 0 && pair[1] <= 1)
|
|
4838
|
+
)
|
|
4839
|
+
throw new DawgSdkError(
|
|
4840
|
+
'style blend must be ["style-id", weight 0..1], like ["bebop", 0.3]',
|
|
4841
|
+
);
|
|
4842
|
+
blend = Object.freeze({ id: pair[0], weight: pair[1] });
|
|
4843
|
+
}
|
|
4844
|
+
return Object.freeze({ id, seed, bars, ...(blend ? { blend } : {}) });
|
|
4845
|
+
}
|
|
4846
|
+
|
|
4007
4847
|
/** A stored song `time`: ticks, and 0-based bar indexes. */
|
|
4008
4848
|
export type ScoreTime = Readonly<{
|
|
4009
4849
|
tempo?: readonly Readonly<{
|
|
@@ -4019,6 +4859,24 @@ export type ScoreTime = Readonly<{
|
|
|
4019
4859
|
fermatas?: readonly Readonly<{ tick: number; beats: number }>[];
|
|
4020
4860
|
}>;
|
|
4021
4861
|
|
|
4862
|
+
/** `song({ style })`: the `style()` record, re-checked for hand-written objects. */
|
|
4863
|
+
function songStyle(input: unknown): { style?: StyleSpec } {
|
|
4864
|
+
if (input === undefined || input === null) return {};
|
|
4865
|
+
if (!isRecord(input))
|
|
4866
|
+
throw new DawgSdkError('song style must come from style("id", { seed })');
|
|
4867
|
+
const blend = input.blend;
|
|
4868
|
+
const spec = style(input.id as string, {
|
|
4869
|
+
seed: input.seed as number,
|
|
4870
|
+
bars: input.bars as number,
|
|
4871
|
+
...(blend !== undefined && blend !== null
|
|
4872
|
+
? isRecord(blend)
|
|
4873
|
+
? { blend: [blend.id, blend.weight] as unknown as [string, number] }
|
|
4874
|
+
: { blend: blend as [string, number] }
|
|
4875
|
+
: {}),
|
|
4876
|
+
});
|
|
4877
|
+
return { style: spec };
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4022
4880
|
const MASTER_KEYS = ["eq", "glue", "tape", "width", "limiter", "target"];
|
|
4023
4881
|
|
|
4024
4882
|
/** Shape checks only; dawg validates every value when it loads the song. */
|
|
@@ -4151,10 +5009,277 @@ function songSections(
|
|
|
4151
5009
|
}
|
|
4152
5010
|
|
|
4153
5011
|
/**
|
|
4154
|
-
* Assemble the song. Beats become ticks (`Math.round(beat * ticksPerBeat)`,
|
|
4155
|
-
* lengths at least one tick), and every note gets a deterministic id from
|
|
4156
|
-
* its track and content, so two evaluations of the same files agree.
|
|
5012
|
+
* Assemble the song. Beats become ticks (`Math.round(beat * ticksPerBeat)`,
|
|
5013
|
+
* lengths at least one tick), and every note gets a deterministic id from
|
|
5014
|
+
* its track and content, so two evaluations of the same files agree.
|
|
5015
|
+
*/
|
|
5016
|
+
/** The newest `song({ calibration })` (mirrors core CALIBRATION_LATEST). */
|
|
5017
|
+
export const SONG_CALIBRATION_LATEST = 1;
|
|
5018
|
+
|
|
5019
|
+
/** `song({ loop })`: bars `"5-6"` → a 0-based range, else a section name. */
|
|
5020
|
+
function songLoop(
|
|
5021
|
+
input: unknown,
|
|
5022
|
+
bars: number,
|
|
5023
|
+
): string | Readonly<{ startBar: number; bars: number }> {
|
|
5024
|
+
if (typeof input !== "string" || input.trim() === "")
|
|
5025
|
+
throw new DawgSdkError('song loop must be bars "5-6" or a section name');
|
|
5026
|
+
const match = /^\s*(\d{1,4})\s*(?:-|–|\.\.)?\s*(\d{1,4})?\s*$/u.exec(input);
|
|
5027
|
+
if (!match) return input.trim();
|
|
5028
|
+
const from = Number(match[1]);
|
|
5029
|
+
const to = match[2] === undefined ? from : Number(match[2]);
|
|
5030
|
+
if (from < 1 || to < from)
|
|
5031
|
+
throw new DawgSdkError(`song loop "${input}" runs low-high from bar 1`);
|
|
5032
|
+
if (to > bars)
|
|
5033
|
+
throw new DawgSdkError(
|
|
5034
|
+
`song loop "${input}" is past the song's ${bars} bars`,
|
|
5035
|
+
);
|
|
5036
|
+
return Object.freeze({ startBar: from - 1, bars: to - from + 1 });
|
|
5037
|
+
}
|
|
5038
|
+
|
|
5039
|
+
// ---------------------------------------------------------------------------
|
|
5040
|
+
// Range helpers (SDK 1.34.0): the prompt's copy, move and bars insert as
|
|
5041
|
+
// pure functions over notes, for files written by hand. Bars are 1-based.
|
|
5042
|
+
|
|
5043
|
+
type Timed = Readonly<{ start: number; length: number }>;
|
|
5044
|
+
type RangeHelperOptions = Readonly<{ beatsPerBar?: number }>;
|
|
5045
|
+
|
|
5046
|
+
function perBar(options: RangeHelperOptions | undefined, what: string): number {
|
|
5047
|
+
return positive(options?.beatsPerBar ?? 4, `${what} beatsPerBar`);
|
|
5048
|
+
}
|
|
5049
|
+
|
|
5050
|
+
function wholeBar(value: unknown, what: string): number {
|
|
5051
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1)
|
|
5052
|
+
throw new DawgSdkError(`${what} must be a bar from 1`);
|
|
5053
|
+
return value;
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
/**
|
|
5057
|
+
* The notes (or hits) that start in bars `from..to`, re-based to beat 0
|
|
5058
|
+
* and cut at the range's end: `bars(chorus, 5, 6)`.
|
|
5059
|
+
*/
|
|
5060
|
+
export function bars<T extends Timed>(
|
|
5061
|
+
notes: readonly T[],
|
|
5062
|
+
from: number,
|
|
5063
|
+
to: number = from,
|
|
5064
|
+
options?: RangeHelperOptions,
|
|
5065
|
+
): readonly T[] {
|
|
5066
|
+
if (!Array.isArray(notes))
|
|
5067
|
+
throw new DawgSdkError("bars needs an array of notes");
|
|
5068
|
+
const each = perBar(options, "bars");
|
|
5069
|
+
const first = wholeBar(from, "bars from");
|
|
5070
|
+
const last = wholeBar(to, "bars to");
|
|
5071
|
+
if (last < first)
|
|
5072
|
+
throw new DawgSdkError("bars runs low-high: bars(notes, 5, 6)");
|
|
5073
|
+
const start = (first - 1) * each;
|
|
5074
|
+
const end = last * each;
|
|
5075
|
+
return Object.freeze(
|
|
5076
|
+
notes
|
|
5077
|
+
.filter((item) => item.start >= start && item.start < end)
|
|
5078
|
+
.map((item) =>
|
|
5079
|
+
Object.freeze({
|
|
5080
|
+
...item,
|
|
5081
|
+
start: item.start - start,
|
|
5082
|
+
length: Math.min(item.length, end - item.start),
|
|
5083
|
+
}),
|
|
5084
|
+
),
|
|
5085
|
+
);
|
|
5086
|
+
}
|
|
5087
|
+
|
|
5088
|
+
/**
|
|
5089
|
+
* Notes laid down at bar `at`, repeated `times` times end to end:
|
|
5090
|
+
* `place(bars(bass, 5, 6), { at: 7, times: 2 })`. Each repeat spans
|
|
5091
|
+
* `bars` bars (default: the whole bars the notes reach).
|
|
5092
|
+
*/
|
|
5093
|
+
export function place<T extends Timed>(
|
|
5094
|
+
notes: readonly T[],
|
|
5095
|
+
options: Readonly<{ at: number; times?: number; bars?: number }> &
|
|
5096
|
+
RangeHelperOptions,
|
|
5097
|
+
): readonly T[] {
|
|
5098
|
+
if (!Array.isArray(notes))
|
|
5099
|
+
throw new DawgSdkError("place needs an array of notes");
|
|
5100
|
+
if (!isRecord(options)) throw new DawgSdkError("place needs { at: <bar> }");
|
|
5101
|
+
const each = perBar(options, "place");
|
|
5102
|
+
const at = (wholeBar(options.at, "place at") - 1) * each;
|
|
5103
|
+
const times = options.times ?? 1;
|
|
5104
|
+
if (!Number.isInteger(times) || times < 1 || times > 64)
|
|
5105
|
+
throw new DawgSdkError("place times must be 1..64");
|
|
5106
|
+
const reach = notes.reduce(
|
|
5107
|
+
(most, item) => Math.max(most, item.start + item.length),
|
|
5108
|
+
0,
|
|
5109
|
+
);
|
|
5110
|
+
const span =
|
|
5111
|
+
(options.bars === undefined
|
|
5112
|
+
? Math.max(1, Math.ceil(reach / each - 1e-9))
|
|
5113
|
+
: wholeBar(options.bars, "place bars")) * each;
|
|
5114
|
+
const out: T[] = [];
|
|
5115
|
+
for (let pass = 0; pass < times; pass += 1)
|
|
5116
|
+
for (const item of notes)
|
|
5117
|
+
out.push(
|
|
5118
|
+
Object.freeze({ ...item, start: at + pass * span + item.start }),
|
|
5119
|
+
);
|
|
5120
|
+
return Object.freeze(out);
|
|
5121
|
+
}
|
|
5122
|
+
|
|
5123
|
+
/**
|
|
5124
|
+
* The notes mirrored in time over `bars` bars from beat 0 (a note ending
|
|
5125
|
+
* at the span's end starts at 0): `reversed(bars(lead, 5, 6), { bars: 2 })`.
|
|
5126
|
+
*/
|
|
5127
|
+
export function reversed<T extends Timed>(
|
|
5128
|
+
notes: readonly T[],
|
|
5129
|
+
options: Readonly<{ bars: number }> & RangeHelperOptions,
|
|
5130
|
+
): readonly T[] {
|
|
5131
|
+
if (!Array.isArray(notes))
|
|
5132
|
+
throw new DawgSdkError("reversed needs an array of notes");
|
|
5133
|
+
if (!isRecord(options))
|
|
5134
|
+
throw new DawgSdkError("reversed needs { bars: <n> }");
|
|
5135
|
+
const span =
|
|
5136
|
+
wholeBar(options.bars, "reversed bars") * perBar(options, "reversed");
|
|
5137
|
+
return Object.freeze(
|
|
5138
|
+
notes
|
|
5139
|
+
.filter((item) => item.start < span)
|
|
5140
|
+
.map((item) => {
|
|
5141
|
+
const length = Math.min(item.length, span - item.start);
|
|
5142
|
+
return Object.freeze({
|
|
5143
|
+
...item,
|
|
5144
|
+
start: span - item.start - length,
|
|
5145
|
+
length,
|
|
5146
|
+
});
|
|
5147
|
+
})
|
|
5148
|
+
.sort((a, b) => a.start - b.start),
|
|
5149
|
+
);
|
|
5150
|
+
}
|
|
5151
|
+
|
|
5152
|
+
/** Shift every `tick` field at or past `at` in an array of points. */
|
|
5153
|
+
function shiftTicks(value: unknown, at: number, shift: number): unknown {
|
|
5154
|
+
if (!Array.isArray(value)) return value;
|
|
5155
|
+
if (!value.every((item) => isRecord(item) && typeof item.tick === "number"))
|
|
5156
|
+
return value;
|
|
5157
|
+
return Object.freeze(
|
|
5158
|
+
value.map((item) =>
|
|
5159
|
+
(item as { tick: number }).tick >= at
|
|
5160
|
+
? Object.freeze({
|
|
5161
|
+
...(item as object),
|
|
5162
|
+
tick: (item as { tick: number }).tick + shift,
|
|
5163
|
+
})
|
|
5164
|
+
: item,
|
|
5165
|
+
),
|
|
5166
|
+
);
|
|
5167
|
+
}
|
|
5168
|
+
|
|
5169
|
+
/**
|
|
5170
|
+
* `bars` empty bars inserted before bar `at` of a `song()` result: later
|
|
5171
|
+
* notes, sections, the loop, tempo marks, fermatas, automation points and
|
|
5172
|
+
* clips move right; a note held across `at` sounds on through the gap.
|
|
5173
|
+
* `export default insertBars(song({...}), { at: 7, bars: 2 })`.
|
|
4157
5174
|
*/
|
|
5175
|
+
export function insertBars(
|
|
5176
|
+
input: Song,
|
|
5177
|
+
options: Readonly<{ at: number; bars: number }>,
|
|
5178
|
+
): Song {
|
|
5179
|
+
if (!isRecord(input) || input.format !== "track.loop/v1")
|
|
5180
|
+
throw new DawgSdkError("insertBars needs a song() result");
|
|
5181
|
+
if (!isRecord(options))
|
|
5182
|
+
throw new DawgSdkError("insertBars needs { at, bars }");
|
|
5183
|
+
const atBar = wholeBar(options.at, "insertBars at") - 1;
|
|
5184
|
+
const count = wholeBar(options.bars, "insertBars bars");
|
|
5185
|
+
if (atBar > input.bars)
|
|
5186
|
+
throw new DawgSdkError(
|
|
5187
|
+
`insertBars at ${atBar + 1} is past the song's ${input.bars} bars`,
|
|
5188
|
+
);
|
|
5189
|
+
if (input.bars + count > 256)
|
|
5190
|
+
throw new DawgSdkError("a song has at most 256 bars");
|
|
5191
|
+
if (input.time?.meter?.some((mark) => mark.bar > 0))
|
|
5192
|
+
throw new DawgSdkError(
|
|
5193
|
+
"insertBars needs one meter · use dawg's bars insert",
|
|
5194
|
+
);
|
|
5195
|
+
const barTicks = input.beatsPerBar * input.ticksPerBeat;
|
|
5196
|
+
const at = atBar * barTicks;
|
|
5197
|
+
const shift = count * barTicks;
|
|
5198
|
+
const atBeat = atBar * input.beatsPerBar;
|
|
5199
|
+
const beats = count * input.beatsPerBar;
|
|
5200
|
+
const moveRange = <R extends { startBar: number; bars: number }>(
|
|
5201
|
+
range: R,
|
|
5202
|
+
): R =>
|
|
5203
|
+
range.startBar >= atBar
|
|
5204
|
+
? { ...range, startBar: range.startBar + count }
|
|
5205
|
+
: range.startBar + range.bars > atBar
|
|
5206
|
+
? { ...range, bars: range.bars + count }
|
|
5207
|
+
: range;
|
|
5208
|
+
const tracks = input.tracks.map((track) => {
|
|
5209
|
+
const out: Record<string, unknown> = { ...track };
|
|
5210
|
+
for (const [field, value] of Object.entries(track)) {
|
|
5211
|
+
if (field === "fxAutomation" && isRecord(value)) {
|
|
5212
|
+
out[field] = Object.freeze(
|
|
5213
|
+
Object.fromEntries(
|
|
5214
|
+
Object.entries(value).map(([lane, points]) => [
|
|
5215
|
+
lane,
|
|
5216
|
+
shiftTicks(points, at, shift),
|
|
5217
|
+
]),
|
|
5218
|
+
),
|
|
5219
|
+
);
|
|
5220
|
+
} else if (field === "clips" && Array.isArray(value)) {
|
|
5221
|
+
out[field] = Object.freeze(
|
|
5222
|
+
value.map((clip) =>
|
|
5223
|
+
isRecord(clip) && typeof clip.at === "number" && clip.at >= atBeat
|
|
5224
|
+
? Object.freeze({ ...clip, at: clip.at + beats })
|
|
5225
|
+
: clip,
|
|
5226
|
+
),
|
|
5227
|
+
);
|
|
5228
|
+
} else out[field] = shiftTicks(value, at, shift);
|
|
5229
|
+
}
|
|
5230
|
+
return Object.freeze(out) as ScoreTrack;
|
|
5231
|
+
});
|
|
5232
|
+
const time = input.time
|
|
5233
|
+
? Object.freeze({
|
|
5234
|
+
...input.time,
|
|
5235
|
+
...(input.time.tempo
|
|
5236
|
+
? {
|
|
5237
|
+
tempo: shiftTicks(
|
|
5238
|
+
input.time.tempo,
|
|
5239
|
+
at,
|
|
5240
|
+
shift,
|
|
5241
|
+
) as ScoreTime["tempo"],
|
|
5242
|
+
}
|
|
5243
|
+
: {}),
|
|
5244
|
+
...(input.time.fermatas
|
|
5245
|
+
? {
|
|
5246
|
+
fermatas: shiftTicks(
|
|
5247
|
+
input.time.fermatas,
|
|
5248
|
+
at,
|
|
5249
|
+
shift,
|
|
5250
|
+
) as ScoreTime["fermatas"],
|
|
5251
|
+
}
|
|
5252
|
+
: {}),
|
|
5253
|
+
})
|
|
5254
|
+
: undefined;
|
|
5255
|
+
return Object.freeze({
|
|
5256
|
+
...input,
|
|
5257
|
+
bars: input.bars + count,
|
|
5258
|
+
...(time ? { time } : {}),
|
|
5259
|
+
tracks: Object.freeze(tracks),
|
|
5260
|
+
notes: Object.freeze(
|
|
5261
|
+
input.notes.map((item) =>
|
|
5262
|
+
item.startTick >= at
|
|
5263
|
+
? Object.freeze({ ...item, startTick: item.startTick + shift })
|
|
5264
|
+
: item.startTick + item.durationTicks > at
|
|
5265
|
+
? Object.freeze({
|
|
5266
|
+
...item,
|
|
5267
|
+
durationTicks: item.durationTicks + shift,
|
|
5268
|
+
})
|
|
5269
|
+
: item,
|
|
5270
|
+
),
|
|
5271
|
+
),
|
|
5272
|
+
...(input.sections
|
|
5273
|
+
? {
|
|
5274
|
+
sections: Object.freeze(
|
|
5275
|
+
input.sections.map((section) => Object.freeze(moveRange(section))),
|
|
5276
|
+
),
|
|
5277
|
+
}
|
|
5278
|
+
: {}),
|
|
5279
|
+
...(input.loop ? { loop: Object.freeze(moveRange(input.loop)) } : {}),
|
|
5280
|
+
});
|
|
5281
|
+
}
|
|
5282
|
+
|
|
4158
5283
|
export function song(input: SongInput): Song {
|
|
4159
5284
|
if (!isRecord(input)) throw new DawgSdkError("song() needs an object");
|
|
4160
5285
|
const tempoBpm = finite(input.tempo ?? 120, "song tempo");
|
|
@@ -4180,6 +5305,16 @@ export function song(input: SongInput): Song {
|
|
|
4180
5305
|
throw new DawgSdkError("song key must be a string or null");
|
|
4181
5306
|
const songTuning = tuningSpec(input.tuning, "song");
|
|
4182
5307
|
const master = masterData(input.master);
|
|
5308
|
+
const calibration = input.calibration ?? 0;
|
|
5309
|
+
if (
|
|
5310
|
+
typeof calibration !== "number" ||
|
|
5311
|
+
!Number.isInteger(calibration) ||
|
|
5312
|
+
calibration < 0 ||
|
|
5313
|
+
calibration > SONG_CALIBRATION_LATEST
|
|
5314
|
+
)
|
|
5315
|
+
throw new DawgSdkError(
|
|
5316
|
+
`song calibration must be an integer 0..${SONG_CALIBRATION_LATEST}`,
|
|
5317
|
+
);
|
|
4183
5318
|
if (!Array.isArray(input.tracks))
|
|
4184
5319
|
throw new DawgSdkError("song tracks must be an array of track()");
|
|
4185
5320
|
if (input.tracks.length > 64)
|
|
@@ -4281,6 +5416,29 @@ export function song(input: SongInput): Song {
|
|
|
4281
5416
|
if (t.modal) stored.modal = t.modal;
|
|
4282
5417
|
if (t.guitar) stored.guitar = t.guitar;
|
|
4283
5418
|
if (t.wind) stored.wind = t.wind;
|
|
5419
|
+
if (t.sing) stored.sing = t.sing;
|
|
5420
|
+
if (t.clips && t.clips.length > 0)
|
|
5421
|
+
stored.clips = Object.freeze(
|
|
5422
|
+
t.clips.map((clip, index) => storedClip(clip, index, ticks)),
|
|
5423
|
+
);
|
|
5424
|
+
if (t.takes && t.takes.length > 0)
|
|
5425
|
+
stored.takes = Object.freeze(
|
|
5426
|
+
t.takes.map((spec) => storedTake(spec, ticks)),
|
|
5427
|
+
);
|
|
5428
|
+
if (t.vocoder)
|
|
5429
|
+
stored.vocoder = Object.freeze(
|
|
5430
|
+
t.vocoder.src === undefined
|
|
5431
|
+
? t.vocoder
|
|
5432
|
+
: {
|
|
5433
|
+
...t.vocoder,
|
|
5434
|
+
src: resolveVocoderSrc(
|
|
5435
|
+
input.tracks as TrackSpec[],
|
|
5436
|
+
t.id,
|
|
5437
|
+
t.vocoder.src,
|
|
5438
|
+
),
|
|
5439
|
+
},
|
|
5440
|
+
);
|
|
5441
|
+
if (t.autotune) stored.autotune = t.autotune;
|
|
4284
5442
|
if (t.rhythm && t.rhythm.length > 0)
|
|
4285
5443
|
stored.rhythm = Object.freeze(
|
|
4286
5444
|
t.rhythm.map((row) => {
|
|
@@ -4320,7 +5478,10 @@ export function song(input: SongInput): Song {
|
|
|
4320
5478
|
: {}),
|
|
4321
5479
|
...(n.vibrato ? { vibrato: n.vibrato } : {}),
|
|
4322
5480
|
...(n.humanize ? { humanize: n.humanize } : {}),
|
|
5481
|
+
...(n.drift !== undefined ? { drift: n.drift } : {}),
|
|
4323
5482
|
...(n.cents ? { cents: n.cents } : {}),
|
|
5483
|
+
...(n.vowel ? { vowel: n.vowel } : {}),
|
|
5484
|
+
...(n.lyric !== undefined ? { lyric: n.lyric } : {}),
|
|
4324
5485
|
}),
|
|
4325
5486
|
);
|
|
4326
5487
|
}
|
|
@@ -4348,6 +5509,7 @@ export function song(input: SongInput): Song {
|
|
|
4348
5509
|
sections?: readonly SongSection[];
|
|
4349
5510
|
form?: readonly SongFormEntry[];
|
|
4350
5511
|
loopSection?: string;
|
|
5512
|
+
loop?: Readonly<{ startBar: number; bars: number }>;
|
|
4351
5513
|
} = {};
|
|
4352
5514
|
if (input.sections !== undefined) {
|
|
4353
5515
|
const sections = songSections(input.sections);
|
|
@@ -4362,6 +5524,13 @@ export function song(input: SongInput): Song {
|
|
|
4362
5524
|
throw new DawgSdkError("song loopSection must be a section name");
|
|
4363
5525
|
arrangement.loopSection = input.loopSection;
|
|
4364
5526
|
}
|
|
5527
|
+
if (input.loop !== undefined) {
|
|
5528
|
+
if (input.loopSection !== undefined)
|
|
5529
|
+
throw new DawgSdkError("song sets loop or loopSection, not both");
|
|
5530
|
+
const looped = songLoop(input.loop, bars);
|
|
5531
|
+
if (typeof looped === "string") arrangement.loopSection = looped;
|
|
5532
|
+
else arrangement.loop = looped;
|
|
5533
|
+
}
|
|
4365
5534
|
return Object.freeze({
|
|
4366
5535
|
format: "track.loop/v1",
|
|
4367
5536
|
version: 1,
|
|
@@ -4383,7 +5552,9 @@ export function song(input: SongInput): Song {
|
|
|
4383
5552
|
tracks: Object.freeze(tracks),
|
|
4384
5553
|
notes: Object.freeze(notes),
|
|
4385
5554
|
...(master ? { master } : {}),
|
|
5555
|
+
...songStyle(input.style),
|
|
4386
5556
|
...arrangement,
|
|
5557
|
+
...(calibration ? { calibration } : {}),
|
|
4387
5558
|
});
|
|
4388
5559
|
}
|
|
4389
5560
|
|
|
@@ -4896,7 +6067,7 @@ function songTime(
|
|
|
4896
6067
|
* an object with at most one table source (`edo`, `ratios`, `cents` or
|
|
4897
6068
|
* `scl`). Library names: `12-tet`, `19-edo`, `24-edo`, `31-edo`,
|
|
4898
6069
|
* `pythagorean`, `just` (5-limit), `7-limit`, `well-tuned-piano`, `pelog`,
|
|
4899
|
-
* `slendro`, `nyamaropa`, `shruti`, maqam and dastgah sets (`bayati`,
|
|
6070
|
+
* `slendro`, `nyamaropa`, `thai`, `shruti`, maqam and dastgah sets (`bayati`,
|
|
4900
6071
|
* `rast`, `saba`, `shur`, `homayoun`, `chahargah`) and raga intonations
|
|
4901
6072
|
* (`yaman`, `bhairav`, `kafi`, `todi`, …); `dawg` lists them with
|
|
4902
6073
|
* `/tuning list`. dawg checks every value when the song loads.
|
|
@@ -5296,6 +6467,298 @@ export function strum(
|
|
|
5296
6467
|
return progression(chords, { ...options, perform: "guitar" });
|
|
5297
6468
|
}
|
|
5298
6469
|
|
|
6470
|
+
// BEGIN lyrics: generated from core/lyrics.ts by core/sdk/sync-lyrics.ts
|
|
6471
|
+
/** Longest lyric on one note (SCORE_LIMITS.maxLyricLength). */
|
|
6472
|
+
const LYRIC_LIMIT = 32;
|
|
6473
|
+
|
|
6474
|
+
/** One lyric token: a syllable, a held note (`_`) or a skipped note (`~`). */
|
|
6475
|
+
type LyricToken = Readonly<{
|
|
6476
|
+
syl: string;
|
|
6477
|
+
/** Index of the word the token belongs to. */
|
|
6478
|
+
word: number;
|
|
6479
|
+
/** First syllable of its word. */
|
|
6480
|
+
first: boolean;
|
|
6481
|
+
kind: "syl" | "hold" | "rest";
|
|
6482
|
+
}>;
|
|
6483
|
+
|
|
6484
|
+
/**
|
|
6485
|
+
* The lyric grammar: words split by spaces, syllables by `-`, `_` holds the
|
|
6486
|
+
* previous syllable over the next note (melisma), `~` skips a note.
|
|
6487
|
+
* "sun-lit morn-ing _ glow" gives sun lit morn ing _ glow.
|
|
6488
|
+
*/
|
|
6489
|
+
function parseLyric(text: string): LyricToken[] {
|
|
6490
|
+
const out: LyricToken[] = [];
|
|
6491
|
+
let word = -1;
|
|
6492
|
+
for (const raw of text.trim().split(/\s+/u)) {
|
|
6493
|
+
if (raw === "") continue;
|
|
6494
|
+
if (raw === "_") out.push({ syl: "_", word, first: false, kind: "hold" });
|
|
6495
|
+
else if (raw === "~")
|
|
6496
|
+
out.push({ syl: "~", word, first: false, kind: "rest" });
|
|
6497
|
+
else {
|
|
6498
|
+
word += 1;
|
|
6499
|
+
raw
|
|
6500
|
+
.split("-")
|
|
6501
|
+
.filter(Boolean)
|
|
6502
|
+
.forEach((syl, index) =>
|
|
6503
|
+
out.push({ syl, word, first: index === 0, kind: "syl" }),
|
|
6504
|
+
);
|
|
6505
|
+
}
|
|
6506
|
+
}
|
|
6507
|
+
return out;
|
|
6508
|
+
}
|
|
6509
|
+
|
|
6510
|
+
const VOWEL = /[aeiouàáâäèéêëìíîïòóôöùúûü]/u;
|
|
6511
|
+
/** Consonant pairs that sound as one consonant and are never split. */
|
|
6512
|
+
const SYL_DIGRAPHS = new Set(["th", "sh", "ch", "ph", "wh", "ng", "ck", "gh"]);
|
|
6513
|
+
/** Digraphs that end a syllable (no English word starts with them). */
|
|
6514
|
+
const SYL_CODA_ONLY = new Set(["ng", "ck", "gh", "x"]);
|
|
6515
|
+
/** Consonant clusters a syllable may start with (maximal onset). */
|
|
6516
|
+
const SYL_ONSETS = new Set(
|
|
6517
|
+
(
|
|
6518
|
+
"bl br cl cr dr fl fr gl gr pl pr sc sk sl sm sn sp st sw tr tw dw " +
|
|
6519
|
+
"thr shr chr phr phl spl spr str scr squ skr"
|
|
6520
|
+
).split(" "),
|
|
6521
|
+
);
|
|
6522
|
+
/** Common words ending in a silent `e` that start compounds (some-thing). */
|
|
6523
|
+
const SYL_SILENT_E_HEADS = (
|
|
6524
|
+
"some home life time love fire side care where there here more one " +
|
|
6525
|
+
"make lone like name game base wide grace face place space stone bone"
|
|
6526
|
+
).split(" ");
|
|
6527
|
+
/** Suffixes kept whole after a silent `e` (love-ly, care-ful). */
|
|
6528
|
+
const SYL_SUFFIXES = ["ly", "ful", "less", "ness", "ment"];
|
|
6529
|
+
/** Unstressed endings that close a short vowel before them (nev-er). */
|
|
6530
|
+
const SYL_CLOSING_ENDINGS = new Set([
|
|
6531
|
+
"er",
|
|
6532
|
+
"en",
|
|
6533
|
+
"el",
|
|
6534
|
+
"et",
|
|
6535
|
+
"ed",
|
|
6536
|
+
"es",
|
|
6537
|
+
"est",
|
|
6538
|
+
"ing",
|
|
6539
|
+
]);
|
|
6540
|
+
|
|
6541
|
+
/**
|
|
6542
|
+
* Syllables of a word typed without hyphens: a guess for English, which a
|
|
6543
|
+
* hyphen always overrides (`nev-er`). Each run of vowels (and `y` after a
|
|
6544
|
+
* consonant) is one syllable; a final silent `e` does not count, but a
|
|
6545
|
+
* consonant plus `le` is its own syllable (lit-tle, ta-ble). Consonant
|
|
6546
|
+
* pairs that sound as one (th sh ch ph wh ng ck gh) never split. Between
|
|
6547
|
+
* vowels a cluster gives the next syllable the longest onset English
|
|
6548
|
+
* allows (mon-ster, chil-dren); one consonant goes with the next vowel
|
|
6549
|
+
* (ba-by, to-night) unless the previous vowel is short before an
|
|
6550
|
+
* unstressed ending (nev-er, sing-ing). "something" gives some thing,
|
|
6551
|
+
* "forever" for ev er.
|
|
6552
|
+
*/
|
|
6553
|
+
function autoSyllabify(word: string): string[] {
|
|
6554
|
+
const w = word.toLowerCase();
|
|
6555
|
+
// Compounds and suffixes after a silent e: some-thing, love-ly.
|
|
6556
|
+
if (w.length >= 6) {
|
|
6557
|
+
for (const head of SYL_SILENT_E_HEADS)
|
|
6558
|
+
if (w.startsWith(head) && VOWEL.test(w.slice(head.length)))
|
|
6559
|
+
return [
|
|
6560
|
+
word.slice(0, head.length),
|
|
6561
|
+
...autoSyllabify(word.slice(head.length)),
|
|
6562
|
+
];
|
|
6563
|
+
for (const suffix of SYL_SUFFIXES) {
|
|
6564
|
+
const stem = w.slice(0, -suffix.length);
|
|
6565
|
+
if (
|
|
6566
|
+
w.endsWith(suffix) &&
|
|
6567
|
+
stem.length >= 3 &&
|
|
6568
|
+
stem.endsWith("e") &&
|
|
6569
|
+
!VOWEL.test(stem[stem.length - 2]!)
|
|
6570
|
+
)
|
|
6571
|
+
return [
|
|
6572
|
+
...autoSyllabify(word.slice(0, stem.length)),
|
|
6573
|
+
word.slice(stem.length),
|
|
6574
|
+
];
|
|
6575
|
+
}
|
|
6576
|
+
}
|
|
6577
|
+
// Letters into units: a vowel, a consonant, a digraph, or `qu`.
|
|
6578
|
+
type Unit = { at: number; text: string; vowel: boolean };
|
|
6579
|
+
const units: Unit[] = [];
|
|
6580
|
+
for (let i = 0; i < w.length;) {
|
|
6581
|
+
const pair = w.slice(i, i + 2);
|
|
6582
|
+
if (pair === "qu" || SYL_DIGRAPHS.has(pair)) {
|
|
6583
|
+
units.push({ at: i, text: pair, vowel: false });
|
|
6584
|
+
i += 2;
|
|
6585
|
+
continue;
|
|
6586
|
+
}
|
|
6587
|
+
const ch = w[i]!;
|
|
6588
|
+
const prev = units.at(-1);
|
|
6589
|
+
const vowel =
|
|
6590
|
+
VOWEL.test(ch) || (ch === "y" && prev !== undefined && !prev.vowel);
|
|
6591
|
+
units.push({ at: i, text: ch, vowel });
|
|
6592
|
+
i += 1;
|
|
6593
|
+
}
|
|
6594
|
+
// Vowel groups as [first unit, last unit].
|
|
6595
|
+
const groups: [number, number][] = [];
|
|
6596
|
+
for (let u = 0; u < units.length;) {
|
|
6597
|
+
if (units[u]!.vowel) {
|
|
6598
|
+
let v = u;
|
|
6599
|
+
while (v + 1 < units.length && units[v + 1]!.vowel) v += 1;
|
|
6600
|
+
groups.push([u, v]);
|
|
6601
|
+
u = v + 1;
|
|
6602
|
+
} else u += 1;
|
|
6603
|
+
}
|
|
6604
|
+
const lastUnit = units.length - 1;
|
|
6605
|
+
const finalLe =
|
|
6606
|
+
w.endsWith("le") &&
|
|
6607
|
+
units.length >= 3 &&
|
|
6608
|
+
units[lastUnit - 1]!.text === "l" &&
|
|
6609
|
+
!units[lastUnit - 2]!.vowel;
|
|
6610
|
+
const last = groups.at(-1);
|
|
6611
|
+
if (
|
|
6612
|
+
groups.length > 1 &&
|
|
6613
|
+
last &&
|
|
6614
|
+
last[0] === lastUnit &&
|
|
6615
|
+
last[1] === lastUnit &&
|
|
6616
|
+
units[lastUnit]!.text === "e" &&
|
|
6617
|
+
!units[lastUnit - 1]!.vowel &&
|
|
6618
|
+
!finalLe
|
|
6619
|
+
)
|
|
6620
|
+
groups.pop();
|
|
6621
|
+
if (groups.length <= 1) return [word];
|
|
6622
|
+
const cuts: number[] = [];
|
|
6623
|
+
for (let g = 1; g < groups.length; g += 1) {
|
|
6624
|
+
const prev = groups[g - 1]!;
|
|
6625
|
+
const next = groups[g]!;
|
|
6626
|
+
const cluster = units.slice(prev[1] + 1, next[0]);
|
|
6627
|
+
const n = cluster.length;
|
|
6628
|
+
const isLast = g === groups.length - 1;
|
|
6629
|
+
let onset: number; // units of the cluster that start the next syllable
|
|
6630
|
+
if (isLast && finalLe && n >= 2)
|
|
6631
|
+
onset = cluster[n - 2]!.text === "ck" ? 1 : 2;
|
|
6632
|
+
else if (n === 1) {
|
|
6633
|
+
const unit = cluster[0]!.text;
|
|
6634
|
+
const prevText = units
|
|
6635
|
+
.slice(prev[0], prev[1] + 1)
|
|
6636
|
+
.map((u) => u.text)
|
|
6637
|
+
.join("");
|
|
6638
|
+
const ending = w.slice(units[next[0]]!.at);
|
|
6639
|
+
const short = prevText.length === 1 && "eiou".includes(prevText);
|
|
6640
|
+
const closes =
|
|
6641
|
+
SYL_CODA_ONLY.has(unit) ||
|
|
6642
|
+
(short && isLast && SYL_CLOSING_ENDINGS.has(ending)) ||
|
|
6643
|
+
(unit === "r" && short && units[next[0]]!.text === "e");
|
|
6644
|
+
onset = closes ? 0 : 1;
|
|
6645
|
+
} else {
|
|
6646
|
+
onset = 1;
|
|
6647
|
+
for (let k = n - 1; k >= 2; k -= 1)
|
|
6648
|
+
if (
|
|
6649
|
+
SYL_ONSETS.has(
|
|
6650
|
+
cluster
|
|
6651
|
+
.slice(n - k)
|
|
6652
|
+
.map((u) => u.text)
|
|
6653
|
+
.join(""),
|
|
6654
|
+
)
|
|
6655
|
+
) {
|
|
6656
|
+
onset = k;
|
|
6657
|
+
break;
|
|
6658
|
+
}
|
|
6659
|
+
if (SYL_CODA_ONLY.has(cluster[n - 1]!.text)) onset = 0;
|
|
6660
|
+
}
|
|
6661
|
+
const first = units[next[0] - onset]!;
|
|
6662
|
+
cuts.push(onset === 0 ? units[next[0]]!.at : first.at);
|
|
6663
|
+
}
|
|
6664
|
+
const out: string[] = [];
|
|
6665
|
+
let at = 0;
|
|
6666
|
+
for (const cut of cuts) {
|
|
6667
|
+
out.push(word.slice(at, cut));
|
|
6668
|
+
at = cut;
|
|
6669
|
+
}
|
|
6670
|
+
out.push(word.slice(at));
|
|
6671
|
+
return out.filter(Boolean);
|
|
6672
|
+
}
|
|
6673
|
+
|
|
6674
|
+
/** What `assignLyrics` put on each note, and what did not fit. */
|
|
6675
|
+
type LyricAssignment = Readonly<{
|
|
6676
|
+
/** Note id to its lyric (`_` holds); notes `~` skipped are absent. */
|
|
6677
|
+
lyrics: ReadonlyMap<string, string>;
|
|
6678
|
+
/** Syllables left over after the last note. */
|
|
6679
|
+
dropped: readonly string[];
|
|
6680
|
+
/** Words split automatically. */
|
|
6681
|
+
split: readonly string[];
|
|
6682
|
+
}>;
|
|
6683
|
+
|
|
6684
|
+
/**
|
|
6685
|
+
* Lyrics onto `notes` in time order. Hyphens split syllables as typed;
|
|
6686
|
+
* when the text has fewer syllables than there are notes, words typed
|
|
6687
|
+
* whole are split by `autoSyllabify`, and any notes still left hold the
|
|
6688
|
+
* last syllable (melisma) instead of failing. Syllables past the last note
|
|
6689
|
+
* are reported in `dropped`. Notes sharing an onset take one token, on
|
|
6690
|
+
* the top note, with `_` on the others. Each lyric is cut to the 32-character limit.
|
|
6691
|
+
*/
|
|
6692
|
+
function assignLyrics(
|
|
6693
|
+
text: string,
|
|
6694
|
+
notes: readonly Readonly<{ id: string; startTick: number; pitch: number }>[],
|
|
6695
|
+
): LyricAssignment {
|
|
6696
|
+
const sorted = [...notes].sort(
|
|
6697
|
+
(a, b) => a.startTick - b.startTick || b.pitch - a.pitch,
|
|
6698
|
+
);
|
|
6699
|
+
// Notes sharing an onset (a chord or a doubled note) take one token: the
|
|
6700
|
+
// top note carries it and the rest hold.
|
|
6701
|
+
const ordered: (typeof sorted)[number][] = [];
|
|
6702
|
+
const under = new Map<string, string[]>();
|
|
6703
|
+
for (const note of sorted) {
|
|
6704
|
+
const top = ordered.at(-1);
|
|
6705
|
+
if (top && top.startTick === note.startTick)
|
|
6706
|
+
under.get(top.id)!.push(note.id);
|
|
6707
|
+
else {
|
|
6708
|
+
ordered.push(note);
|
|
6709
|
+
under.set(note.id, []);
|
|
6710
|
+
}
|
|
6711
|
+
}
|
|
6712
|
+
let tokens = parseLyric(text);
|
|
6713
|
+
const split: string[] = [];
|
|
6714
|
+
if (tokens.length < ordered.length) {
|
|
6715
|
+
// Split every word typed whole; keep the split only if it still fits.
|
|
6716
|
+
const out: LyricToken[] = [];
|
|
6717
|
+
const words: string[] = [];
|
|
6718
|
+
for (const token of tokens) {
|
|
6719
|
+
const whole =
|
|
6720
|
+
token.kind === "syl" &&
|
|
6721
|
+
token.first &&
|
|
6722
|
+
!tokens.some((t) => t.word === token.word && !t.first);
|
|
6723
|
+
if (!whole) {
|
|
6724
|
+
out.push(token);
|
|
6725
|
+
continue;
|
|
6726
|
+
}
|
|
6727
|
+
const parts = autoSyllabify(token.syl);
|
|
6728
|
+
if (parts.length > 1) words.push(token.syl);
|
|
6729
|
+
parts.forEach((syl, index) =>
|
|
6730
|
+
out.push({ syl, word: token.word, first: index === 0, kind: "syl" }),
|
|
6731
|
+
);
|
|
6732
|
+
}
|
|
6733
|
+
if (out.length <= ordered.length) {
|
|
6734
|
+
tokens = out;
|
|
6735
|
+
split.push(...words);
|
|
6736
|
+
}
|
|
6737
|
+
}
|
|
6738
|
+
const lyrics = new Map<string, string>();
|
|
6739
|
+
const limit = LYRIC_LIMIT;
|
|
6740
|
+
ordered.forEach((note, index) => {
|
|
6741
|
+
const token = tokens[index];
|
|
6742
|
+
if (!token) {
|
|
6743
|
+
if (tokens.length > 0)
|
|
6744
|
+
for (const id of [note.id, ...under.get(note.id)!]) lyrics.set(id, "_");
|
|
6745
|
+
return;
|
|
6746
|
+
}
|
|
6747
|
+
if (token.kind === "rest") return;
|
|
6748
|
+
lyrics.set(
|
|
6749
|
+
note.id,
|
|
6750
|
+
token.kind === "hold" ? "_" : token.syl.slice(0, limit),
|
|
6751
|
+
);
|
|
6752
|
+
for (const id of under.get(note.id)!) lyrics.set(id, "_");
|
|
6753
|
+
});
|
|
6754
|
+
const dropped = tokens
|
|
6755
|
+
.slice(ordered.length)
|
|
6756
|
+
.filter((token) => token.kind === "syl")
|
|
6757
|
+
.map((token) => token.syl);
|
|
6758
|
+
return { lyrics, dropped, split };
|
|
6759
|
+
}
|
|
6760
|
+
// END lyrics
|
|
6761
|
+
|
|
5299
6762
|
// BEGIN instrument words: generated from core/instruments.ts by core/sdk/sync-instruments.ts
|
|
5300
6763
|
/** What an instrument word stores on a track. */
|
|
5301
6764
|
type InstrumentWord = Readonly<{
|
|
@@ -5695,6 +7158,25 @@ const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
|
|
|
5695
7158
|
{ word: "frenchhorn", instrument: "wind", field: "wind", preset: "horn" },
|
|
5696
7159
|
{ word: "mutedtrumpet", instrument: "wind", field: "wind", preset: "harmon" },
|
|
5697
7160
|
{ word: "wahtrumpet", instrument: "wind", field: "wind", preset: "plunger" },
|
|
7161
|
+
// f07-sing: the built-in singing voice (core/sing.ts).
|
|
7162
|
+
{ word: "sing", instrument: "sing", field: "sing" },
|
|
7163
|
+
{ word: "aah", instrument: "sing", field: "sing", preset: "aah" },
|
|
7164
|
+
{ word: "ooh", instrument: "sing", field: "sing", preset: "ooh" },
|
|
7165
|
+
{ word: "choir", instrument: "sing", field: "sing", preset: "choir" },
|
|
7166
|
+
{ word: "chorale", instrument: "sing", field: "sing", preset: "chorale" },
|
|
7167
|
+
{ word: "khoomei", instrument: "sing", field: "sing", preset: "khoomei" },
|
|
7168
|
+
{ word: "sygyt", instrument: "sing", field: "sing", preset: "sygyt" },
|
|
7169
|
+
{ word: "kargyraa", instrument: "sing", field: "sing", preset: "kargyraa" },
|
|
7170
|
+
// f07-clips: a track of audio clips; its notes are guides.
|
|
7171
|
+
{ word: "vocal", instrument: "vocal" },
|
|
7172
|
+
// f07-vocoder: the built-in carrier (core/vocoder.ts); set a source with
|
|
7173
|
+
// `/vocoder src <track>`.
|
|
7174
|
+
{
|
|
7175
|
+
word: "vocoder",
|
|
7176
|
+
instrument: "vocoder",
|
|
7177
|
+
field: "vocoder",
|
|
7178
|
+
preset: "classic",
|
|
7179
|
+
},
|
|
5698
7180
|
]);
|
|
5699
7181
|
|
|
5700
7182
|
/**
|
|
@@ -5750,6 +7232,7 @@ const QUALITIES = [
|
|
|
5750
7232
|
"mb6",
|
|
5751
7233
|
"b6",
|
|
5752
7234
|
"7#9",
|
|
7235
|
+
"b5",
|
|
5753
7236
|
] as const;
|
|
5754
7237
|
type Quality = (typeof QUALITIES)[number];
|
|
5755
7238
|
|
|
@@ -5766,6 +7249,7 @@ const QUALITY_INTERVALS: Readonly<Record<Quality, readonly number[]>> =
|
|
|
5766
7249
|
mb6: [0, 3, 7, 8],
|
|
5767
7250
|
b6: [0, 4, 7, 8],
|
|
5768
7251
|
"7#9": [0, 4, 7, 10, 15],
|
|
7252
|
+
b5: [0, 4, 6],
|
|
5769
7253
|
});
|
|
5770
7254
|
|
|
5771
7255
|
/**
|
|
@@ -5818,6 +7302,11 @@ type Chord = Readonly<{
|
|
|
5818
7302
|
* such as `C11`, `G13` or `A7b9` carry them (0.6.1).
|
|
5819
7303
|
*/
|
|
5820
7304
|
tensions?: readonly number[] | undefined;
|
|
7305
|
+
/**
|
|
7306
|
+
* The root's letter, 0..6 for C..B, when a roman numeral spelled it:
|
|
7307
|
+
* `bVII` in C names Bb (not A#) and `vii` in F# names E# (not F).
|
|
7308
|
+
*/
|
|
7309
|
+
letter?: number | undefined;
|
|
5821
7310
|
}>;
|
|
5822
7311
|
|
|
5823
7312
|
function makeChord(
|
|
@@ -5896,6 +7385,25 @@ function noteName(pc: number, flats = false): string {
|
|
|
5896
7385
|
return (flats ? FLAT_NAMES : SHARP_NAMES)[mod12(pc)]!;
|
|
5897
7386
|
}
|
|
5898
7387
|
|
|
7388
|
+
const LETTERS = "CDEFGAB";
|
|
7389
|
+
const LETTER_PCS = [0, 2, 4, 5, 7, 9, 11] as const;
|
|
7390
|
+
|
|
7391
|
+
/**
|
|
7392
|
+
* `pc` spelled on letter `letter` (0..6, C..B) with one accidental at
|
|
7393
|
+
* most (E#, Cb, Bb); undefined when that would need a double accidental.
|
|
7394
|
+
*/
|
|
7395
|
+
function spellOnLetter(pc: number, letter: number): string | undefined {
|
|
7396
|
+
const l = ((Math.trunc(letter) % 7) + 7) % 7;
|
|
7397
|
+
const diff = ((mod12(pc) - LETTER_PCS[l]! + 18) % 12) - 6;
|
|
7398
|
+
if (Math.abs(diff) > 1) return undefined;
|
|
7399
|
+
return `${LETTERS[l]}${diff === 1 ? "#" : diff === -1 ? "b" : ""}`;
|
|
7400
|
+
}
|
|
7401
|
+
|
|
7402
|
+
/** The letter (0..6, C..B) a key's tonic is spelled on. */
|
|
7403
|
+
function tonicLetter(key: Key): number {
|
|
7404
|
+
return LETTERS.indexOf(noteName(key.tonic, keyUsesFlats(key))[0]!);
|
|
7405
|
+
}
|
|
7406
|
+
|
|
5899
7407
|
const SECRET_SUFFIX: Readonly<Partial<Record<Quality, string>>> = Object.freeze(
|
|
5900
7408
|
{ madd4: "m(add4)", mb6: "m(b6)", b6: "(b6)", "7#9": "7#9" },
|
|
5901
7409
|
);
|
|
@@ -5974,6 +7482,9 @@ function chordSuffix(chord: Chord): string {
|
|
|
5974
7482
|
base = `${seventh}(no3)`;
|
|
5975
7483
|
if (nine) extras.push("9");
|
|
5976
7484
|
break;
|
|
7485
|
+
case "b5":
|
|
7486
|
+
base = `${ninth}b5`;
|
|
7487
|
+
break;
|
|
5977
7488
|
default:
|
|
5978
7489
|
base = seventh; // secret qualities returned above
|
|
5979
7490
|
}
|
|
@@ -5992,6 +7503,7 @@ function chordSuffix(chord: Chord): string {
|
|
|
5992
7503
|
mb6: "m(b6)",
|
|
5993
7504
|
b6: "(b6)",
|
|
5994
7505
|
"7#9": "7#9",
|
|
7506
|
+
b5: "(b5)",
|
|
5995
7507
|
};
|
|
5996
7508
|
base = triad[q];
|
|
5997
7509
|
if (six && nine && (q === "maj" || q === "min")) return `${base}6/9`;
|
|
@@ -6011,7 +7523,11 @@ function chordSuffix(chord: Chord): string {
|
|
|
6011
7523
|
function chordName(chord: Chord, flats = false): string {
|
|
6012
7524
|
const slash =
|
|
6013
7525
|
chord.bass === undefined ? "" : `/${noteName(chord.bass, flats)}`;
|
|
6014
|
-
|
|
7526
|
+
const root =
|
|
7527
|
+
(chord.letter === undefined
|
|
7528
|
+
? undefined
|
|
7529
|
+
: spellOnLetter(chord.root, chord.letter)) ?? noteName(chord.root, flats);
|
|
7530
|
+
return `${root}${chordSuffix(chord)}${slash}`;
|
|
6015
7531
|
}
|
|
6016
7532
|
|
|
6017
7533
|
/** Suffix → quality and extensions, longest first when parsing. */
|
|
@@ -6076,6 +7592,31 @@ const SUFFIXES: readonly (readonly [string, Quality, readonly Extension[]])[] =
|
|
|
6076
7592
|
["(b6)", "b6", []],
|
|
6077
7593
|
["addb6", "b6", []],
|
|
6078
7594
|
["7#9", "7#9", []],
|
|
7595
|
+
// 0.7: more spellings of the same chords.
|
|
7596
|
+
["-maj7", "min", ["M7"]],
|
|
7597
|
+
["mmaj7", "min", ["M7"]],
|
|
7598
|
+
["mMaj7", "min", ["M7"]],
|
|
7599
|
+
["m(maj7)", "min", ["M7"]],
|
|
7600
|
+
["-Δ7", "min", ["M7"]],
|
|
7601
|
+
["mΔ7", "min", ["M7"]],
|
|
7602
|
+
["add2", "maj", ["9"]],
|
|
7603
|
+
["2", "maj", ["9"]],
|
|
7604
|
+
["madd2", "min", ["9"]],
|
|
7605
|
+
["6add9", "maj", ["6", "9"]],
|
|
7606
|
+
["m6add9", "min", ["6", "9"]],
|
|
7607
|
+
["-6", "min", ["6"]],
|
|
7608
|
+
["-9", "min", ["m7", "9"]],
|
|
7609
|
+
["dom9", "maj", ["m7", "9"]],
|
|
7610
|
+
["aug(maj7)", "aug", ["M7"]],
|
|
7611
|
+
["augmaj7", "aug", ["M7"]],
|
|
7612
|
+
["+maj7", "aug", ["M7"]],
|
|
7613
|
+
["aug9", "aug", ["m7", "9"]],
|
|
7614
|
+
["+9", "aug", ["m7", "9"]],
|
|
7615
|
+
["m(maj9)", "min", ["M7", "9"]],
|
|
7616
|
+
["mmaj9", "min", ["M7", "9"]],
|
|
7617
|
+
["dim(maj7)", "dim", ["M7"]],
|
|
7618
|
+
["7(no3)", "5", ["m7"]],
|
|
7619
|
+
["maj7(no3)", "5", ["M7"]],
|
|
6079
7620
|
];
|
|
6080
7621
|
|
|
6081
7622
|
/** Typed upper-tension chords (0.6.1): suffix, quality, buttons, tensions. */
|
|
@@ -6110,10 +7651,7 @@ const TENSION_NAMES: Readonly<Record<number, string>> = Object.freeze({
|
|
|
6110
7651
|
21: "13",
|
|
6111
7652
|
});
|
|
6112
7653
|
|
|
6113
|
-
const SUFFIX_TABLE = new Map<
|
|
6114
|
-
string,
|
|
6115
|
-
{ quality: Quality; ext: readonly Extension[]; tensions?: readonly number[] }
|
|
6116
|
-
>([
|
|
7654
|
+
const SUFFIX_TABLE = new Map<string, SuffixEntry>([
|
|
6117
7655
|
...SUFFIXES.map(
|
|
6118
7656
|
([suffix, quality, ext]) => [suffix, { quality, ext }] as const,
|
|
6119
7657
|
),
|
|
@@ -6146,6 +7684,81 @@ function parsePitchClass(text: string): number | undefined {
|
|
|
6146
7684
|
return mod12(LETTER[match[1]!.toLowerCase()]! + accidental);
|
|
6147
7685
|
}
|
|
6148
7686
|
|
|
7687
|
+
/** Alteration → upper tension (semitones above the root). */
|
|
7688
|
+
const ALTERATION_TENSION: Readonly<Record<string, number>> = Object.freeze({
|
|
7689
|
+
b9: 13,
|
|
7690
|
+
"#9": 15,
|
|
7691
|
+
"11": 17,
|
|
7692
|
+
"#11": 18,
|
|
7693
|
+
"+11": 18,
|
|
7694
|
+
b13: 20,
|
|
7695
|
+
"13": 21,
|
|
7696
|
+
});
|
|
7697
|
+
|
|
7698
|
+
const TOKEN = "b5|#5|\\+5|b9|#9|#11|\\+11|b13|alt|add9|add11|add13|11|13|9|6";
|
|
7699
|
+
/** Bare alterations or parenthesized comma lists of them, in any order. */
|
|
7700
|
+
const ALTERATIONS = new RegExp(
|
|
7701
|
+
`^(?:(?:${TOKEN})|\\((?:${TOKEN})(?:,(?:${TOKEN}))*\\))+$`,
|
|
7702
|
+
);
|
|
7703
|
+
|
|
7704
|
+
type SuffixEntry = {
|
|
7705
|
+
quality: Quality;
|
|
7706
|
+
ext: readonly Extension[];
|
|
7707
|
+
tensions?: readonly number[];
|
|
7708
|
+
};
|
|
7709
|
+
|
|
7710
|
+
/**
|
|
7711
|
+
* A suffix the table does not list, read as a listed base followed by
|
|
7712
|
+
* alterations, bare or in parentheses: `7#5`, `9#11`, `13#11`, `7b9b13`,
|
|
7713
|
+
* `9b5`, `maj7+5`, `7alt`, `7(b9,#9)`. A raised fifth makes the triad
|
|
7714
|
+
* augmented, a lowered one makes a major triad `b5` (a minor one
|
|
7715
|
+
* diminished); `alt` is b9, #9, #11 and b13 over a dominant seventh.
|
|
7716
|
+
*/
|
|
7717
|
+
function parseAlteredSuffix(suffix: string): SuffixEntry | undefined {
|
|
7718
|
+
for (let cut = suffix.length - 1; cut >= 0; cut -= 1) {
|
|
7719
|
+
const base = SUFFIX_TABLE.get(suffix.slice(0, cut));
|
|
7720
|
+
if (!base) continue;
|
|
7721
|
+
const raw = suffix.slice(cut);
|
|
7722
|
+
if (!ALTERATIONS.test(raw)) continue;
|
|
7723
|
+
const rest = raw.replace(/[(),]/g, "");
|
|
7724
|
+
const tokens = rest.match(
|
|
7725
|
+
/b5|#5|\+5|b9|#9|#11|\+11|b13|alt|add9|add11|add13|11|13|9|6/g,
|
|
7726
|
+
);
|
|
7727
|
+
if (!tokens || tokens.join("") !== rest) continue;
|
|
7728
|
+
let quality: Quality = base.quality;
|
|
7729
|
+
const ext = new Set<Extension>(base.ext);
|
|
7730
|
+
const tensions = new Set<number>(base.tensions ?? []);
|
|
7731
|
+
let ok = true;
|
|
7732
|
+
for (const token of tokens) {
|
|
7733
|
+
if (token === "#5" || token === "+5") {
|
|
7734
|
+
if (quality === "maj" || quality === "aug") quality = "aug";
|
|
7735
|
+
else ok = false;
|
|
7736
|
+
} else if (token === "b5") {
|
|
7737
|
+
if (quality === "maj" || quality === "b5") quality = "b5";
|
|
7738
|
+
else if (quality === "min" || quality === "dim") quality = "dim";
|
|
7739
|
+
else ok = false;
|
|
7740
|
+
} else if (token === "alt") {
|
|
7741
|
+
if (quality !== "maj") ok = false;
|
|
7742
|
+
ext.add("m7");
|
|
7743
|
+
for (const step of [13, 15, 18, 20]) tensions.add(step);
|
|
7744
|
+
} else if (token === "9" || token === "add9") ext.add("9");
|
|
7745
|
+
else if (token === "6") ext.add("6");
|
|
7746
|
+
else {
|
|
7747
|
+
const step = ALTERATION_TENSION[token.replace(/^add/, "")];
|
|
7748
|
+
if (step === undefined) ok = false;
|
|
7749
|
+
else tensions.add(step);
|
|
7750
|
+
}
|
|
7751
|
+
}
|
|
7752
|
+
if (!ok) continue;
|
|
7753
|
+
return {
|
|
7754
|
+
quality,
|
|
7755
|
+
ext: [...ext],
|
|
7756
|
+
tensions: [...tensions].sort((a, b) => a - b),
|
|
7757
|
+
};
|
|
7758
|
+
}
|
|
7759
|
+
return undefined;
|
|
7760
|
+
}
|
|
7761
|
+
|
|
6149
7762
|
/** Parse a chord symbol (`Cm7`, `F#dim`, `Bbmaj9`, `G7sus4`, `C/E`). */
|
|
6150
7763
|
function parseChord(symbol: string): Chord | undefined {
|
|
6151
7764
|
if (typeof symbol !== "string" || symbol.length > 24) return undefined;
|
|
@@ -6156,7 +7769,8 @@ function parseChord(symbol: string): Chord | undefined {
|
|
|
6156
7769
|
const match = trimmed.match(/^([A-Ga-g])(#|b|♯|♭)?([^/]*)(?:\/(.+))?$/);
|
|
6157
7770
|
if (!match) return undefined;
|
|
6158
7771
|
const root = parsePitchClass(`${match[1]}${match[2] ?? ""}`);
|
|
6159
|
-
const entry =
|
|
7772
|
+
const entry =
|
|
7773
|
+
SUFFIX_TABLE.get(match[3] ?? "") ?? parseAlteredSuffix(match[3] ?? "");
|
|
6160
7774
|
if (root === undefined || !entry) return undefined;
|
|
6161
7775
|
let bass: number | undefined;
|
|
6162
7776
|
if (match[4] !== undefined) {
|
|
@@ -6216,7 +7830,15 @@ const MODE_ALIASES: Readonly<Record<string, ModeName>> = Object.freeze({
|
|
|
6216
7830
|
});
|
|
6217
7831
|
|
|
6218
7832
|
type ScaleFamily =
|
|
6219
|
-
|
|
7833
|
+
| "pentatonic"
|
|
7834
|
+
| "blues"
|
|
7835
|
+
| "maqam"
|
|
7836
|
+
| "dastgah"
|
|
7837
|
+
| "raga"
|
|
7838
|
+
| "messiaen"
|
|
7839
|
+
| "chromatic"
|
|
7840
|
+
| "overtone"
|
|
7841
|
+
| "quarter-tone";
|
|
6220
7842
|
|
|
6221
7843
|
/**
|
|
6222
7844
|
* Scales beyond the chord modes, for keys such as `D bayati`, `C yaman` or
|
|
@@ -6263,6 +7885,12 @@ const SCALES = Object.freeze({
|
|
|
6263
7885
|
family: "blues",
|
|
6264
7886
|
aliases: ["major blues"],
|
|
6265
7887
|
},
|
|
7888
|
+
"yonanuki-minor": {
|
|
7889
|
+
steps: [0, 2, 3, 7, 8],
|
|
7890
|
+
mode: "minor",
|
|
7891
|
+
family: "pentatonic",
|
|
7892
|
+
aliases: ["yonanuki", "yonanuki minor", "enka minor"],
|
|
7893
|
+
},
|
|
6266
7894
|
hijaz: {
|
|
6267
7895
|
steps: [0, 1, 4, 5, 7, 8, 10],
|
|
6268
7896
|
mode: "phrygian-dominant",
|
|
@@ -6439,6 +8067,32 @@ const SCALES = Object.freeze({
|
|
|
6439
8067
|
mode: "harmonic-minor",
|
|
6440
8068
|
family: "messiaen",
|
|
6441
8069
|
},
|
|
8070
|
+
// All twelve pitch classes: the field of free atonality and the row.
|
|
8071
|
+
chromatic: {
|
|
8072
|
+
steps: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
|
8073
|
+
mode: "minor",
|
|
8074
|
+
family: "chromatic",
|
|
8075
|
+
aliases: ["twelve-tone", "aggregate"],
|
|
8076
|
+
},
|
|
8077
|
+
// Partials 8 to 15 of the harmonic series over the tonic (Grisey,
|
|
8078
|
+
// Murail): the acoustic scale with the 11th and 13th partials' and the
|
|
8079
|
+
// 7th's just pitches, which its named tuning applies.
|
|
8080
|
+
"harmonic-series": {
|
|
8081
|
+
steps: [0, 2, 4, 6, 7, 9, 10, 11],
|
|
8082
|
+
mode: "mixolydian",
|
|
8083
|
+
family: "overtone",
|
|
8084
|
+
intonation: [0, 204, 386, 551, 702, 841, 969, 1088],
|
|
8085
|
+
aliases: ["overtone", "overtone-scale", "partials"],
|
|
8086
|
+
},
|
|
8087
|
+
// Each tempered degree of the major scale beside its quarter-tone
|
|
8088
|
+
// shadow (Haba, Wyschnegradsky): neutral 2nd, 3rd, 6th and 7th and a
|
|
8089
|
+
// quarter-sharp 4th, sounded as note cents over twelve-tone keys.
|
|
8090
|
+
"quarter-tone": {
|
|
8091
|
+
steps: [0, 1.5, 2, 3.5, 4, 5, 5.5, 7, 8.5, 9, 10.5],
|
|
8092
|
+
mode: "major",
|
|
8093
|
+
family: "quarter-tone",
|
|
8094
|
+
aliases: ["quartertone", "24-tone"],
|
|
8095
|
+
},
|
|
6442
8096
|
} as const satisfies Record<string, ScaleInfo>);
|
|
6443
8097
|
type ScaleName = keyof typeof SCALES;
|
|
6444
8098
|
const SCALE_NAMES = Object.keys(SCALES) as ScaleName[];
|
|
@@ -6641,9 +8295,14 @@ function romanOf(key: Key, chord: Chord): string {
|
|
|
6641
8295
|
if (degree < 0) {
|
|
6642
8296
|
// Name chromatic roots against the major scale: bIII, #iv°.
|
|
6643
8297
|
const major = MODES.major.map((step) => mod12(key.tonic + step));
|
|
8298
|
+
const natural = major.indexOf(chord.root);
|
|
6644
8299
|
const flat = major.indexOf(mod12(chord.root + 1));
|
|
6645
8300
|
const sharp = major.indexOf(mod12(chord.root - 1));
|
|
6646
|
-
if (
|
|
8301
|
+
if (natural >= 0) {
|
|
8302
|
+
// A major-scale note the mode alters: ♮II in Phrygian.
|
|
8303
|
+
degree = natural;
|
|
8304
|
+
accidental = "♮";
|
|
8305
|
+
} else if (flat >= 0) {
|
|
6647
8306
|
degree = flat;
|
|
6648
8307
|
accidental = "b";
|
|
6649
8308
|
} else {
|
|
@@ -6654,7 +8313,6 @@ function romanOf(key: Key, chord: Chord): string {
|
|
|
6654
8313
|
const lower =
|
|
6655
8314
|
chord.quality === "min" ||
|
|
6656
8315
|
chord.quality === "dim" ||
|
|
6657
|
-
chord.quality === "5" ||
|
|
6658
8316
|
chord.quality === "madd4" ||
|
|
6659
8317
|
chord.quality === "mb6";
|
|
6660
8318
|
const numeral = NUMERALS[degree]!;
|
|
@@ -6671,7 +8329,33 @@ function romanOf(key: Key, chord: Chord): string {
|
|
|
6671
8329
|
: ext.has("M7")
|
|
6672
8330
|
? "maj7"
|
|
6673
8331
|
: "";
|
|
6674
|
-
|
|
8332
|
+
// The short numeral when it reads back as this chord; else the chord's
|
|
8333
|
+
// own suffix in brackets (`I[7]`, `i[m6]`, `V[7#9]`), which is exact.
|
|
8334
|
+
const plain = `${accidental}${body}${mark}${seventh}`;
|
|
8335
|
+
const bare = makeChord(
|
|
8336
|
+
chord.root,
|
|
8337
|
+
chord.quality,
|
|
8338
|
+
chord.extensions,
|
|
8339
|
+
undefined,
|
|
8340
|
+
chord.tensions,
|
|
8341
|
+
);
|
|
8342
|
+
for (const candidate of [
|
|
8343
|
+
plain,
|
|
8344
|
+
`${accidental}${body}${mark}${seventh === "7" ? "dom7" : seventh}`,
|
|
8345
|
+
])
|
|
8346
|
+
if (sameChord(parseRoman(key, candidate), bare)) return candidate;
|
|
8347
|
+
return `${accidental}${body}[${chordSuffix(bare)}]`;
|
|
8348
|
+
}
|
|
8349
|
+
|
|
8350
|
+
function sameChord(a: Chord | undefined, b: Chord): boolean {
|
|
8351
|
+
return (
|
|
8352
|
+
a !== undefined &&
|
|
8353
|
+
a.root === b.root &&
|
|
8354
|
+
a.quality === b.quality &&
|
|
8355
|
+
a.bass === b.bass &&
|
|
8356
|
+
a.extensions.join() === b.extensions.join() &&
|
|
8357
|
+
(a.tensions ?? []).join() === (b.tensions ?? []).join()
|
|
8358
|
+
);
|
|
6675
8359
|
}
|
|
6676
8360
|
|
|
6677
8361
|
/**
|
|
@@ -6684,31 +8368,64 @@ function romanOf(key: Key, chord: Chord): string {
|
|
|
6684
8368
|
* major). `7` adds the diatonic seventh; `maj7`/`M7` and `dom7` are exact.
|
|
6685
8369
|
*/
|
|
6686
8370
|
function parseRoman(key: Key, text: string): Chord | undefined {
|
|
6687
|
-
|
|
8371
|
+
return romanIn(key, text, tonicLetter(key));
|
|
8372
|
+
}
|
|
8373
|
+
|
|
8374
|
+
/** `parseRoman` with the tonic spelled on `tonic` (0..6, C..B). */
|
|
8375
|
+
function romanIn(key: Key, text: string, tonic: number): Chord | undefined {
|
|
8376
|
+
if (typeof text !== "string" || text.length > 32) return undefined;
|
|
6688
8377
|
const trimmed = text.trim();
|
|
8378
|
+
const exact = trimmed.match(
|
|
8379
|
+
/^(b|#|♭|♯|♮)?(vii|vi|v|iv|iii|ii|i|VII|VI|V|IV|III|II|I)\[([^\]]*)\]$/,
|
|
8380
|
+
);
|
|
8381
|
+
if (exact) {
|
|
8382
|
+
// `I[7]`: the numeral names the root, the bracket is a chord suffix.
|
|
8383
|
+
const degree = NUMERALS.indexOf(exact[2]!.toLowerCase());
|
|
8384
|
+
const shift =
|
|
8385
|
+
exact[1] === "b" || exact[1] === "♭"
|
|
8386
|
+
? -1
|
|
8387
|
+
: exact[1] === "♮" || !exact[1]
|
|
8388
|
+
? 0
|
|
8389
|
+
: 1;
|
|
8390
|
+
const root = !exact[1]
|
|
8391
|
+
? scaleOf(key)[degree]
|
|
8392
|
+
: mod12(key.tonic + MODES.major[degree]! + shift);
|
|
8393
|
+
if (root === undefined) return undefined;
|
|
8394
|
+
const letter = (tonic + degree) % 7;
|
|
8395
|
+
const chord = parseChord(
|
|
8396
|
+
`${spellOnLetter(root, letter) ?? noteName(root)}${exact[3]!}`,
|
|
8397
|
+
);
|
|
8398
|
+
return chord && Object.freeze({ ...chord, letter });
|
|
8399
|
+
}
|
|
6689
8400
|
const slash = trimmed.match(/^(.+)\/(.+)$/);
|
|
6690
8401
|
if (slash) {
|
|
6691
8402
|
// V/x: the chord built on the degree of x in the key (secondary function).
|
|
6692
|
-
const target =
|
|
8403
|
+
const target = romanIn(key, slash[2]!, tonic);
|
|
6693
8404
|
if (!target) return undefined;
|
|
6694
|
-
|
|
6695
|
-
|
|
8405
|
+
return romanIn(
|
|
8406
|
+
{ tonic: target.root, mode: "major" },
|
|
8407
|
+
slash[1]!,
|
|
8408
|
+
target.letter ?? tonic,
|
|
8409
|
+
);
|
|
6696
8410
|
}
|
|
6697
8411
|
const match = trimmed.match(
|
|
6698
|
-
/^(b
|
|
8412
|
+
/^(b|#|♭|♯|♮)?(vii|vi|v|iv|iii|ii|i|VII|VI|V|IV|III|II|I)(°|o|ø|\+)?(maj7|M7|dom7|7|9|maj9|6|sus4|sus2|sus|add9)?$/,
|
|
6699
8413
|
);
|
|
6700
8414
|
if (!match) return undefined;
|
|
6701
8415
|
const accidental =
|
|
6702
|
-
match[1] === "b" || match[1] === "♭"
|
|
8416
|
+
match[1] === "b" || match[1] === "♭"
|
|
8417
|
+
? -1
|
|
8418
|
+
: match[1] === "#" || match[1] === "♯"
|
|
8419
|
+
? 1
|
|
8420
|
+
: 0;
|
|
6703
8421
|
const numeral = match[2]!;
|
|
6704
8422
|
const lower = numeral === numeral.toLowerCase();
|
|
6705
8423
|
const degree = NUMERALS.indexOf(numeral.toLowerCase());
|
|
6706
8424
|
const mark = match[3];
|
|
6707
8425
|
const suffix = match[4] ?? "";
|
|
6708
|
-
const root =
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
: mod12(key.tonic + MODES.major[degree]! + accidental);
|
|
8426
|
+
const root = !match[1]
|
|
8427
|
+
? scaleOf(key)[degree]!
|
|
8428
|
+
: mod12(key.tonic + MODES.major[degree]! + accidental);
|
|
6712
8429
|
const inKey = degreeOf(key, root);
|
|
6713
8430
|
const triad = inKey === undefined ? undefined : diatonicChord(key, inKey);
|
|
6714
8431
|
const seventh =
|
|
@@ -6762,7 +8479,10 @@ function parseRoman(key: Key, text: string): Chord | undefined {
|
|
|
6762
8479
|
quality = "sus2";
|
|
6763
8480
|
break;
|
|
6764
8481
|
}
|
|
6765
|
-
return
|
|
8482
|
+
return Object.freeze({
|
|
8483
|
+
...makeChord(root, quality, ext),
|
|
8484
|
+
letter: (tonic + degree) % 7,
|
|
8485
|
+
});
|
|
6766
8486
|
}
|
|
6767
8487
|
|
|
6768
8488
|
// ---------------------------------------------------------------------------
|
|
@@ -6804,7 +8524,17 @@ function rotate(pitches: readonly number[], steps: number): number[] {
|
|
|
6804
8524
|
|
|
6805
8525
|
function rootPosition(chord: Chord, anchor = 60): number[] {
|
|
6806
8526
|
const rootPitch = anchor + mod12(chord.root - anchor);
|
|
6807
|
-
|
|
8527
|
+
const steps = chordIntervals(chord);
|
|
8528
|
+
// A natural 11 over a major 3rd and a 7th is the avoid-note clash (E
|
|
8529
|
+
// under F a minor 9th up in C11), so the voicing drops the 3rd: C11 is
|
|
8530
|
+
// C G Bb D F, the sus voicing; C13 already leaves the 11 out.
|
|
8531
|
+
const clash =
|
|
8532
|
+
steps.includes(4) &&
|
|
8533
|
+
steps.includes(17) &&
|
|
8534
|
+
(steps.includes(10) || steps.includes(11));
|
|
8535
|
+
return steps
|
|
8536
|
+
.filter((step) => !(clash && step === 4))
|
|
8537
|
+
.map((step) => rootPitch + step);
|
|
6808
8538
|
}
|
|
6809
8539
|
|
|
6810
8540
|
/** Open voicings: `open` drops the second voice from the top an octave
|
|
@@ -8160,6 +9890,362 @@ function renderProgression(options: RenderOptions): RenderedProgression {
|
|
|
8160
9890
|
}
|
|
8161
9891
|
// END chord engine
|
|
8162
9892
|
|
|
9893
|
+
// ---------------------------------------------------------------------------
|
|
9894
|
+
// Audio clips, takes and lyrics (SDK 1.32.0)
|
|
9895
|
+
|
|
9896
|
+
/** Options for `audio()`: times in beats, offsets and lengths in seconds. */
|
|
9897
|
+
export type AudioOptions = Readonly<{
|
|
9898
|
+
/** Stable clip id; defaults to `clip`, `clip2`, ... by position. */
|
|
9899
|
+
id?: string;
|
|
9900
|
+
/** Beat the clip starts on, default 0. */
|
|
9901
|
+
at?: number;
|
|
9902
|
+
/** Seconds into the file where the clip starts, default 0. */
|
|
9903
|
+
offset?: number;
|
|
9904
|
+
/** Seconds of the file it plays, default to the end. */
|
|
9905
|
+
dur?: number;
|
|
9906
|
+
/** Linear gain 0..4, default 1. */
|
|
9907
|
+
gain?: number;
|
|
9908
|
+
/** Equal-power fade in, seconds (default 5 ms). */
|
|
9909
|
+
fadeInTime?: number;
|
|
9910
|
+
/** Equal-power fade out, seconds (default 5 ms). */
|
|
9911
|
+
fadeTime?: number;
|
|
9912
|
+
/** Play the slice backwards. */
|
|
9913
|
+
rev?: boolean;
|
|
9914
|
+
/** The track take this clip plays from (its clock drift applies). */
|
|
9915
|
+
take?: string;
|
|
9916
|
+
mute?: boolean;
|
|
9917
|
+
/** The words sung in the clip, for the highway and lyrics. */
|
|
9918
|
+
text?: string;
|
|
9919
|
+
/** A text-to-speech clip's time map (0.7.1); kept as written. */
|
|
9920
|
+
say?: Readonly<Record<string, unknown>>;
|
|
9921
|
+
/** The file's pin; dawg fills it from the file when absent. */
|
|
9922
|
+
sha256?: string;
|
|
9923
|
+
}>;
|
|
9924
|
+
|
|
9925
|
+
/** One audio clip as `audio()` builds it. */
|
|
9926
|
+
export type AudioSpec = Readonly<
|
|
9927
|
+
{ kind: "audio"; src: string; at: number } & Omit<AudioOptions, "at">
|
|
9928
|
+
>;
|
|
9929
|
+
|
|
9930
|
+
const AUDIO_KEYS = [
|
|
9931
|
+
"id",
|
|
9932
|
+
"at",
|
|
9933
|
+
"offset",
|
|
9934
|
+
"dur",
|
|
9935
|
+
"gain",
|
|
9936
|
+
"fadeInTime",
|
|
9937
|
+
"fadeTime",
|
|
9938
|
+
"rev",
|
|
9939
|
+
"take",
|
|
9940
|
+
"mute",
|
|
9941
|
+
"text",
|
|
9942
|
+
"say",
|
|
9943
|
+
"sha256",
|
|
9944
|
+
] as const;
|
|
9945
|
+
|
|
9946
|
+
/**
|
|
9947
|
+
* An audio file on the track's timeline (SDK 1.32.0). `src` is relative
|
|
9948
|
+
* to the track folder (`samples/lead.wav`) or the project
|
|
9949
|
+
* (`tracks/vox/samples/lead.wav`).
|
|
9950
|
+
*
|
|
9951
|
+
* ```ts
|
|
9952
|
+
* clips: [audio("samples/verse.wav", { at: 16, gain: 0.8, fadeTime: 0.2 })]
|
|
9953
|
+
* ```
|
|
9954
|
+
*/
|
|
9955
|
+
export function audio(src: string, options: AudioOptions = {}): AudioSpec {
|
|
9956
|
+
const file = text(src, "audio src");
|
|
9957
|
+
if (!isRecord(options))
|
|
9958
|
+
throw new DawgSdkError("audio options must be an object");
|
|
9959
|
+
for (const key of Object.keys(options))
|
|
9960
|
+
if (!(AUDIO_KEYS as readonly string[]).includes(key))
|
|
9961
|
+
throw new DawgSdkError(
|
|
9962
|
+
`audio has an unknown option "${key.slice(0, 32)}" (${AUDIO_KEYS.join(" ")})`,
|
|
9963
|
+
);
|
|
9964
|
+
const out: Record<string, unknown> = {
|
|
9965
|
+
kind: "audio",
|
|
9966
|
+
src: file,
|
|
9967
|
+
at: beat(options.at ?? 0, "audio at"),
|
|
9968
|
+
};
|
|
9969
|
+
if (options.id !== undefined) out.id = text(options.id, "audio id");
|
|
9970
|
+
for (const key of [
|
|
9971
|
+
"offset",
|
|
9972
|
+
"dur",
|
|
9973
|
+
"gain",
|
|
9974
|
+
"fadeInTime",
|
|
9975
|
+
"fadeTime",
|
|
9976
|
+
] as const)
|
|
9977
|
+
if (options[key] !== undefined) {
|
|
9978
|
+
const value = finite(options[key], `audio ${key}`);
|
|
9979
|
+
if (value < 0) throw new DawgSdkError(`audio ${key} must be ≥ 0`);
|
|
9980
|
+
out[key] = value;
|
|
9981
|
+
}
|
|
9982
|
+
for (const key of ["rev", "mute"] as const)
|
|
9983
|
+
if (options[key] !== undefined) {
|
|
9984
|
+
if (typeof options[key] !== "boolean")
|
|
9985
|
+
throw new DawgSdkError(`audio ${key} must be true or false`);
|
|
9986
|
+
if (options[key]) out[key] = true;
|
|
9987
|
+
}
|
|
9988
|
+
if (options.take !== undefined) out.take = text(options.take, "audio take");
|
|
9989
|
+
if (options.text !== undefined) {
|
|
9990
|
+
if (typeof options.text !== "string" || options.text.length > 2000)
|
|
9991
|
+
throw new DawgSdkError("audio text must be at most 2000 characters");
|
|
9992
|
+
out.text = options.text;
|
|
9993
|
+
}
|
|
9994
|
+
if (options.say !== undefined) {
|
|
9995
|
+
if (!isRecord(options.say))
|
|
9996
|
+
throw new DawgSdkError("audio say must be an object");
|
|
9997
|
+
out.say = options.say;
|
|
9998
|
+
}
|
|
9999
|
+
if (options.sha256 !== undefined) {
|
|
10000
|
+
if (
|
|
10001
|
+
typeof options.sha256 !== "string" ||
|
|
10002
|
+
!/^[0-9a-f]{64}$/.test(options.sha256)
|
|
10003
|
+
)
|
|
10004
|
+
throw new DawgSdkError(
|
|
10005
|
+
"audio sha256 must be 64 lowercase hex characters",
|
|
10006
|
+
);
|
|
10007
|
+
out.sha256 = options.sha256;
|
|
10008
|
+
}
|
|
10009
|
+
return Object.freeze(out) as AudioSpec;
|
|
10010
|
+
}
|
|
10011
|
+
|
|
10012
|
+
/**
|
|
10013
|
+
* Copies of `clip` every `every` beats after it while they start before
|
|
10014
|
+
* `until` (SDK 1.32.0), the clip first: `...repeatAudio(hook, { every: 8,
|
|
10015
|
+
* until: 64 })`. Copies of a clip with an id get `<id>-r2`, `<id>-r3`, ...
|
|
10016
|
+
*/
|
|
10017
|
+
export function repeatAudio(
|
|
10018
|
+
clip: AudioSpec,
|
|
10019
|
+
options: Readonly<{ every: number; until: number }>,
|
|
10020
|
+
): readonly AudioSpec[] {
|
|
10021
|
+
if (!isRecord(clip) || clip.kind !== "audio")
|
|
10022
|
+
throw new DawgSdkError("repeatAudio needs a clip from audio()");
|
|
10023
|
+
if (!isRecord(options))
|
|
10024
|
+
throw new DawgSdkError("repeatAudio needs { every, until } in beats");
|
|
10025
|
+
const every = positive(options.every, "repeatAudio every");
|
|
10026
|
+
const until = beat(options.until, "repeatAudio until");
|
|
10027
|
+
const out: AudioSpec[] = [clip];
|
|
10028
|
+
for (
|
|
10029
|
+
let at = clip.at + every, pass = 2;
|
|
10030
|
+
at < until - 1e-9 && out.length < 256;
|
|
10031
|
+
at += every, pass += 1
|
|
10032
|
+
)
|
|
10033
|
+
out.push(
|
|
10034
|
+
Object.freeze({
|
|
10035
|
+
...clip,
|
|
10036
|
+
at,
|
|
10037
|
+
...(clip.id !== undefined ? { id: `${clip.id}-r${pass}` } : {}),
|
|
10038
|
+
}) as AudioSpec,
|
|
10039
|
+
);
|
|
10040
|
+
return Object.freeze(out);
|
|
10041
|
+
}
|
|
10042
|
+
|
|
10043
|
+
/** Options for `take()`: `at`, `in` and `out` in beats, the rest in seconds. */
|
|
10044
|
+
export type TakeOptions = Readonly<{
|
|
10045
|
+
/** Beat the recording's first sample lines up with. */
|
|
10046
|
+
at?: number;
|
|
10047
|
+
/** Punch range in beats, default `at` to `at + 4`. */
|
|
10048
|
+
in?: number;
|
|
10049
|
+
out?: number;
|
|
10050
|
+
/** Seconds into the file where the take starts. */
|
|
10051
|
+
offset?: number;
|
|
10052
|
+
/** Round-trip latency compensated, seconds. */
|
|
10053
|
+
latency?: number;
|
|
10054
|
+
latencyAssumed?: boolean;
|
|
10055
|
+
/** Clock drift in parts per million (±1000). */
|
|
10056
|
+
ppm?: number;
|
|
10057
|
+
/** 0..1 fit of the alignment. */
|
|
10058
|
+
fit?: number;
|
|
10059
|
+
warn?: string;
|
|
10060
|
+
/** Manual nudge in milliseconds (±250). */
|
|
10061
|
+
nudge?: number;
|
|
10062
|
+
sha256?: string;
|
|
10063
|
+
}>;
|
|
10064
|
+
|
|
10065
|
+
/** One take as `take()` builds it. */
|
|
10066
|
+
export type TakeSpec = Readonly<
|
|
10067
|
+
{
|
|
10068
|
+
kind: "take";
|
|
10069
|
+
name: string;
|
|
10070
|
+
src: string;
|
|
10071
|
+
at: number;
|
|
10072
|
+
in: number;
|
|
10073
|
+
out: number;
|
|
10074
|
+
} & Omit<TakeOptions, "at" | "in" | "out">
|
|
10075
|
+
>;
|
|
10076
|
+
|
|
10077
|
+
const TAKE_KEYS = [
|
|
10078
|
+
"at",
|
|
10079
|
+
"in",
|
|
10080
|
+
"out",
|
|
10081
|
+
"offset",
|
|
10082
|
+
"latency",
|
|
10083
|
+
"latencyAssumed",
|
|
10084
|
+
"ppm",
|
|
10085
|
+
"fit",
|
|
10086
|
+
"warn",
|
|
10087
|
+
"nudge",
|
|
10088
|
+
"sha256",
|
|
10089
|
+
] as const;
|
|
10090
|
+
|
|
10091
|
+
/**
|
|
10092
|
+
* A take (SDK 1.32.0): one recorded or imported pass the track's clips can
|
|
10093
|
+
* play from (`audio(src, { take: "take-1" })`). Recording itself is 0.7.1.
|
|
10094
|
+
*/
|
|
10095
|
+
export function take(
|
|
10096
|
+
name: string,
|
|
10097
|
+
src: string,
|
|
10098
|
+
options: TakeOptions = {},
|
|
10099
|
+
): TakeSpec {
|
|
10100
|
+
const label = `take ${text(name, "take name")}`;
|
|
10101
|
+
if (!isRecord(options))
|
|
10102
|
+
throw new DawgSdkError(`${label} options must be an object`);
|
|
10103
|
+
for (const key of Object.keys(options))
|
|
10104
|
+
if (!(TAKE_KEYS as readonly string[]).includes(key))
|
|
10105
|
+
throw new DawgSdkError(
|
|
10106
|
+
`${label} has an unknown option "${key.slice(0, 32)}" (${TAKE_KEYS.join(" ")})`,
|
|
10107
|
+
);
|
|
10108
|
+
const at = beat(options.at ?? 0, `${label} at`);
|
|
10109
|
+
const from = beat(options.in ?? at, `${label} in`);
|
|
10110
|
+
const to = beat(options.out ?? from + 4, `${label} out`);
|
|
10111
|
+
if (to <= from) throw new DawgSdkError(`${label}: out must be after in`);
|
|
10112
|
+
const out: Record<string, unknown> = {
|
|
10113
|
+
kind: "take",
|
|
10114
|
+
name,
|
|
10115
|
+
src: text(src, `${label} src`),
|
|
10116
|
+
at,
|
|
10117
|
+
in: from,
|
|
10118
|
+
out: to,
|
|
10119
|
+
};
|
|
10120
|
+
for (const key of ["offset", "latency", "ppm", "fit", "nudge"] as const)
|
|
10121
|
+
if (options[key] !== undefined)
|
|
10122
|
+
out[key] = finite(options[key], `${label} ${key}`);
|
|
10123
|
+
if (options.latencyAssumed) out.latencyAssumed = true;
|
|
10124
|
+
if (options.warn !== undefined)
|
|
10125
|
+
out.warn = text(options.warn, `${label} warn`);
|
|
10126
|
+
if (options.sha256 !== undefined)
|
|
10127
|
+
out.sha256 = text(options.sha256 as unknown, `${label} sha256`);
|
|
10128
|
+
return Object.freeze(out) as TakeSpec;
|
|
10129
|
+
}
|
|
10130
|
+
|
|
10131
|
+
/**
|
|
10132
|
+
* Sings `text` on `notes` in time order (SDK 1.32.0): spaces split words,
|
|
10133
|
+
* `-` splits syllables, `_` holds the previous syllable over the next note
|
|
10134
|
+
* (melisma), `~` skips a note. With fewer syllables than notes, words typed
|
|
10135
|
+
* whole split by vowel groups and leftover notes hold the last syllable;
|
|
10136
|
+
* syllables past the last note are dropped. Same rules as `/lyrics`.
|
|
10137
|
+
*
|
|
10138
|
+
* ```ts
|
|
10139
|
+
* notes: lyrics("sun-lit morn-ing glow", seq("C4 D4 E4 G4 E4"))
|
|
10140
|
+
* ```
|
|
10141
|
+
*/
|
|
10142
|
+
export function lyrics<N extends NoteSpec>(
|
|
10143
|
+
text: string,
|
|
10144
|
+
notes: readonly N[],
|
|
10145
|
+
): readonly N[] {
|
|
10146
|
+
if (typeof text !== "string" || text.length > 2000)
|
|
10147
|
+
throw new DawgSdkError("lyrics text must be at most 2000 characters");
|
|
10148
|
+
if (!Array.isArray(notes))
|
|
10149
|
+
throw new DawgSdkError("lyrics needs an array of notes");
|
|
10150
|
+
const keyed = notes.map((n, index) => ({
|
|
10151
|
+
id: String(index),
|
|
10152
|
+
startTick: Math.round(n.start * 960),
|
|
10153
|
+
pitch: n.pitch,
|
|
10154
|
+
}));
|
|
10155
|
+
const { lyrics: sung } = assignLyrics(text, keyed);
|
|
10156
|
+
return Object.freeze(
|
|
10157
|
+
notes.map((n, index) => {
|
|
10158
|
+
const lyric = sung.get(String(index));
|
|
10159
|
+
if (lyric === undefined) {
|
|
10160
|
+
const { lyric: _drop, ...rest } = n as N & { lyric?: string };
|
|
10161
|
+
return Object.freeze(rest) as unknown as N;
|
|
10162
|
+
}
|
|
10163
|
+
return Object.freeze({ ...n, lyric }) as N;
|
|
10164
|
+
}),
|
|
10165
|
+
);
|
|
10166
|
+
}
|
|
10167
|
+
|
|
10168
|
+
/** A clip for `song()`, ticks resolved and its default id filled. */
|
|
10169
|
+
function storedClip(
|
|
10170
|
+
clip: AudioSpec,
|
|
10171
|
+
index: number,
|
|
10172
|
+
ticks: (beats: number) => number,
|
|
10173
|
+
): Record<string, unknown> {
|
|
10174
|
+
const { kind: _kind, at, id, ...rest } = clip;
|
|
10175
|
+
return Object.freeze({
|
|
10176
|
+
id: id ?? defaultClipId(index),
|
|
10177
|
+
...rest,
|
|
10178
|
+
startTick: ticks(at),
|
|
10179
|
+
});
|
|
10180
|
+
}
|
|
10181
|
+
|
|
10182
|
+
/** The id a clip without one gets: `clip`, `clip2`, `clip3`, ... */
|
|
10183
|
+
export function defaultClipId(index: number): string {
|
|
10184
|
+
return index === 0 ? "clip" : `clip${index + 1}`;
|
|
10185
|
+
}
|
|
10186
|
+
|
|
10187
|
+
function storedTake(
|
|
10188
|
+
spec: TakeSpec,
|
|
10189
|
+
ticks: (beats: number) => number,
|
|
10190
|
+
): Record<string, unknown> {
|
|
10191
|
+
const { kind: _kind, at, in: from, out: to, ...rest } = spec;
|
|
10192
|
+
return Object.freeze({
|
|
10193
|
+
offset: 0,
|
|
10194
|
+
latency: 0,
|
|
10195
|
+
...rest,
|
|
10196
|
+
startTick: ticks(at),
|
|
10197
|
+
inTick: ticks(from),
|
|
10198
|
+
outTick: ticks(to),
|
|
10199
|
+
});
|
|
10200
|
+
}
|
|
10201
|
+
|
|
10202
|
+
/** `track({ clips, takes })` as TrackSpec fields, paths project-relative. */
|
|
10203
|
+
function trackClips(
|
|
10204
|
+
input: TrackInput,
|
|
10205
|
+
name: string,
|
|
10206
|
+
slug: string,
|
|
10207
|
+
): { clips?: readonly AudioSpec[]; takes?: readonly TakeSpec[] } {
|
|
10208
|
+
const local = (src: string) => {
|
|
10209
|
+
const path = src.replace(/^\.\//, "");
|
|
10210
|
+
return path.startsWith("tracks/") || path.startsWith("pack:")
|
|
10211
|
+
? path
|
|
10212
|
+
: `tracks/${slug}/${path}`;
|
|
10213
|
+
};
|
|
10214
|
+
const out: { clips?: readonly AudioSpec[]; takes?: readonly TakeSpec[] } = {};
|
|
10215
|
+
if (input.clips !== undefined) {
|
|
10216
|
+
if (!Array.isArray(input.clips))
|
|
10217
|
+
throw new DawgSdkError(
|
|
10218
|
+
`track ${name}: clips must be an array of audio()`,
|
|
10219
|
+
);
|
|
10220
|
+
const flat = (input.clips as readonly unknown[]).flat();
|
|
10221
|
+
const clips = flat.map((item, index) => {
|
|
10222
|
+
if (!isRecord(item) || item.kind !== "audio")
|
|
10223
|
+
throw new DawgSdkError(
|
|
10224
|
+
`track ${name}: clips[${index}] must come from audio() or repeatAudio()`,
|
|
10225
|
+
);
|
|
10226
|
+
const clip = item as AudioSpec;
|
|
10227
|
+
return Object.freeze({ ...clip, src: local(clip.src) }) as AudioSpec;
|
|
10228
|
+
});
|
|
10229
|
+
if (clips.length > 256)
|
|
10230
|
+
throw new DawgSdkError(`track ${name}: at most 256 clips`);
|
|
10231
|
+
if (clips.length > 0) out.clips = Object.freeze(clips);
|
|
10232
|
+
}
|
|
10233
|
+
if (input.takes !== undefined) {
|
|
10234
|
+
if (!Array.isArray(input.takes))
|
|
10235
|
+
throw new DawgSdkError(`track ${name}: takes must be an array of take()`);
|
|
10236
|
+
const takes = input.takes.map((item, index) => {
|
|
10237
|
+
if (!isRecord(item) || item.kind !== "take")
|
|
10238
|
+
throw new DawgSdkError(
|
|
10239
|
+
`track ${name}: takes[${index}] must come from take()`,
|
|
10240
|
+
);
|
|
10241
|
+
const spec = item as TakeSpec;
|
|
10242
|
+
return Object.freeze({ ...spec, src: local(spec.src) }) as TakeSpec;
|
|
10243
|
+
});
|
|
10244
|
+
if (takes.length > 0) out.takes = Object.freeze(takes);
|
|
10245
|
+
}
|
|
10246
|
+
return out;
|
|
10247
|
+
}
|
|
10248
|
+
|
|
8163
10249
|
// ---------------------------------------------------------------------------
|
|
8164
10250
|
// Internals
|
|
8165
10251
|
|