@hraness/dawg 0.6.0 → 0.7.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 +126 -0
- package/DAWG.md +655 -141
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +759 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -99
- package/core/expression.ts +158 -6
- package/core/fx.ts +354 -32
- package/core/granular.ts +91 -0
- package/core/instruments.ts +180 -0
- package/core/keys.ts +573 -4
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +306 -4
- package/core/routing.ts +165 -0
- package/core/score.ts +1181 -16
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +354 -6
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +3160 -69
- package/core/sections.ts +298 -24
- package/core/sing.ts +815 -0
- package/core/strings.ts +237 -2
- 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/core/winds.ts +652 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -6
- package/guides/chords.md +16 -14
- package/guides/effects.md +17 -15
- 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 +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -10
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +32 -0
- package/guides/rhythm.md +17 -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 -12
- package/guides/styles.md +31 -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 +5 -2
- package/src/agent/agent.ts +131 -13
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +15 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +5 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/resample-tool.ts +131 -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 +307 -37
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +16 -6
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +58 -3
- 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/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +171 -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/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +21 -4
- package/src/audio/effects/common.ts +57 -0
- package/src/audio/effects/convolution.ts +113 -5
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/engine.ts +206 -33
- package/src/audio/fit.ts +80 -3
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +67 -47
- package/src/audio/instruments.ts +36 -3
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +427 -0
- package/src/audio/keys/engine.ts +156 -20
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +72 -8
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/kits.ts +135 -6
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +247 -26
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +45 -3
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +286 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +252 -24
- package/src/audio/samples.ts +46 -3
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +699 -0
- package/src/audio/strings/engine.ts +274 -22
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- 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 +608 -85
- package/src/audio/winds/engine.ts +306 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +104 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +492 -0
- 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 +44 -29
- 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 +104 -23
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +105 -33
- package/src/commands/grammar.ts +558 -0
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +652 -339
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +293 -17
- package/src/commands/modal.ts +67 -4
- 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 +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +8 -25
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/sing.ts +478 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +485 -0
- 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 +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +244 -0
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1521 -255
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +26 -3
- 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 +36 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +50 -7
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- 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 +1377 -193
- package/src/tui/modal-menu.ts +38 -11
- package/src/tui/performance-menu.ts +46 -1
- package/src/tui/play-chords.ts +108 -5
- package/src/tui/play-mode.ts +16 -1
- package/src/tui/play-session.ts +81 -16
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +144 -0
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
package/core/sdk/v1.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
/** SDK release; dawg refreshes the vendored copy when its own is newer. */
|
|
30
|
-
export const SDK_VERSION = "1.
|
|
30
|
+
export const SDK_VERSION = "1.33.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
|
}
|
|
@@ -1283,6 +1349,30 @@ export type SampleSpec = Readonly<{
|
|
|
1283
1349
|
fitmode?: "repitch" | "beats" | "tones";
|
|
1284
1350
|
/** Window length in beats (SDK 1.20.0); `fit` wins over `bpm`, `bpm` over `len`. */
|
|
1285
1351
|
len?: number;
|
|
1352
|
+
/** Pitch shift in semitones at the same length (−24..24, SDK 1.29.0). */
|
|
1353
|
+
shift?: number;
|
|
1354
|
+
/** Formant shift in semitones with `shift` (SDK 1.29.0): absent follows the pitch, 0 keeps the formants. */
|
|
1355
|
+
formant?: number;
|
|
1356
|
+
/** Like Strudel `fadeTime`: release fade in seconds (0..2, SDK 1.29.0). */
|
|
1357
|
+
fadeTime?: number;
|
|
1358
|
+
/** Like Strudel `fadeInTime`: attack fade in seconds (0..2, SDK 1.29.0). */
|
|
1359
|
+
fadeInTime?: number;
|
|
1360
|
+
/** Where `resample` rendered the file from (informational, SDK 1.29.0). */
|
|
1361
|
+
from?: SampleProvenanceSpec;
|
|
1362
|
+
/** Velocity layer (SDK 1.31.0, SFZ lovel/hivel): MIDI velocities `[lo, hi]` this voice plays. */
|
|
1363
|
+
vel?: readonly [number, number];
|
|
1364
|
+
/** Round-robin group (SDK 1.31.0, SFZ seq_length): voices in a group take turns, A B A B. */
|
|
1365
|
+
rr?: string;
|
|
1366
|
+
}>;
|
|
1367
|
+
|
|
1368
|
+
/** `SampleSpec.from` (SDK 1.29.0): the source of a resampled file. */
|
|
1369
|
+
export type SampleProvenanceSpec = Readonly<{
|
|
1370
|
+
/** `track:<id>`, `orbit:<n>` or `master`. */
|
|
1371
|
+
source: string;
|
|
1372
|
+
section?: string;
|
|
1373
|
+
bars?: readonly [number, number];
|
|
1374
|
+
/** sha256 of the score the file was rendered from. */
|
|
1375
|
+
score: string;
|
|
1286
1376
|
}>;
|
|
1287
1377
|
|
|
1288
1378
|
/** Result of `sampler()`; pass it as a track's `instrument`. */
|
|
@@ -1446,9 +1536,13 @@ export const STRING_INSTRUMENT = "string";
|
|
|
1446
1536
|
* String engine settings (SDK 1.21.0): a `preset` (`nylon`, `steel`,
|
|
1447
1537
|
* `electric`, `jangle`, `ebass`, `slap`, `upright`, `sitar`, `tanpura`,
|
|
1448
1538
|
* `harpsichord`, `lute`, `oud`, `setar`, `tar`, `santur`, `dulcimer`, `koto`,
|
|
1449
|
-
* `harp`, `banjo`, `tres`, `requinto
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1539
|
+
* `harp`, `banjo`, `tres`, `requinto`; bowed since SDK 1.31.0: `violin`,
|
|
1540
|
+
* `viola`, `cello`, `contrabass`, `fiddle`, `erhu`, `kamancheh`, `violins`,
|
|
1541
|
+
* `violas`, `cellos`, `contrabasses`, `pizz`, `trem`) plus any parameter to
|
|
1542
|
+
* override (`ring`, `bright`, `damp`, `pos`, `mute`, `buzz`, `body`, `sym`,
|
|
1543
|
+
* bow: `exciter: "bow"`, `pressure`, `speed`, `attack`, `vib`, `vibmod`,
|
|
1544
|
+
* `vibdelay`, `tremhz`, `sord`, `dyn`). dawg validates names and ranges;
|
|
1545
|
+
* see **Strings** in DAWG.md.
|
|
1452
1546
|
*/
|
|
1453
1547
|
export type StringInput = Readonly<
|
|
1454
1548
|
{ preset?: string } & Record<string, number | string | undefined>
|
|
@@ -1458,15 +1552,24 @@ export type StringInput = Readonly<
|
|
|
1458
1552
|
export type StringSpec = Readonly<{ kind: "string" } & StringInput>;
|
|
1459
1553
|
|
|
1460
1554
|
/**
|
|
1461
|
-
* A plucked string instrument (SDK 1.21.0
|
|
1555
|
+
* A plucked or bowed string instrument (SDK 1.21.0; bowed and the object
|
|
1556
|
+
* form 1.31.0): a preset and overrides.
|
|
1462
1557
|
*
|
|
1463
1558
|
* instrument: stringed("nylon")
|
|
1464
1559
|
* instrument: stringed("sitar", { buzz: 0.8, sym: 0.5 })
|
|
1560
|
+
* instrument: stringed({ preset: "cello", vib: 6 })
|
|
1465
1561
|
*/
|
|
1466
1562
|
export function stringed(
|
|
1467
|
-
preset = "nylon",
|
|
1563
|
+
preset: string | StringInput = "nylon",
|
|
1468
1564
|
params: Readonly<Record<string, number | string>> = {},
|
|
1469
1565
|
): StringSpec {
|
|
1566
|
+
if (isRecord(preset)) {
|
|
1567
|
+
const { preset: name, ...rest } = preset as Record<string, unknown>;
|
|
1568
|
+
return stringed(name === undefined ? "nylon" : (name as string), {
|
|
1569
|
+
...(rest as Record<string, number | string>),
|
|
1570
|
+
...params,
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1470
1573
|
if (typeof preset !== "string" || preset.length === 0)
|
|
1471
1574
|
throw new DawgSdkError("stringed needs a preset name");
|
|
1472
1575
|
if (!isRecord(params))
|
|
@@ -1567,7 +1670,23 @@ export type ModalPresetName =
|
|
|
1567
1670
|
| "steelpan"
|
|
1568
1671
|
| "bowl"
|
|
1569
1672
|
| "gong"
|
|
1570
|
-
| "timpani"
|
|
1673
|
+
| "timpani"
|
|
1674
|
+
// SDK 1.30.0: gamelan, small bells and frame drums.
|
|
1675
|
+
| "crotales"
|
|
1676
|
+
| "musicbox"
|
|
1677
|
+
| "toypiano"
|
|
1678
|
+
| "saron"
|
|
1679
|
+
| "demung"
|
|
1680
|
+
| "slenthem"
|
|
1681
|
+
| "gangsa"
|
|
1682
|
+
| "gender"
|
|
1683
|
+
| "bonang"
|
|
1684
|
+
| "kenong"
|
|
1685
|
+
| "kethuk"
|
|
1686
|
+
| "kempul"
|
|
1687
|
+
| "daf"
|
|
1688
|
+
| "bodhran"
|
|
1689
|
+
| "tabla";
|
|
1571
1690
|
|
|
1572
1691
|
/** Modal overrides; omitted means the preset's value. dawg validates ranges. */
|
|
1573
1692
|
export type ModalParams = Readonly<{
|
|
@@ -1600,6 +1719,12 @@ export type ModalParams = Readonly<{
|
|
|
1600
1719
|
gain?: number;
|
|
1601
1720
|
/** Mode table override (`marimba`, `bell`, `gong`, …). */
|
|
1602
1721
|
body?: string;
|
|
1722
|
+
/**
|
|
1723
|
+
* The partner track id of an ombak pair (SDK 1.30.0): this track (the
|
|
1724
|
+
* pengisep) sounds `ombak` Hz above it. song() sets the partner's ombak
|
|
1725
|
+
* to 0 (one straight voice) unless the partner gives its own.
|
|
1726
|
+
*/
|
|
1727
|
+
pair?: string;
|
|
1603
1728
|
}>;
|
|
1604
1729
|
|
|
1605
1730
|
const MODAL_KEYS = Object.freeze([
|
|
@@ -1675,6 +1800,9 @@ const MODAL_RANGES: Readonly<Record<string, readonly [number, number]>> =
|
|
|
1675
1800
|
gain: [0, 2],
|
|
1676
1801
|
});
|
|
1677
1802
|
|
|
1803
|
+
/** Modal presets whose default carries an ombak twin bank (core/resonators.ts). */
|
|
1804
|
+
const MODAL_TWIN_PRESETS: readonly string[] = Object.freeze(["gangsa"]);
|
|
1805
|
+
|
|
1678
1806
|
const MODAL_PRESET_WORDS: readonly string[] = Object.freeze([
|
|
1679
1807
|
"marimba",
|
|
1680
1808
|
"vibes",
|
|
@@ -1688,6 +1816,21 @@ const MODAL_PRESET_WORDS: readonly string[] = Object.freeze([
|
|
|
1688
1816
|
"bowl",
|
|
1689
1817
|
"gong",
|
|
1690
1818
|
"timpani",
|
|
1819
|
+
"crotales",
|
|
1820
|
+
"musicbox",
|
|
1821
|
+
"toypiano",
|
|
1822
|
+
"saron",
|
|
1823
|
+
"demung",
|
|
1824
|
+
"slenthem",
|
|
1825
|
+
"gangsa",
|
|
1826
|
+
"gender",
|
|
1827
|
+
"bonang",
|
|
1828
|
+
"kenong",
|
|
1829
|
+
"kethuk",
|
|
1830
|
+
"kempul",
|
|
1831
|
+
"daf",
|
|
1832
|
+
"bodhran",
|
|
1833
|
+
"tabla",
|
|
1691
1834
|
]);
|
|
1692
1835
|
|
|
1693
1836
|
/** Result of `modal()`; pass it as a track's `instrument`. */
|
|
@@ -1733,6 +1876,10 @@ export function modal(
|
|
|
1733
1876
|
`modal ${key} "${String(value).slice(0, 32)}" is not one of ${words.join(" ")}`,
|
|
1734
1877
|
);
|
|
1735
1878
|
out[key] = value;
|
|
1879
|
+
} else if (key === "pair") {
|
|
1880
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(value))
|
|
1881
|
+
throw new DawgSdkError("modal pair must be a track id");
|
|
1882
|
+
out.pair = value;
|
|
1736
1883
|
} else if ((MODAL_KEYS as readonly string[]).includes(key)) {
|
|
1737
1884
|
const number = finite(value, `modal ${key}`);
|
|
1738
1885
|
const [min, max] = MODAL_RANGES[key]!;
|
|
@@ -1741,12 +1888,760 @@ export function modal(
|
|
|
1741
1888
|
out[key] = number;
|
|
1742
1889
|
} else
|
|
1743
1890
|
throw new DawgSdkError(
|
|
1744
|
-
`modal has no parameter "${key.slice(0, 32)}" (${MODAL_KEYS.join(" ")})`,
|
|
1891
|
+
`modal has no parameter "${key.slice(0, 32)}" (${MODAL_KEYS.join(" ")} pair)`,
|
|
1745
1892
|
);
|
|
1746
1893
|
}
|
|
1747
1894
|
return Object.freeze(out) as ModalSpec;
|
|
1748
1895
|
}
|
|
1749
1896
|
|
|
1897
|
+
// ---- winds (f061-gamelan-winds, SDK 1.30.0) ----
|
|
1898
|
+
|
|
1899
|
+
/** The instrument value of the wind engine (core/winds.ts). */
|
|
1900
|
+
export const WIND_INSTRUMENT = "wind";
|
|
1901
|
+
|
|
1902
|
+
/** Wind presets (core/winds.ts WIND_PRESET_NAMES). */
|
|
1903
|
+
export type WindPresetName =
|
|
1904
|
+
| "flute"
|
|
1905
|
+
| "recorder"
|
|
1906
|
+
| "whistle"
|
|
1907
|
+
| "ney"
|
|
1908
|
+
| "shakuhachi"
|
|
1909
|
+
| "panpipe"
|
|
1910
|
+
| "suling"
|
|
1911
|
+
| "bansuri"
|
|
1912
|
+
| "clarinet"
|
|
1913
|
+
| "bassclarinet"
|
|
1914
|
+
| "oboe"
|
|
1915
|
+
| "bassoon"
|
|
1916
|
+
| "sax"
|
|
1917
|
+
| "altosax"
|
|
1918
|
+
| "barisax"
|
|
1919
|
+
| "trumpet"
|
|
1920
|
+
| "harmon"
|
|
1921
|
+
| "plunger"
|
|
1922
|
+
| "trombone"
|
|
1923
|
+
| "tuba"
|
|
1924
|
+
| "horn";
|
|
1925
|
+
|
|
1926
|
+
const WIND_PRESET_WORDS: readonly string[] = Object.freeze([
|
|
1927
|
+
"flute",
|
|
1928
|
+
"recorder",
|
|
1929
|
+
"whistle",
|
|
1930
|
+
"ney",
|
|
1931
|
+
"shakuhachi",
|
|
1932
|
+
"panpipe",
|
|
1933
|
+
"suling",
|
|
1934
|
+
"bansuri",
|
|
1935
|
+
"clarinet",
|
|
1936
|
+
"bassclarinet",
|
|
1937
|
+
"oboe",
|
|
1938
|
+
"bassoon",
|
|
1939
|
+
"sax",
|
|
1940
|
+
"altosax",
|
|
1941
|
+
"barisax",
|
|
1942
|
+
"trumpet",
|
|
1943
|
+
"harmon",
|
|
1944
|
+
"plunger",
|
|
1945
|
+
"trombone",
|
|
1946
|
+
"tuba",
|
|
1947
|
+
"horn",
|
|
1948
|
+
]);
|
|
1949
|
+
|
|
1950
|
+
/** Wind overrides (core/winds.ts WIND_PARAMS). */
|
|
1951
|
+
export type WindParams = Readonly<{
|
|
1952
|
+
model?: "jet" | "reed" | "sax" | "lips";
|
|
1953
|
+
breath?: number;
|
|
1954
|
+
noise?: number;
|
|
1955
|
+
attack?: number;
|
|
1956
|
+
release?: number;
|
|
1957
|
+
vib?: number;
|
|
1958
|
+
vibmod?: number;
|
|
1959
|
+
reed?: number;
|
|
1960
|
+
bright?: number;
|
|
1961
|
+
stopped?: boolean;
|
|
1962
|
+
mute?: "open" | "straight" | "cup" | "harmon" | "plunger";
|
|
1963
|
+
wah?: number;
|
|
1964
|
+
wahenv?: number;
|
|
1965
|
+
growl?: number;
|
|
1966
|
+
flutter?: number;
|
|
1967
|
+
players?: number;
|
|
1968
|
+
gain?: number;
|
|
1969
|
+
}>;
|
|
1970
|
+
|
|
1971
|
+
/** Enum words per wind parameter (core/winds.ts WIND_PARAMS). */
|
|
1972
|
+
const WIND_ENUMS: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
1973
|
+
model: Object.freeze(["jet", "reed", "sax", "lips"]),
|
|
1974
|
+
mute: Object.freeze(["open", "straight", "cup", "harmon", "plunger"]),
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
/** Numeric ranges (core/winds.ts WIND_PARAMS min..max). */
|
|
1978
|
+
const WIND_RANGES: Readonly<Record<string, readonly [number, number]>> =
|
|
1979
|
+
Object.freeze({
|
|
1980
|
+
breath: [0, 1],
|
|
1981
|
+
noise: [0, 1],
|
|
1982
|
+
attack: [0.001, 2],
|
|
1983
|
+
release: [0.005, 2],
|
|
1984
|
+
vib: [0, 12],
|
|
1985
|
+
vibmod: [0, 1],
|
|
1986
|
+
reed: [0, 1],
|
|
1987
|
+
bright: [0, 1],
|
|
1988
|
+
wah: [0, 1],
|
|
1989
|
+
wahenv: [0, 1],
|
|
1990
|
+
growl: [0, 1],
|
|
1991
|
+
flutter: [0, 1],
|
|
1992
|
+
players: [1, 8],
|
|
1993
|
+
gain: [0, 2],
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
/** Result of `wind()`; pass it as a track's `instrument`. */
|
|
1997
|
+
export type WindSpec = Readonly<
|
|
1998
|
+
{ kind: "wind"; preset?: WindPresetName } & WindParams
|
|
1999
|
+
>;
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* Winds and brass on the wind engine (SDK 1.30.0): a preset and optional
|
|
2003
|
+
* overrides. A preset word alone (`instrument: "flute"`) is the same as
|
|
2004
|
+
* `wind("flute")`; the bare word `"wind"` stays the legacy wind tone.
|
|
2005
|
+
*
|
|
2006
|
+
* ```ts
|
|
2007
|
+
* instrument: wind("sax", { breath: 0.8, growl: 0.2 })
|
|
2008
|
+
* instrument: wind("trumpet", { mute: "harmon", players: 3 })
|
|
2009
|
+
* instrument: wind({ bright: 0.3 }) // default preset (flute)
|
|
2010
|
+
* ```
|
|
2011
|
+
*/
|
|
2012
|
+
export function wind(
|
|
2013
|
+
preset?: WindPresetName | WindParams,
|
|
2014
|
+
params: WindParams = {},
|
|
2015
|
+
): WindSpec {
|
|
2016
|
+
const overrides = isRecord(preset) ? preset : params;
|
|
2017
|
+
const name = isRecord(preset) ? undefined : preset;
|
|
2018
|
+
if (!isRecord(overrides))
|
|
2019
|
+
throw new DawgSdkError("wind params must be an object");
|
|
2020
|
+
const out: Record<string, unknown> = { kind: "wind" };
|
|
2021
|
+
if (name !== undefined) {
|
|
2022
|
+
if (typeof name !== "string" || !WIND_PRESET_WORDS.includes(name))
|
|
2023
|
+
throw new DawgSdkError(
|
|
2024
|
+
`wind preset "${String(name).slice(0, 32)}" is not one of ${WIND_PRESET_WORDS.join(" ")}`,
|
|
2025
|
+
);
|
|
2026
|
+
out.preset = name;
|
|
2027
|
+
}
|
|
2028
|
+
for (const key of Object.keys(overrides)) {
|
|
2029
|
+
const value = (overrides as Record<string, unknown>)[key];
|
|
2030
|
+
if (value === undefined) continue;
|
|
2031
|
+
const words = WIND_ENUMS[key];
|
|
2032
|
+
if (words) {
|
|
2033
|
+
if (typeof value !== "string" || !words.includes(value))
|
|
2034
|
+
throw new DawgSdkError(
|
|
2035
|
+
`wind ${key} "${String(value).slice(0, 32)}" is not one of ${words.join(" ")}`,
|
|
2036
|
+
);
|
|
2037
|
+
out[key] = value;
|
|
2038
|
+
} else if (key === "stopped") {
|
|
2039
|
+
if (typeof value !== "boolean")
|
|
2040
|
+
throw new DawgSdkError("wind stopped must be true or false");
|
|
2041
|
+
out.stopped = value;
|
|
2042
|
+
} else if (WIND_RANGES[key]) {
|
|
2043
|
+
const number = finite(value, `wind ${key}`);
|
|
2044
|
+
const [min, max] = WIND_RANGES[key]!;
|
|
2045
|
+
if (number < min || number > max)
|
|
2046
|
+
throw new DawgSdkError(`wind ${key} must be ${min}..${max}`);
|
|
2047
|
+
if (key === "players" && !Number.isInteger(number))
|
|
2048
|
+
throw new DawgSdkError("wind players must be a whole number");
|
|
2049
|
+
out[key] = number;
|
|
2050
|
+
} else
|
|
2051
|
+
throw new DawgSdkError(
|
|
2052
|
+
`wind has no parameter "${key.slice(0, 32)}" (${[...Object.keys(WIND_ENUMS), "stopped", ...Object.keys(WIND_RANGES)].join(" ")})`,
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
return Object.freeze(out) as WindSpec;
|
|
2056
|
+
}
|
|
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
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* The wind field an instrument makes: `wind(...)`, or a wind preset word
|
|
2280
|
+
* (`"flute"`, `"saxophone"`). The bare word `"wind"` keeps its pre-0.6.1
|
|
2281
|
+
* meaning (the legacy tone) and makes none.
|
|
2282
|
+
*/
|
|
2283
|
+
function trackWind(
|
|
2284
|
+
raw: unknown,
|
|
2285
|
+
): Readonly<{ preset?: WindPresetName } & WindParams> | undefined {
|
|
2286
|
+
if (isRecord(raw) && raw.kind === "wind") {
|
|
2287
|
+
const { kind: _kind, ...fields } = raw as WindSpec;
|
|
2288
|
+
return Object.freeze(fields);
|
|
2289
|
+
}
|
|
2290
|
+
if (typeof raw !== "string" || raw === WIND_INSTRUMENT) return undefined;
|
|
2291
|
+
const meaning = resolveInstrumentWord(raw);
|
|
2292
|
+
if (meaning?.instrument !== WIND_INSTRUMENT || !meaning.preset)
|
|
2293
|
+
return undefined;
|
|
2294
|
+
return Object.freeze({ preset: meaning.preset as WindPresetName });
|
|
2295
|
+
}
|
|
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
|
+
|
|
1750
2645
|
/**
|
|
1751
2646
|
* `count` equal slices of one file as voices `prefix0 … prefixN-1`, for
|
|
1752
2647
|
* chopped breaks: `sampler(slices("samples/break.wav", 8, "brk"))`, then
|
|
@@ -1800,6 +2695,13 @@ function sampleSpec(value: string | SampleSpec, name: string): SampleSpec {
|
|
|
1800
2695
|
bpm?: number;
|
|
1801
2696
|
fitmode?: "repitch" | "beats" | "tones";
|
|
1802
2697
|
len?: number;
|
|
2698
|
+
shift?: number;
|
|
2699
|
+
formant?: number;
|
|
2700
|
+
fadeTime?: number;
|
|
2701
|
+
fadeInTime?: number;
|
|
2702
|
+
from?: SampleProvenanceSpec;
|
|
2703
|
+
vel?: readonly [number, number];
|
|
2704
|
+
rr?: string;
|
|
1803
2705
|
} = { src: spec.src };
|
|
1804
2706
|
if (spec.src.startsWith("pack:")) {
|
|
1805
2707
|
if (spec.sha256 !== undefined)
|
|
@@ -1862,6 +2764,27 @@ function sampleSpec(value: string | SampleSpec, name: string): SampleSpec {
|
|
|
1862
2764
|
out.fitmode = spec.fitmode;
|
|
1863
2765
|
}
|
|
1864
2766
|
if (spec.len !== undefined) out.len = finite(spec.len, `${name} len`);
|
|
2767
|
+
if (spec.shift !== undefined) out.shift = finite(spec.shift, `${name} shift`);
|
|
2768
|
+
if (spec.formant !== undefined)
|
|
2769
|
+
out.formant = finite(spec.formant, `${name} formant`);
|
|
2770
|
+
if (spec.fadeTime !== undefined)
|
|
2771
|
+
out.fadeTime = finite(spec.fadeTime, `${name} fadeTime`);
|
|
2772
|
+
if (spec.fadeInTime !== undefined)
|
|
2773
|
+
out.fadeInTime = finite(spec.fadeInTime, `${name} fadeInTime`);
|
|
2774
|
+
if (spec.from !== undefined) {
|
|
2775
|
+
if (typeof spec.from !== "object" || spec.from === null)
|
|
2776
|
+
throw new DawgSdkError(`${name} from must be an object`);
|
|
2777
|
+
out.from = spec.from;
|
|
2778
|
+
}
|
|
2779
|
+
if (spec.vel !== undefined) {
|
|
2780
|
+
if (!Array.isArray(spec.vel) || spec.vel.length !== 2)
|
|
2781
|
+
throw new DawgSdkError(`${name} vel must be [lo, hi]`);
|
|
2782
|
+
out.vel = Object.freeze([
|
|
2783
|
+
finite(spec.vel[0], `${name} vel`),
|
|
2784
|
+
finite(spec.vel[1], `${name} vel`),
|
|
2785
|
+
] as const);
|
|
2786
|
+
}
|
|
2787
|
+
if (spec.rr !== undefined) out.rr = text(spec.rr, `${name} rr`);
|
|
1865
2788
|
return Object.freeze(out);
|
|
1866
2789
|
}
|
|
1867
2790
|
|
|
@@ -2018,14 +2941,85 @@ export const RIG_PRESETS: Readonly<
|
|
|
2018
2941
|
head: { type: "chime", gain: 3, treble: 7 },
|
|
2019
2942
|
cab: { type: "2x12", mic: 0.2 },
|
|
2020
2943
|
compressor: { threshold: -20, ratio: 4, attack: 0.01, release: 0.15 },
|
|
2944
|
+
// The `rig jangle` command adds `double` since 0.6.1; rig("jangle")
|
|
2945
|
+
// keeps its 0.6.0 stages so older song.ts files sound the same. Add
|
|
2946
|
+
// `double: { time: 12, drift: 1.5, width: 0.5 }` for the new one.
|
|
2021
2947
|
},
|
|
2022
2948
|
alt: {
|
|
2023
2949
|
stomp: { type: "rat", gain: 6, tone: 0.4 },
|
|
2024
2950
|
head: { type: "crunch", gain: 4 },
|
|
2025
2951
|
cab: { type: "4x12" },
|
|
2026
2952
|
},
|
|
2953
|
+
// SDK 1.27.0 shoegaze rigs. Their long wash is the track's `reverb`
|
|
2954
|
+
// (see `rigReverb` in the `rig` command), not part of `rig()`.
|
|
2955
|
+
shoegaze: {
|
|
2956
|
+
stomp: { type: "fuzz", gain: 7, tone: 0.45 },
|
|
2957
|
+
head: { type: "chime", gain: 4 },
|
|
2958
|
+
cab: { type: "2x12" },
|
|
2959
|
+
wobble: { depth: 25, rate: 0.4, drift: 0.4 },
|
|
2960
|
+
bloom: { amount: 0.4, harm: 2, delay: 0.6, time: 1 },
|
|
2961
|
+
double: { time: 18, drift: 2.5, width: 0.4 },
|
|
2962
|
+
postgain: { gain: 0.86 },
|
|
2963
|
+
},
|
|
2964
|
+
glide: {
|
|
2965
|
+
stomp: { type: "face", gain: 5, tone: 0.5 },
|
|
2966
|
+
head: { type: "clean", gain: 4, treble: 5 },
|
|
2967
|
+
cab: { type: "2x12" },
|
|
2968
|
+
wobble: { depth: 35, rate: 0.4, drift: 0.4 },
|
|
2969
|
+
postgain: { gain: 0.8 },
|
|
2970
|
+
},
|
|
2971
|
+
dreampop: {
|
|
2972
|
+
stomp: undefined,
|
|
2973
|
+
head: { type: "clean", gain: 2, treble: 6 },
|
|
2974
|
+
cab: { type: "1x12" },
|
|
2975
|
+
chorus: { rate: 0.6, depth: 0.3, mix: 0.35 },
|
|
2976
|
+
double: { time: 14, drift: 2, width: 0.4 },
|
|
2977
|
+
postgain: { gain: 1.45 },
|
|
2978
|
+
},
|
|
2979
|
+
swell: {
|
|
2980
|
+
stomp: undefined,
|
|
2981
|
+
head: { type: "clean", gain: 3, treble: 5 },
|
|
2982
|
+
cab: { type: "1x12" },
|
|
2983
|
+
swell: { time: 0.5 },
|
|
2984
|
+
postgain: { gain: 1.25 },
|
|
2985
|
+
},
|
|
2986
|
+
ebow: {
|
|
2987
|
+
stomp: { type: "od", gain: 3, tone: 0.5, level: 3 },
|
|
2988
|
+
head: { type: "lead", gain: 5, mid: 6 },
|
|
2989
|
+
cab: { type: "4x12" },
|
|
2990
|
+
swell: { time: 0.25 },
|
|
2991
|
+
bloom: { amount: 0.8, harm: 1, delay: 0.1, time: 0.6 },
|
|
2992
|
+
},
|
|
2027
2993
|
});
|
|
2028
2994
|
|
|
2995
|
+
/**
|
|
2996
|
+
* The long wash a rig preset sets as the track's `reverb` (SDK 1.27.0),
|
|
2997
|
+
* as the `rig` command stores it. `rig()` returns `fx` only, so give it
|
|
2998
|
+
* as `reverb: rigReverb("shoegaze")`; `instrument: "shoegaze"` adds it
|
|
2999
|
+
* when the track has no `reverb` of its own.
|
|
3000
|
+
*/
|
|
3001
|
+
const RIG_REVERBS: Readonly<Record<string, ReverbInput & { mix: number }>> =
|
|
3002
|
+
Object.freeze({
|
|
3003
|
+
spring: { mix: 0.3, size: 0.35, fade: 1.5, predelay: 0, dim: 3500 },
|
|
3004
|
+
shoegaze: { mix: 0.35, size: 0.9, fade: 4, predelay: 0.02, dim: 6000 },
|
|
3005
|
+
glide: { mix: 0.35, size: 0.7, fade: 2.5, predelay: 0.01, dim: 6000 },
|
|
3006
|
+
dreampop: { mix: 0.4, size: 0.85, fade: 5, predelay: 0.03, dim: 7000 },
|
|
3007
|
+
swell: { mix: 0.45, size: 0.8, fade: 4, predelay: 0.02, dim: 6500 },
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
/** A rig preset's track reverb (`spring`, the shoegaze rigs), if any. */
|
|
3011
|
+
export function rigReverb(name: string): ReverbInput | undefined {
|
|
3012
|
+
if (
|
|
3013
|
+
typeof name !== "string" ||
|
|
3014
|
+
!Object.prototype.hasOwnProperty.call(RIG_PRESETS, name)
|
|
3015
|
+
)
|
|
3016
|
+
throw new DawgSdkError(
|
|
3017
|
+
`unknown rig "${String(name)}" (rigs: ${Object.keys(RIG_PRESETS).join(", ")})`,
|
|
3018
|
+
);
|
|
3019
|
+
const values = RIG_REVERBS[name];
|
|
3020
|
+
return values === undefined ? undefined : Object.freeze({ ...values });
|
|
3021
|
+
}
|
|
3022
|
+
|
|
2029
3023
|
/**
|
|
2030
3024
|
* A guitar rig for a track's `fx` (SDK 1.22.0): the stomp → head → cab
|
|
2031
3025
|
* stages of rig preset `name` and its companion effects (as the `rig`
|
|
@@ -2093,15 +3087,21 @@ export type FxInput = Readonly<Record<string, EffectParams>>;
|
|
|
2093
3087
|
* Every parameter, range and default: **Synth** in DAWG.md.
|
|
2094
3088
|
*/
|
|
2095
3089
|
/**
|
|
2096
|
-
* Modelled
|
|
3090
|
+
* Modelled keys settings (SDK 1.24.0), for a track whose instrument is a
|
|
2097
3091
|
* piano family (`"grand"`, `"upright"`, `"felt"`, `"honkytonk"`,
|
|
2098
|
-
* `"prepared"`; the words `"ballad"` and `"lofi"` pick presets)
|
|
2099
|
-
*
|
|
3092
|
+
* `"prepared"`; the words `"ballad"` and `"lofi"` pick presets) or, from
|
|
3093
|
+
* SDK 1.26.0, an electric family (`"epiano"`, `"wurli"`, `"clav"`; presets
|
|
3094
|
+
* epiano suitcase dyno wurli clav funkclav). Every field is optional; `{}`
|
|
3095
|
+
* is the family's own sound. Automate a parameter with
|
|
2100
3096
|
* `automation.fx["keys-<param>"]` (hardness, touch, decay, release, knock,
|
|
2101
|
-
* noise, felt), read at each note's onset.
|
|
3097
|
+
* noise, felt; electric tone, vibe, trem), read at each note's onset.
|
|
3098
|
+
* Ranges: **Keys** in DAWG.md.
|
|
2102
3099
|
*/
|
|
2103
3100
|
export type KeysInput = Readonly<{
|
|
2104
|
-
/**
|
|
3101
|
+
/**
|
|
3102
|
+
* A named preset: grand ballad upright felt lofi honkytonk prepared, or
|
|
3103
|
+
* electric epiano suitcase dyno wurli clav funkclav.
|
|
3104
|
+
*/
|
|
2105
3105
|
preset?: string;
|
|
2106
3106
|
/** Hammer hardness 0..1: brightness at a given velocity (0.5). */
|
|
2107
3107
|
hardness?: number;
|
|
@@ -2137,6 +3137,53 @@ export type KeysInput = Readonly<{
|
|
|
2137
3137
|
vib?: number;
|
|
2138
3138
|
/** Pitch wobble depth in semitones (0.5). */
|
|
2139
3139
|
vibmod?: number;
|
|
3140
|
+
// keys-electric (SDK 1.26.0)
|
|
3141
|
+
/** Sympathetic string resonance 0..1 under the sustain pedal (0; pianos). */
|
|
3142
|
+
sym?: number;
|
|
3143
|
+
/** Pickup drive 0..1: growl when played hard (0.35; epiano, wurli). */
|
|
3144
|
+
bark?: number;
|
|
3145
|
+
/** Tine or reed bell ping 0..1 (0.5; epiano, wurli). */
|
|
3146
|
+
bell?: number;
|
|
3147
|
+
/** Output low-pass in Hz, 0 off (electric keys). */
|
|
3148
|
+
tone?: number;
|
|
3149
|
+
/** Clav pickup switch: neck bridge both out ("both"). */
|
|
3150
|
+
pickup?: "neck" | "bridge" | "both" | "out";
|
|
3151
|
+
/** Clav mute slider 0..1 (0). */
|
|
3152
|
+
mute?: number;
|
|
3153
|
+
/** Suitcase stereo vibrato depth 0..1 (0; epiano). */
|
|
3154
|
+
vibe?: number;
|
|
3155
|
+
/** Suitcase vibrato rate in Hz 0.5..12 (4; epiano). */
|
|
3156
|
+
vibehz?: number;
|
|
3157
|
+
// Organs (SDK 1.28.0), for instrument `"tonewheel"`, `"combo"` or
|
|
3158
|
+
// `"pipe"` (presets tonewheel gospel jazzorgan combo vox pipe flutes
|
|
3159
|
+
// cornet reeds celeste). `"organ"` stays the legacy sine voice.
|
|
3160
|
+
/** Tonewheel drawbars, nine digits 0-8, 16' to 1' (`"888000000"`). */
|
|
3161
|
+
drawbars?: string;
|
|
3162
|
+
/** Tonewheel percussion: off 2nd 3rd. */
|
|
3163
|
+
perc?: string;
|
|
3164
|
+
/** Percussion decay: fast slow. */
|
|
3165
|
+
percdecay?: string;
|
|
3166
|
+
/** Key click 0..1. */
|
|
3167
|
+
click?: number;
|
|
3168
|
+
/** Scanner vibrato/chorus: off v1 v2 v3 c1 c2 c3. */
|
|
3169
|
+
scanner?: string;
|
|
3170
|
+
/** Preamp drive 0..1 (lane `keys-drive`). */
|
|
3171
|
+
drive?: number;
|
|
3172
|
+
/** Rotary speaker: slow fast stop (lane `keys-rotary`). */
|
|
3173
|
+
rotary?: string;
|
|
3174
|
+
/** Combo registers, five digits 0-8, 16' 8' 4' 2⅔' 2' (`"08800"`). */
|
|
3175
|
+
registers?: string;
|
|
3176
|
+
/**
|
|
3177
|
+
* Pipe stops: names or a registration (plenum flutes cornet reeds strings
|
|
3178
|
+
* full), as one string or a list (`["principal8", "octave4"]`).
|
|
3179
|
+
*/
|
|
3180
|
+
stops?: string | readonly string[];
|
|
3181
|
+
/** Pipe chiff 0..1. */
|
|
3182
|
+
chiff?: number;
|
|
3183
|
+
/** Pipe wind unsteadiness 0..1. */
|
|
3184
|
+
wind?: number;
|
|
3185
|
+
/** Tremolo depth 0..1: reed piano (0; wurli), pipe tremulant (0; pipe). */
|
|
3186
|
+
trem?: number;
|
|
2140
3187
|
}>;
|
|
2141
3188
|
|
|
2142
3189
|
export type SynthInput = Readonly<{
|
|
@@ -2231,12 +3278,26 @@ export type TrackInput = Readonly<{
|
|
|
2231
3278
|
| WavetableSpec
|
|
2232
3279
|
| StringSpec
|
|
2233
3280
|
| GranularSpec
|
|
2234
|
-
| ModalSpec
|
|
3281
|
+
| ModalSpec
|
|
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;
|
|
2235
3290
|
/**
|
|
2236
3291
|
* The sampler a `granular(...)` track keeps while it grains one of its
|
|
2237
3292
|
* voices (SDK 1.23.0); `grain off` plays it again.
|
|
2238
3293
|
*/
|
|
2239
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;
|
|
2240
3301
|
/**
|
|
2241
3302
|
* Granular engine (SDK 1.23.0) for an `instrument: "granular"` track, or
|
|
2242
3303
|
* use `instrument: granular("cloud", {...})` or a word (`"cloud"`).
|
|
@@ -2263,6 +3324,19 @@ export type TrackInput = Readonly<{
|
|
|
2263
3324
|
* `{ ref: 432 }` alone keeps the song's table at another pitch.
|
|
2264
3325
|
*/
|
|
2265
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;
|
|
2266
3340
|
/** Synth voice parameters, Strudel names (`{ attack: 0.01, lpf: 800 }`). */
|
|
2267
3341
|
synth?: SynthInput;
|
|
2268
3342
|
/**
|
|
@@ -2302,6 +3376,24 @@ export type TrackInput = Readonly<{
|
|
|
2302
3376
|
glide?: number | Readonly<{ time?: number; mode?: GlideMode }>;
|
|
2303
3377
|
/** Sustain pedal changes as `[beat, "down" | "half" | "up"]` (SDK 1.15.0). */
|
|
2304
3378
|
pedal?: readonly (readonly [number, PedalState])[];
|
|
3379
|
+
/**
|
|
3380
|
+
* Una corda on a modelled piano (SDK 1.26.0): `[beat, "down" | "half" |
|
|
3381
|
+
* "up"]`; while down each note strikes fewer strings, softer and darker.
|
|
3382
|
+
*/
|
|
3383
|
+
softPedal?: readonly (readonly [number, PedalState])[];
|
|
3384
|
+
/**
|
|
3385
|
+
* Sostenuto on a modelled piano (SDK 1.26.0): `[beat, "down" | "up"]`;
|
|
3386
|
+
* holds only the keys already down when it presses.
|
|
3387
|
+
*/
|
|
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[];
|
|
2305
3397
|
/**
|
|
2306
3398
|
* Velocity response (SDK 1.15.0): `soft` (quiet notes louder), `hard`
|
|
2307
3399
|
* (needs a firm touch), `fixed` (every note at 0.8, like an organ) or
|
|
@@ -2320,6 +3412,17 @@ export type TrackInput = Readonly<{
|
|
|
2320
3412
|
length?: number;
|
|
2321
3413
|
seed?: number;
|
|
2322
3414
|
}>;
|
|
3415
|
+
/**
|
|
3416
|
+
* How chords are fretted on this track (SDK 1.27.0): `{ tune: "dadgad",
|
|
3417
|
+
* capo: 2 }`. dawg's `strum` command and perform mode `guitar` use it;
|
|
3418
|
+
* it changes no sound by itself.
|
|
3419
|
+
*/
|
|
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;
|
|
2323
3426
|
/** `note()`/`seq()` for pitched tracks, `hit()`/`hits()` for kits and one-shot samplers. */
|
|
2324
3427
|
notes?: readonly (NoteSpec | HitSpec)[];
|
|
2325
3428
|
/**
|
|
@@ -2430,6 +3533,9 @@ export type TrackSpec = Readonly<{
|
|
|
2430
3533
|
/** Performance (SDK 1.15.0); present only when set. */
|
|
2431
3534
|
glide?: Readonly<{ time: number; mode: GlideMode }>;
|
|
2432
3535
|
pedal?: readonly (readonly [number, PedalState])[];
|
|
3536
|
+
/** Soft and sostenuto pedals (SDK 1.26.0); present only when set. */
|
|
3537
|
+
softPedal?: readonly (readonly [number, PedalState])[];
|
|
3538
|
+
sostenuto?: readonly (readonly [number, PedalState])[];
|
|
2433
3539
|
velocityCurve?: Readonly<{
|
|
2434
3540
|
curve: Exclude<VelocityCurveName, "linear">;
|
|
2435
3541
|
fixed?: number;
|
|
@@ -2445,6 +3551,20 @@ export type TrackSpec = Readonly<{
|
|
|
2445
3551
|
keys?: KeysInput;
|
|
2446
3552
|
/** Modal settings (SDK 1.25.0); present only on a modal track. */
|
|
2447
3553
|
modal?: Readonly<{ preset?: ModalPresetName } & ModalParams>;
|
|
3554
|
+
/** Guitar fretting (SDK 1.27.0); present only when set. */
|
|
3555
|
+
guitar?: GuitarSetup;
|
|
3556
|
+
/** Wind settings (SDK 1.30.0); present only on a wind-engine track. */
|
|
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;
|
|
2448
3568
|
}>;
|
|
2449
3569
|
|
|
2450
3570
|
export type GlideMode = "legato" | "mono" | "poly";
|
|
@@ -2458,10 +3578,17 @@ const DEFAULT_FIXED_VELOCITY = 0.8;
|
|
|
2458
3578
|
function trackPerformance(
|
|
2459
3579
|
input: TrackInput,
|
|
2460
3580
|
name: string,
|
|
2461
|
-
): Partial<
|
|
3581
|
+
): Partial<
|
|
3582
|
+
Pick<
|
|
3583
|
+
TrackSpec,
|
|
3584
|
+
"glide" | "pedal" | "softPedal" | "sostenuto" | "velocityCurve" | "humanize"
|
|
3585
|
+
>
|
|
3586
|
+
> {
|
|
2462
3587
|
const out: {
|
|
2463
3588
|
glide?: TrackSpec["glide"];
|
|
2464
3589
|
pedal?: TrackSpec["pedal"];
|
|
3590
|
+
softPedal?: TrackSpec["softPedal"];
|
|
3591
|
+
sostenuto?: TrackSpec["sostenuto"];
|
|
2465
3592
|
velocityCurve?: TrackSpec["velocityCurve"];
|
|
2466
3593
|
humanize?: TrackSpec["humanize"];
|
|
2467
3594
|
} = {};
|
|
@@ -2482,29 +3609,38 @@ function trackPerformance(
|
|
|
2482
3609
|
mode,
|
|
2483
3610
|
});
|
|
2484
3611
|
}
|
|
2485
|
-
|
|
2486
|
-
|
|
3612
|
+
const pedalLane = (
|
|
3613
|
+
key: "pedal" | "softPedal" | "sostenuto",
|
|
3614
|
+
states: readonly PedalState[],
|
|
3615
|
+
) => {
|
|
3616
|
+
const value: unknown = input[key];
|
|
3617
|
+
if (value === undefined) return;
|
|
3618
|
+
const shape = `[beat, ${states.map((state) => `"${state}"`).join(" | ")}]`;
|
|
3619
|
+
if (!Array.isArray(value) || value.length > 1024)
|
|
2487
3620
|
throw new DawgSdkError(
|
|
2488
|
-
`track ${name}:
|
|
3621
|
+
`track ${name}: ${key} must be at most 1024 ${shape} events`,
|
|
2489
3622
|
);
|
|
2490
|
-
if (
|
|
2491
|
-
out
|
|
2492
|
-
|
|
3623
|
+
if (value.length > 0)
|
|
3624
|
+
out[key] = Object.freeze(
|
|
3625
|
+
value.map((event: unknown, index: number) => {
|
|
2493
3626
|
if (
|
|
2494
3627
|
!Array.isArray(event) ||
|
|
2495
3628
|
event.length !== 2 ||
|
|
2496
|
-
!
|
|
3629
|
+
!states.includes(event[1] as PedalState)
|
|
2497
3630
|
)
|
|
2498
3631
|
throw new DawgSdkError(
|
|
2499
|
-
`track ${name}:
|
|
3632
|
+
`track ${name}: ${key}[${index}] must be ${shape}`,
|
|
2500
3633
|
);
|
|
2501
3634
|
return Object.freeze([
|
|
2502
|
-
beat(event[0], `${name}
|
|
3635
|
+
beat(event[0], `${name} ${key}[${index}] beat`),
|
|
2503
3636
|
event[1] as PedalState,
|
|
2504
3637
|
] as const);
|
|
2505
3638
|
}),
|
|
2506
3639
|
);
|
|
2507
|
-
}
|
|
3640
|
+
};
|
|
3641
|
+
pedalLane("pedal", ["down", "half", "up"]);
|
|
3642
|
+
pedalLane("softPedal", ["down", "half", "up"]);
|
|
3643
|
+
pedalLane("sostenuto", ["down", "up"]);
|
|
2508
3644
|
if (input.velocityCurve !== undefined) {
|
|
2509
3645
|
const raw =
|
|
2510
3646
|
typeof input.velocityCurve === "string"
|
|
@@ -2634,6 +3770,40 @@ const ZZFX_SHAPES = Object.freeze([
|
|
|
2634
3770
|
* Sample paths without a `tracks/` prefix are made project-relative under
|
|
2635
3771
|
* this track's `tracks/<slug>/`.
|
|
2636
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
|
+
|
|
2637
3807
|
export function track(input: TrackInput): TrackSpec {
|
|
2638
3808
|
if (!isRecord(input)) throw new DawgSdkError("track() needs an object");
|
|
2639
3809
|
if (typeof input.name !== "string" || input.name.trim().length === 0)
|
|
@@ -2645,7 +3815,7 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
2645
3815
|
const id = input.id ?? slug;
|
|
2646
3816
|
if (typeof id !== "string" || id.length === 0 || id.length > 64)
|
|
2647
3817
|
throw new DawgSdkError(`track ${name}: id must be 1..64 characters`);
|
|
2648
|
-
const rawInstrument = input.instrument ?? "sine";
|
|
3818
|
+
const rawInstrument = engineField(input, name) ?? input.instrument ?? "sine";
|
|
2649
3819
|
// A granular track may keep the sampler it grains (`grain off` goes back).
|
|
2650
3820
|
const keptSampler =
|
|
2651
3821
|
isRecord(input.sampler) &&
|
|
@@ -2662,9 +3832,24 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
2662
3832
|
isRecord(rawInstrument) && rawInstrument.kind === "sampler"
|
|
2663
3833
|
? localizeSampler(rawInstrument as SamplerSpec, slug)
|
|
2664
3834
|
: keptSampler;
|
|
2665
|
-
const
|
|
2666
|
-
isRecord(rawInstrument) && rawInstrument.kind === "wavetable"
|
|
2667
|
-
|
|
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)
|
|
2668
3853
|
: null;
|
|
2669
3854
|
const stringFromInstrument =
|
|
2670
3855
|
isRecord(rawInstrument) && rawInstrument.kind === "string"
|
|
@@ -2679,19 +3864,29 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
2679
3864
|
? resolveInstrumentWord(rawInstrument)
|
|
2680
3865
|
: undefined;
|
|
2681
3866
|
const modalSpec = trackModal(rawInstrument);
|
|
3867
|
+
const guitarSpec = guitarInput(input.guitar, `track ${name}`);
|
|
3868
|
+
const windSpec = trackWind(rawInstrument);
|
|
3869
|
+
const singSpec = trackSing(rawInstrument);
|
|
3870
|
+
const vocoderSpec = trackVocoder(rawInstrument, input.vocoder);
|
|
2682
3871
|
const instrument = granularFromInstrument
|
|
2683
3872
|
? GRANULAR_INSTRUMENT
|
|
2684
3873
|
: samplerSpec
|
|
2685
3874
|
? SAMPLER_INSTRUMENT
|
|
2686
|
-
:
|
|
3875
|
+
: playedWavetable
|
|
2687
3876
|
? WAVETABLE_INSTRUMENT
|
|
2688
3877
|
: stringFromInstrument
|
|
2689
3878
|
? STRING_INSTRUMENT
|
|
2690
3879
|
: modalSpec
|
|
2691
3880
|
? MODAL_INSTRUMENT
|
|
2692
|
-
:
|
|
2693
|
-
?
|
|
2694
|
-
:
|
|
3881
|
+
: windSpec
|
|
3882
|
+
? WIND_INSTRUMENT
|
|
3883
|
+
: singSpec
|
|
3884
|
+
? SING_INSTRUMENT
|
|
3885
|
+
: isRecord(rawInstrument) && rawInstrument.kind === "vocoder"
|
|
3886
|
+
? VOCODER_INSTRUMENT
|
|
3887
|
+
: typeof rawInstrument === "string"
|
|
3888
|
+
? (word?.instrument ?? rawInstrument)
|
|
3889
|
+
: undefined;
|
|
2695
3890
|
// A granular word (`"cloud"`) turns the engine on with its preset.
|
|
2696
3891
|
const granularSpec =
|
|
2697
3892
|
granularInput(input.granular, name, slug) ??
|
|
@@ -2734,7 +3929,7 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
2734
3929
|
if (pitch === undefined)
|
|
2735
3930
|
throw new DawgSdkError(
|
|
2736
3931
|
kit
|
|
2737
|
-
? `track ${name}: unknown drum
|
|
3932
|
+
? `track ${name}: unknown drum "${spec.voice}" (kick snare clap rim tom hat openhat)`
|
|
2738
3933
|
: slots
|
|
2739
3934
|
? `track ${name}: unknown sampler voice "${spec.voice}" (${[...slots.keys()].join(" ")})`
|
|
2740
3935
|
: `track ${name}: hit("${spec.voice}") needs instrument "kit" or sampler(...)`,
|
|
@@ -2835,6 +4030,13 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
2835
4030
|
`${name} delay`,
|
|
2836
4031
|
),
|
|
2837
4032
|
});
|
|
4033
|
+
// A rig alias with a wash (`instrument: "shoegaze"`) sets the track
|
|
4034
|
+
// reverb the `track shoegaze` prompt sets, unless `reverb` is given.
|
|
4035
|
+
if (input.reverb === undefined && typeof rawInstrument === "string") {
|
|
4036
|
+
const rigName = resolveInstrumentWord(rawInstrument)?.fx;
|
|
4037
|
+
const wash = rigName === undefined ? undefined : RIG_REVERBS[rigName];
|
|
4038
|
+
if (wash) input = { ...input, reverb: wash };
|
|
4039
|
+
}
|
|
2838
4040
|
const reverb =
|
|
2839
4041
|
input.reverb === undefined || input.reverb === null
|
|
2840
4042
|
? null
|
|
@@ -2907,6 +4109,14 @@ export function track(input: TrackInput): TrackSpec {
|
|
|
2907
4109
|
tuning: tuningSpec(input.tuning, `track ${name}`),
|
|
2908
4110
|
...keysSpec(input.keys, rawInstrument, name),
|
|
2909
4111
|
...(modalSpec ? { modal: modalSpec } : {}),
|
|
4112
|
+
...(guitarSpec ? { guitar: guitarSpec } : {}),
|
|
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
|
+
: {}),
|
|
2910
4120
|
});
|
|
2911
4121
|
}
|
|
2912
4122
|
|
|
@@ -2931,6 +4141,12 @@ const KEYS_PRESET_FX: Readonly<
|
|
|
2931
4141
|
filter: { cutoff: 3500, resonance: 0.1 },
|
|
2932
4142
|
fx: { crush: { bits: 10 } },
|
|
2933
4143
|
},
|
|
4144
|
+
// f061-organ: the pipe presets sound in a church.
|
|
4145
|
+
pipe: { reverb: { mix: 0.35, size: 0.9 } },
|
|
4146
|
+
flutes: { reverb: { mix: 0.3, size: 0.8 } },
|
|
4147
|
+
cornet: { reverb: { mix: 0.3, size: 0.8 } },
|
|
4148
|
+
reeds: { reverb: { mix: 0.3, size: 0.85 } },
|
|
4149
|
+
celeste: { reverb: { mix: 0.35, size: 0.9 } },
|
|
2934
4150
|
});
|
|
2935
4151
|
|
|
2936
4152
|
/**
|
|
@@ -2975,6 +4191,13 @@ function keysSpec(
|
|
|
2975
4191
|
out.preset = preset;
|
|
2976
4192
|
for (const [key, value] of Object.entries(input)) {
|
|
2977
4193
|
if (value === undefined) continue;
|
|
4194
|
+
// Pipe stops may be a list: stored as one space-separated string.
|
|
4195
|
+
if (key === "stops" && Array.isArray(value)) {
|
|
4196
|
+
if (!value.every((stop) => typeof stop === "string"))
|
|
4197
|
+
throw new DawgSdkError(`track ${name}: keys.stops must be stop names`);
|
|
4198
|
+
out[key] = value.join(" ");
|
|
4199
|
+
continue;
|
|
4200
|
+
}
|
|
2978
4201
|
out[key] = effectValue(value, `${name} keys.${key}`);
|
|
2979
4202
|
}
|
|
2980
4203
|
return { keys: Object.freeze(out) };
|
|
@@ -3324,6 +4547,12 @@ export type SongInput = Readonly<{
|
|
|
3324
4547
|
tracks: readonly TrackSpec[];
|
|
3325
4548
|
/** Master chain and loudness target after every track and orbit bus (SDK 1.17.0); omit for none. */
|
|
3326
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;
|
|
3327
4556
|
/**
|
|
3328
4557
|
* Named bar ranges (SDK 1.18.0): `{ name: "chorus", startBar: 8, bars: 8 }`,
|
|
3329
4558
|
* optionally with `mute: ["pad"]` and `vary: { lead: { transpose: 12 } }`.
|
|
@@ -3337,6 +4566,13 @@ export type SongInput = Readonly<{
|
|
|
3337
4566
|
form?: string | readonly (string | SongFormEntry)[];
|
|
3338
4567
|
/** The section playback loops (SDK 1.18.0); export ignores it. */
|
|
3339
4568
|
loopSection?: string;
|
|
4569
|
+
/**
|
|
4570
|
+
* Sound calibration (SDK 1.32.0): `1` renders the 0.7 level, pitch and
|
|
4571
|
+
* drum-kit fixes (hat choke, tuned toms, crash and ride, level keys,
|
|
4572
|
+
* steady brass). Omit it to keep an older song's sound byte-identical;
|
|
4573
|
+
* `dawg init` writes the latest.
|
|
4574
|
+
*/
|
|
4575
|
+
calibration?: number;
|
|
3340
4576
|
}>;
|
|
3341
4577
|
|
|
3342
4578
|
/** A song section (SDK 1.18.0); bars are 0-based like beats. */
|
|
@@ -3401,8 +4637,12 @@ export type ScoreNote = Readonly<{
|
|
|
3401
4637
|
bend?: readonly Readonly<{ at: number; cents: number }>[];
|
|
3402
4638
|
vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
|
|
3403
4639
|
humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
|
|
4640
|
+
/** Autotune guide drift share (SDK 1.32.0). */
|
|
4641
|
+
drift?: number;
|
|
3404
4642
|
/** Static cents offset (SDK 1.16.0); absent is 0. */
|
|
3405
4643
|
cents?: number;
|
|
4644
|
+
/** Sung vowel (SDK 1.32.0); absent sings the lyric's or the track's. */
|
|
4645
|
+
vowel?: string;
|
|
3406
4646
|
}>;
|
|
3407
4647
|
|
|
3408
4648
|
/** A stored automation point: integer tick. */
|
|
@@ -3431,6 +4671,11 @@ export type ScoreSampleRef = Readonly<{
|
|
|
3431
4671
|
bpm?: number;
|
|
3432
4672
|
fitmode?: "repitch" | "beats" | "tones";
|
|
3433
4673
|
len?: number;
|
|
4674
|
+
shift?: number;
|
|
4675
|
+
formant?: number;
|
|
4676
|
+
fadeTime?: number;
|
|
4677
|
+
fadeInTime?: number;
|
|
4678
|
+
from?: SampleProvenanceSpec;
|
|
3434
4679
|
}>;
|
|
3435
4680
|
|
|
3436
4681
|
/** A stored track; optional fields are present only when set. */
|
|
@@ -3480,8 +4725,20 @@ export type ScoreTrack = Readonly<{
|
|
|
3480
4725
|
granular?: GranularInput;
|
|
3481
4726
|
/** Modal settings (SDK 1.25.0). */
|
|
3482
4727
|
modal?: TrackSpec["modal"];
|
|
4728
|
+
/** Guitar fretting (SDK 1.27.0). */
|
|
4729
|
+
guitar?: GuitarSetup;
|
|
4730
|
+
/** Wind settings (SDK 1.30.0). */
|
|
4731
|
+
wind?: TrackSpec["wind"];
|
|
4732
|
+
/** Sing settings (SDK 1.32.0). */
|
|
4733
|
+
sing?: TrackSpec["sing"];
|
|
4734
|
+
/** Vocoder settings (SDK 1.32.0); `src` is a track id. */
|
|
4735
|
+
vocoder?: TrackSpec["vocoder"];
|
|
4736
|
+
/** Pitch correction (SDK 1.32.0). */
|
|
4737
|
+
autotune?: AutotuneSettings;
|
|
3483
4738
|
glide?: TrackSpec["glide"];
|
|
3484
4739
|
pedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
|
|
4740
|
+
softPedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
|
|
4741
|
+
sostenuto?: readonly Readonly<{ tick: number; state: PedalState }>[];
|
|
3485
4742
|
velocityCurve?: TrackSpec["velocityCurve"];
|
|
3486
4743
|
humanize?: TrackSpec["humanize"];
|
|
3487
4744
|
}>;
|
|
@@ -3506,14 +4763,80 @@ export type Song = Readonly<{
|
|
|
3506
4763
|
tracks: readonly ScoreTrack[];
|
|
3507
4764
|
notes: readonly ScoreNote[];
|
|
3508
4765
|
master?: MasterInput;
|
|
4766
|
+
/** Present only when the song names a style (SDK 1.33.0). */
|
|
4767
|
+
style?: StyleSpec;
|
|
3509
4768
|
/** Present only when the song has sections (SDK 1.18.0). */
|
|
3510
4769
|
sections?: readonly SongSection[];
|
|
3511
4770
|
/** Present only when the song has a form (SDK 1.18.0). */
|
|
3512
4771
|
form?: readonly SongFormEntry[];
|
|
3513
4772
|
/** Present only when a section loops (SDK 1.18.0). */
|
|
3514
4773
|
loopSection?: string;
|
|
4774
|
+
/** Present only when the song sets one (SDK 1.32.0). */
|
|
4775
|
+
calibration?: number;
|
|
4776
|
+
}>;
|
|
4777
|
+
|
|
4778
|
+
/** A song's style provenance (SDK 1.33.0); see `style()`. */
|
|
4779
|
+
export type StyleSpec = Readonly<{
|
|
4780
|
+
id: string;
|
|
4781
|
+
seed: number;
|
|
4782
|
+
bars: number;
|
|
4783
|
+
blend?: Readonly<{ id: string; weight: number }>;
|
|
4784
|
+
}>;
|
|
4785
|
+
|
|
4786
|
+
export type StyleOptions = Readonly<{
|
|
4787
|
+
/** Generator seed, integer 0..2147483647, default 1. */
|
|
4788
|
+
seed?: number;
|
|
4789
|
+
/** Bars generated, 1..256, default 8. */
|
|
4790
|
+
bars?: number;
|
|
4791
|
+
/** Blend partner and its weight 0..1: `blend: ["bebop", 0.3]`. */
|
|
4792
|
+
blend?: readonly [string, number];
|
|
3515
4793
|
}>;
|
|
3516
4794
|
|
|
4795
|
+
const STYLE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
4796
|
+
|
|
4797
|
+
/**
|
|
4798
|
+
* Which style and seed made a song (SDK 1.33.0), for `song({ style })`:
|
|
4799
|
+
*
|
|
4800
|
+
* ```ts
|
|
4801
|
+
* style("deep-house", { seed: 3, bars: 8 })
|
|
4802
|
+
* style("bebop", { seed: 7, blend: ["bossa-nova", 0.3] })
|
|
4803
|
+
* ```
|
|
4804
|
+
*
|
|
4805
|
+
* The id is a taxonomy id (`dawg` lists them with `/style list`). It is a
|
|
4806
|
+
* record of where the song came from; the notes live in the tracks.
|
|
4807
|
+
*/
|
|
4808
|
+
export function style(id: string, options: StyleOptions = {}): StyleSpec {
|
|
4809
|
+
if (typeof id !== "string" || !STYLE_ID_PATTERN.test(id))
|
|
4810
|
+
throw new DawgSdkError(
|
|
4811
|
+
`style id must be lowercase words joined by hyphens, like "deep-house"; got ${JSON.stringify(id)}`,
|
|
4812
|
+
);
|
|
4813
|
+
if (!isRecord(options))
|
|
4814
|
+
throw new DawgSdkError("style options must be an object");
|
|
4815
|
+
const seed = options.seed ?? 1;
|
|
4816
|
+
if (!Number.isInteger(seed) || seed < 0 || seed > 2147483647)
|
|
4817
|
+
throw new DawgSdkError("style seed must be an integer 0..2147483647");
|
|
4818
|
+
const bars = options.bars ?? 8;
|
|
4819
|
+
if (!Number.isInteger(bars) || bars < 1 || bars > 256)
|
|
4820
|
+
throw new DawgSdkError("style bars must be an integer 1..256");
|
|
4821
|
+
let blend: StyleSpec["blend"];
|
|
4822
|
+
if (options.blend !== undefined) {
|
|
4823
|
+
const pair = options.blend;
|
|
4824
|
+
if (
|
|
4825
|
+
!Array.isArray(pair) ||
|
|
4826
|
+
pair.length !== 2 ||
|
|
4827
|
+
typeof pair[0] !== "string" ||
|
|
4828
|
+
!STYLE_ID_PATTERN.test(pair[0]) ||
|
|
4829
|
+
typeof pair[1] !== "number" ||
|
|
4830
|
+
!(pair[1] >= 0 && pair[1] <= 1)
|
|
4831
|
+
)
|
|
4832
|
+
throw new DawgSdkError(
|
|
4833
|
+
'style blend must be ["style-id", weight 0..1], like ["bebop", 0.3]',
|
|
4834
|
+
);
|
|
4835
|
+
blend = Object.freeze({ id: pair[0], weight: pair[1] });
|
|
4836
|
+
}
|
|
4837
|
+
return Object.freeze({ id, seed, bars, ...(blend ? { blend } : {}) });
|
|
4838
|
+
}
|
|
4839
|
+
|
|
3517
4840
|
/** A stored song `time`: ticks, and 0-based bar indexes. */
|
|
3518
4841
|
export type ScoreTime = Readonly<{
|
|
3519
4842
|
tempo?: readonly Readonly<{
|
|
@@ -3529,6 +4852,24 @@ export type ScoreTime = Readonly<{
|
|
|
3529
4852
|
fermatas?: readonly Readonly<{ tick: number; beats: number }>[];
|
|
3530
4853
|
}>;
|
|
3531
4854
|
|
|
4855
|
+
/** `song({ style })`: the `style()` record, re-checked for hand-written objects. */
|
|
4856
|
+
function songStyle(input: unknown): { style?: StyleSpec } {
|
|
4857
|
+
if (input === undefined || input === null) return {};
|
|
4858
|
+
if (!isRecord(input))
|
|
4859
|
+
throw new DawgSdkError('song style must come from style("id", { seed })');
|
|
4860
|
+
const blend = input.blend;
|
|
4861
|
+
const spec = style(input.id as string, {
|
|
4862
|
+
seed: input.seed as number,
|
|
4863
|
+
bars: input.bars as number,
|
|
4864
|
+
...(blend !== undefined && blend !== null
|
|
4865
|
+
? isRecord(blend)
|
|
4866
|
+
? { blend: [blend.id, blend.weight] as unknown as [string, number] }
|
|
4867
|
+
: { blend: blend as [string, number] }
|
|
4868
|
+
: {}),
|
|
4869
|
+
});
|
|
4870
|
+
return { style: spec };
|
|
4871
|
+
}
|
|
4872
|
+
|
|
3532
4873
|
const MASTER_KEYS = ["eq", "glue", "tape", "width", "limiter", "target"];
|
|
3533
4874
|
|
|
3534
4875
|
/** Shape checks only; dawg validates every value when it loads the song. */
|
|
@@ -3665,6 +5006,9 @@ function songSections(
|
|
|
3665
5006
|
* lengths at least one tick), and every note gets a deterministic id from
|
|
3666
5007
|
* its track and content, so two evaluations of the same files agree.
|
|
3667
5008
|
*/
|
|
5009
|
+
/** The newest `song({ calibration })` (mirrors core CALIBRATION_LATEST). */
|
|
5010
|
+
export const SONG_CALIBRATION_LATEST = 1;
|
|
5011
|
+
|
|
3668
5012
|
export function song(input: SongInput): Song {
|
|
3669
5013
|
if (!isRecord(input)) throw new DawgSdkError("song() needs an object");
|
|
3670
5014
|
const tempoBpm = finite(input.tempo ?? 120, "song tempo");
|
|
@@ -3690,6 +5034,16 @@ export function song(input: SongInput): Song {
|
|
|
3690
5034
|
throw new DawgSdkError("song key must be a string or null");
|
|
3691
5035
|
const songTuning = tuningSpec(input.tuning, "song");
|
|
3692
5036
|
const master = masterData(input.master);
|
|
5037
|
+
const calibration = input.calibration ?? 0;
|
|
5038
|
+
if (
|
|
5039
|
+
typeof calibration !== "number" ||
|
|
5040
|
+
!Number.isInteger(calibration) ||
|
|
5041
|
+
calibration < 0 ||
|
|
5042
|
+
calibration > SONG_CALIBRATION_LATEST
|
|
5043
|
+
)
|
|
5044
|
+
throw new DawgSdkError(
|
|
5045
|
+
`song calibration must be an integer 0..${SONG_CALIBRATION_LATEST}`,
|
|
5046
|
+
);
|
|
3693
5047
|
if (!Array.isArray(input.tracks))
|
|
3694
5048
|
throw new DawgSdkError("song tracks must be an array of track()");
|
|
3695
5049
|
if (input.tracks.length > 64)
|
|
@@ -3771,11 +5125,13 @@ export function song(input: SongInput): Song {
|
|
|
3771
5125
|
if (Object.keys(time).length > 0) stored.time = Object.freeze(time);
|
|
3772
5126
|
}
|
|
3773
5127
|
if (t.glide) stored.glide = t.glide;
|
|
3774
|
-
|
|
5128
|
+
for (const key of ["pedal", "softPedal", "sostenuto"] as const) {
|
|
5129
|
+
const lane = t[key];
|
|
5130
|
+
if (!lane || lane.length === 0) continue;
|
|
3775
5131
|
// One event per tick (the last wins), in tick order, as dawg stores it.
|
|
3776
5132
|
const byTick = new Map<number, PedalState>();
|
|
3777
|
-
for (const [at, state] of
|
|
3778
|
-
stored
|
|
5133
|
+
for (const [at, state] of lane) byTick.set(ticks(at), state);
|
|
5134
|
+
stored[key] = Object.freeze(
|
|
3779
5135
|
[...byTick.entries()]
|
|
3780
5136
|
.sort((a, b) => a[0] - b[0])
|
|
3781
5137
|
.map(([tick, state]) => Object.freeze({ tick, state })),
|
|
@@ -3787,6 +5143,31 @@ export function song(input: SongInput): Song {
|
|
|
3787
5143
|
if (t.string) stored.string = t.string;
|
|
3788
5144
|
if (t.keys) stored.keys = t.keys;
|
|
3789
5145
|
if (t.modal) stored.modal = t.modal;
|
|
5146
|
+
if (t.guitar) stored.guitar = t.guitar;
|
|
5147
|
+
if (t.wind) stored.wind = t.wind;
|
|
5148
|
+
if (t.sing) stored.sing = t.sing;
|
|
5149
|
+
if (t.clips && t.clips.length > 0)
|
|
5150
|
+
stored.clips = Object.freeze(
|
|
5151
|
+
t.clips.map((clip, index) => storedClip(clip, index, ticks)),
|
|
5152
|
+
);
|
|
5153
|
+
if (t.takes && t.takes.length > 0)
|
|
5154
|
+
stored.takes = Object.freeze(
|
|
5155
|
+
t.takes.map((spec) => storedTake(spec, ticks)),
|
|
5156
|
+
);
|
|
5157
|
+
if (t.vocoder)
|
|
5158
|
+
stored.vocoder = Object.freeze(
|
|
5159
|
+
t.vocoder.src === undefined
|
|
5160
|
+
? t.vocoder
|
|
5161
|
+
: {
|
|
5162
|
+
...t.vocoder,
|
|
5163
|
+
src: resolveVocoderSrc(
|
|
5164
|
+
input.tracks as TrackSpec[],
|
|
5165
|
+
t.id,
|
|
5166
|
+
t.vocoder.src,
|
|
5167
|
+
),
|
|
5168
|
+
},
|
|
5169
|
+
);
|
|
5170
|
+
if (t.autotune) stored.autotune = t.autotune;
|
|
3790
5171
|
if (t.rhythm && t.rhythm.length > 0)
|
|
3791
5172
|
stored.rhythm = Object.freeze(
|
|
3792
5173
|
t.rhythm.map((row) => {
|
|
@@ -3826,11 +5207,33 @@ export function song(input: SongInput): Song {
|
|
|
3826
5207
|
: {}),
|
|
3827
5208
|
...(n.vibrato ? { vibrato: n.vibrato } : {}),
|
|
3828
5209
|
...(n.humanize ? { humanize: n.humanize } : {}),
|
|
5210
|
+
...(n.drift !== undefined ? { drift: n.drift } : {}),
|
|
3829
5211
|
...(n.cents ? { cents: n.cents } : {}),
|
|
5212
|
+
...(n.vowel ? { vowel: n.vowel } : {}),
|
|
5213
|
+
...(n.lyric !== undefined ? { lyric: n.lyric } : {}),
|
|
3830
5214
|
}),
|
|
3831
5215
|
);
|
|
3832
5216
|
}
|
|
3833
5217
|
});
|
|
5218
|
+
// An ombak pair (SDK 1.30.0): a partner (pengumbang) whose preset carries
|
|
5219
|
+
// its own ombak twin bank plays one straight voice instead, as `modal
|
|
5220
|
+
// pair` and set_modal leave it, unless it sets ombak itself.
|
|
5221
|
+
for (const track of [...tracks]) {
|
|
5222
|
+
const pair = (track as { modal?: { pair?: string } }).modal?.pair;
|
|
5223
|
+
if (pair === undefined) continue;
|
|
5224
|
+
const at = tracks.findIndex((candidate) => candidate.id === pair);
|
|
5225
|
+
const partner = tracks[at] as
|
|
5226
|
+
(ScoreTrack & { modal?: Readonly<Record<string, unknown>> }) | undefined;
|
|
5227
|
+
if (
|
|
5228
|
+
partner?.modal &&
|
|
5229
|
+
partner.modal.ombak === undefined &&
|
|
5230
|
+
MODAL_TWIN_PRESETS.includes(String(partner.modal.preset))
|
|
5231
|
+
)
|
|
5232
|
+
tracks[at] = Object.freeze({
|
|
5233
|
+
...partner,
|
|
5234
|
+
modal: Object.freeze({ ...partner.modal, ombak: 0 }),
|
|
5235
|
+
}) as ScoreTrack;
|
|
5236
|
+
}
|
|
3834
5237
|
const arrangement: {
|
|
3835
5238
|
sections?: readonly SongSection[];
|
|
3836
5239
|
form?: readonly SongFormEntry[];
|
|
@@ -3870,7 +5273,9 @@ export function song(input: SongInput): Song {
|
|
|
3870
5273
|
tracks: Object.freeze(tracks),
|
|
3871
5274
|
notes: Object.freeze(notes),
|
|
3872
5275
|
...(master ? { master } : {}),
|
|
5276
|
+
...songStyle(input.style),
|
|
3873
5277
|
...arrangement,
|
|
5278
|
+
...(calibration ? { calibration } : {}),
|
|
3874
5279
|
});
|
|
3875
5280
|
}
|
|
3876
5281
|
|
|
@@ -4383,7 +5788,7 @@ function songTime(
|
|
|
4383
5788
|
* an object with at most one table source (`edo`, `ratios`, `cents` or
|
|
4384
5789
|
* `scl`). Library names: `12-tet`, `19-edo`, `24-edo`, `31-edo`,
|
|
4385
5790
|
* `pythagorean`, `just` (5-limit), `7-limit`, `well-tuned-piano`, `pelog`,
|
|
4386
|
-
* `slendro`, `nyamaropa`, `shruti`, maqam and dastgah sets (`bayati`,
|
|
5791
|
+
* `slendro`, `nyamaropa`, `thai`, `shruti`, maqam and dastgah sets (`bayati`,
|
|
4387
5792
|
* `rast`, `saba`, `shur`, `homayoun`, `chahargah`) and raga intonations
|
|
4388
5793
|
* (`yaman`, `bhairav`, `kafi`, `todi`, …); `dawg` lists them with
|
|
4389
5794
|
* `/tuning list`. dawg checks every value when the song loads.
|
|
@@ -4495,7 +5900,22 @@ export type ChordOptions = Readonly<{
|
|
|
4495
5900
|
| "arp-random"
|
|
4496
5901
|
| "harp"
|
|
4497
5902
|
| "slop"
|
|
4498
|
-
| "pattern"
|
|
5903
|
+
| "pattern"
|
|
5904
|
+
| "guitar";
|
|
5905
|
+
/**
|
|
5906
|
+
* With `perform: "guitar"` (SDK 1.27.0): the stroke grid, a name (`down`
|
|
5907
|
+
* `folk` `pop` `punk` `funk` `reggae` `waltz` `jangle` `island`) or
|
|
5908
|
+
* characters D (down) U (up) d u (light) x (muted chuck) - . (rest).
|
|
5909
|
+
*/
|
|
5910
|
+
strokes?: string;
|
|
5911
|
+
/** Guitar: grid step in beats, default 0.5 (8ths). */
|
|
5912
|
+
step?: number;
|
|
5913
|
+
/** Guitar: milliseconds a full six-string down stroke takes, default 22. */
|
|
5914
|
+
speed?: number;
|
|
5915
|
+
/** Guitar: the song tempo `speed` converts with, default 120. */
|
|
5916
|
+
tempo?: number;
|
|
5917
|
+
/** Guitar: tuning, capo, hand, ring and position (as a track's `guitar`). */
|
|
5918
|
+
guitar?: GuitarInput;
|
|
4499
5919
|
/**
|
|
4500
5920
|
* With `perform: "pattern"`: a rhythm pattern by name or 1-based number
|
|
4501
5921
|
* (SDK 1.7.0): `eighths`, `sixteenths`, `offbeat`, `pop`, `charleston`,
|
|
@@ -4646,6 +6066,7 @@ export function progression(
|
|
|
4646
6066
|
seed: finite(options.seed ?? 0, "chord seed"),
|
|
4647
6067
|
...(options.pattern !== undefined ? { pattern: options.pattern } : {}),
|
|
4648
6068
|
velocity: vel,
|
|
6069
|
+
...(mode === "guitar" ? guitarPerform(options) : {}),
|
|
4649
6070
|
},
|
|
4650
6071
|
});
|
|
4651
6072
|
const out = [
|
|
@@ -4659,6 +6080,406 @@ export function progression(
|
|
|
4659
6080
|
);
|
|
4660
6081
|
}
|
|
4661
6082
|
|
|
6083
|
+
/** A track's guitar setup (SDK 1.27.0): see `TrackInput.guitar`. */
|
|
6084
|
+
export type GuitarInput = Readonly<{
|
|
6085
|
+
/** `standard` `dropd` `doubledropd` `dadgad` `openg` `opend` `opene` `halfdown` `nashville` `bass` `ukulele` `requinto`, or open-string pitches low to high. */
|
|
6086
|
+
tune?: string | readonly Pitch[];
|
|
6087
|
+
/** Capo fret 0..12. */
|
|
6088
|
+
capo?: number;
|
|
6089
|
+
/** Hand stretch in frets 3..6, default 4. */
|
|
6090
|
+
hand?: number;
|
|
6091
|
+
/** 0 closed shapes .. 1 ringing open strings, default 0.5. */
|
|
6092
|
+
ring?: number;
|
|
6093
|
+
/** Preferred fret position 0..12. */
|
|
6094
|
+
position?: number;
|
|
6095
|
+
}>;
|
|
6096
|
+
|
|
6097
|
+
function guitarInput(
|
|
6098
|
+
input: GuitarInput | undefined,
|
|
6099
|
+
where: string,
|
|
6100
|
+
): GuitarSetup | undefined {
|
|
6101
|
+
if (input === undefined) return undefined;
|
|
6102
|
+
if (!isRecord(input))
|
|
6103
|
+
throw new DawgSdkError(`${where} guitar must be an object`);
|
|
6104
|
+
const out: Record<string, unknown> = {};
|
|
6105
|
+
for (const key of Object.keys(input))
|
|
6106
|
+
if (!["tune", "capo", "hand", "ring", "position"].includes(key))
|
|
6107
|
+
throw new DawgSdkError(
|
|
6108
|
+
`${where} guitar has no field "${key.slice(0, 32)}" (tune capo hand ring position)`,
|
|
6109
|
+
);
|
|
6110
|
+
if (input.tune !== undefined) {
|
|
6111
|
+
if (typeof input.tune === "string") {
|
|
6112
|
+
const name = input.tune.toLowerCase().replace(/[\s_-]/g, "");
|
|
6113
|
+
if (!(GUITAR_TUNING_NAMES as readonly string[]).includes(name))
|
|
6114
|
+
throw new DawgSdkError(
|
|
6115
|
+
`${where} guitar tune must be one of ${GUITAR_TUNING_NAMES.join(" ")} or a list of pitches`,
|
|
6116
|
+
);
|
|
6117
|
+
out.tune = name;
|
|
6118
|
+
} else if (
|
|
6119
|
+
Array.isArray(input.tune) &&
|
|
6120
|
+
input.tune.length >= 3 &&
|
|
6121
|
+
input.tune.length <= 12
|
|
6122
|
+
)
|
|
6123
|
+
out.tune = Object.freeze(input.tune.map((p) => midi(p)));
|
|
6124
|
+
else
|
|
6125
|
+
throw new DawgSdkError(
|
|
6126
|
+
`${where} guitar tune must be a name or 3..12 pitches`,
|
|
6127
|
+
);
|
|
6128
|
+
}
|
|
6129
|
+
const int = (key: "capo" | "hand" | "position", min: number, max: number) => {
|
|
6130
|
+
const value = input[key];
|
|
6131
|
+
if (value === undefined) return;
|
|
6132
|
+
if (!Number.isInteger(value) || value < min || value > max)
|
|
6133
|
+
throw new DawgSdkError(
|
|
6134
|
+
`${where} guitar ${key} must be an integer ${min}..${max}`,
|
|
6135
|
+
);
|
|
6136
|
+
out[key] = value;
|
|
6137
|
+
};
|
|
6138
|
+
int("capo", 0, 12);
|
|
6139
|
+
int("hand", 3, 6);
|
|
6140
|
+
if (input.ring !== undefined)
|
|
6141
|
+
out.ring =
|
|
6142
|
+
Math.round(unit(input.ring, `${where} guitar ring`) * 1000) / 1000;
|
|
6143
|
+
int("position", 0, 12);
|
|
6144
|
+
return Object.keys(out).length > 0 ? Object.freeze(out) : undefined;
|
|
6145
|
+
}
|
|
6146
|
+
|
|
6147
|
+
function guitarPerform(options: ChordOptions): Partial<PerformOptions> {
|
|
6148
|
+
if (options.strokes !== undefined && !strokeGrid(options.strokes))
|
|
6149
|
+
throw new DawgSdkError(
|
|
6150
|
+
`chord strokes must be one of ${STROKE_PATTERN_NAMES.join(" ")} or a grid of D U d u x - .`,
|
|
6151
|
+
);
|
|
6152
|
+
const setup = guitarInput(options.guitar, "chord");
|
|
6153
|
+
return {
|
|
6154
|
+
...(options.strokes !== undefined ? { strokes: options.strokes } : {}),
|
|
6155
|
+
...(options.step !== undefined
|
|
6156
|
+
? { step: positive(options.step, "chord step") }
|
|
6157
|
+
: {}),
|
|
6158
|
+
...(options.speed !== undefined
|
|
6159
|
+
? {
|
|
6160
|
+
speed:
|
|
6161
|
+
Math.min(200, Math.max(0, finite(options.speed, "chord speed"))) /
|
|
6162
|
+
1000,
|
|
6163
|
+
}
|
|
6164
|
+
: {}),
|
|
6165
|
+
...(options.tempo !== undefined
|
|
6166
|
+
? { tempo: positive(options.tempo, "chord tempo") }
|
|
6167
|
+
: {}),
|
|
6168
|
+
...(setup ? { guitar: setup } : {}),
|
|
6169
|
+
};
|
|
6170
|
+
}
|
|
6171
|
+
|
|
6172
|
+
/**
|
|
6173
|
+
* Strummed guitar chords (SDK 1.27.0): `progression()` with
|
|
6174
|
+
* `perform: "guitar"`. Each chord is fretted on the guitar (`tune`, `capo`,
|
|
6175
|
+
* no barre over an open string) and strummed with a stroke grid; a down
|
|
6176
|
+
* stroke sweeps the strings in `speed` ms. Pair with `guitar` on the track
|
|
6177
|
+
* so dawg's prompt strums the same shapes.
|
|
6178
|
+
*
|
|
6179
|
+
* ```ts
|
|
6180
|
+
* notes: strum("G D Em C", { strokes: "folk", speed: 25, tempo: 96 })
|
|
6181
|
+
* notes: strum("i VI III VII", { key: "e minor", strokes: "D-DU-UDU", guitar: { capo: 2 } })
|
|
6182
|
+
* ```
|
|
6183
|
+
*/
|
|
6184
|
+
export function strum(
|
|
6185
|
+
chords: string | readonly string[],
|
|
6186
|
+
options: Omit<ProgressionOptions, "perform"> = {},
|
|
6187
|
+
): readonly NoteSpec[] {
|
|
6188
|
+
return progression(chords, { ...options, perform: "guitar" });
|
|
6189
|
+
}
|
|
6190
|
+
|
|
6191
|
+
// BEGIN lyrics: generated from core/lyrics.ts by core/sdk/sync-lyrics.ts
|
|
6192
|
+
/** Longest lyric on one note (SCORE_LIMITS.maxLyricLength). */
|
|
6193
|
+
const LYRIC_LIMIT = 32;
|
|
6194
|
+
|
|
6195
|
+
/** One lyric token: a syllable, a held note (`_`) or a skipped note (`~`). */
|
|
6196
|
+
type LyricToken = Readonly<{
|
|
6197
|
+
syl: string;
|
|
6198
|
+
/** Index of the word the token belongs to. */
|
|
6199
|
+
word: number;
|
|
6200
|
+
/** First syllable of its word. */
|
|
6201
|
+
first: boolean;
|
|
6202
|
+
kind: "syl" | "hold" | "rest";
|
|
6203
|
+
}>;
|
|
6204
|
+
|
|
6205
|
+
/**
|
|
6206
|
+
* The lyric grammar: words split by spaces, syllables by `-`, `_` holds the
|
|
6207
|
+
* previous syllable over the next note (melisma), `~` skips a note.
|
|
6208
|
+
* "sun-lit morn-ing _ glow" gives sun lit morn ing _ glow.
|
|
6209
|
+
*/
|
|
6210
|
+
function parseLyric(text: string): LyricToken[] {
|
|
6211
|
+
const out: LyricToken[] = [];
|
|
6212
|
+
let word = -1;
|
|
6213
|
+
for (const raw of text.trim().split(/\s+/u)) {
|
|
6214
|
+
if (raw === "") continue;
|
|
6215
|
+
if (raw === "_") out.push({ syl: "_", word, first: false, kind: "hold" });
|
|
6216
|
+
else if (raw === "~")
|
|
6217
|
+
out.push({ syl: "~", word, first: false, kind: "rest" });
|
|
6218
|
+
else {
|
|
6219
|
+
word += 1;
|
|
6220
|
+
raw
|
|
6221
|
+
.split("-")
|
|
6222
|
+
.filter(Boolean)
|
|
6223
|
+
.forEach((syl, index) =>
|
|
6224
|
+
out.push({ syl, word, first: index === 0, kind: "syl" }),
|
|
6225
|
+
);
|
|
6226
|
+
}
|
|
6227
|
+
}
|
|
6228
|
+
return out;
|
|
6229
|
+
}
|
|
6230
|
+
|
|
6231
|
+
const VOWEL = /[aeiouàáâäèéêëìíîïòóôöùúûü]/u;
|
|
6232
|
+
/** Consonant pairs that sound as one consonant and are never split. */
|
|
6233
|
+
const SYL_DIGRAPHS = new Set(["th", "sh", "ch", "ph", "wh", "ng", "ck", "gh"]);
|
|
6234
|
+
/** Digraphs that end a syllable (no English word starts with them). */
|
|
6235
|
+
const SYL_CODA_ONLY = new Set(["ng", "ck", "gh", "x"]);
|
|
6236
|
+
/** Consonant clusters a syllable may start with (maximal onset). */
|
|
6237
|
+
const SYL_ONSETS = new Set(
|
|
6238
|
+
(
|
|
6239
|
+
"bl br cl cr dr fl fr gl gr pl pr sc sk sl sm sn sp st sw tr tw dw " +
|
|
6240
|
+
"thr shr chr phr phl spl spr str scr squ skr"
|
|
6241
|
+
).split(" "),
|
|
6242
|
+
);
|
|
6243
|
+
/** Common words ending in a silent `e` that start compounds (some-thing). */
|
|
6244
|
+
const SYL_SILENT_E_HEADS = (
|
|
6245
|
+
"some home life time love fire side care where there here more one " +
|
|
6246
|
+
"make lone like name game base wide grace face place space stone bone"
|
|
6247
|
+
).split(" ");
|
|
6248
|
+
/** Suffixes kept whole after a silent `e` (love-ly, care-ful). */
|
|
6249
|
+
const SYL_SUFFIXES = ["ly", "ful", "less", "ness", "ment"];
|
|
6250
|
+
/** Unstressed endings that close a short vowel before them (nev-er). */
|
|
6251
|
+
const SYL_CLOSING_ENDINGS = new Set([
|
|
6252
|
+
"er",
|
|
6253
|
+
"en",
|
|
6254
|
+
"el",
|
|
6255
|
+
"et",
|
|
6256
|
+
"ed",
|
|
6257
|
+
"es",
|
|
6258
|
+
"est",
|
|
6259
|
+
"ing",
|
|
6260
|
+
]);
|
|
6261
|
+
|
|
6262
|
+
/**
|
|
6263
|
+
* Syllables of a word typed without hyphens: a guess for English, which a
|
|
6264
|
+
* hyphen always overrides (`nev-er`). Each run of vowels (and `y` after a
|
|
6265
|
+
* consonant) is one syllable; a final silent `e` does not count, but a
|
|
6266
|
+
* consonant plus `le` is its own syllable (lit-tle, ta-ble). Consonant
|
|
6267
|
+
* pairs that sound as one (th sh ch ph wh ng ck gh) never split. Between
|
|
6268
|
+
* vowels a cluster gives the next syllable the longest onset English
|
|
6269
|
+
* allows (mon-ster, chil-dren); one consonant goes with the next vowel
|
|
6270
|
+
* (ba-by, to-night) unless the previous vowel is short before an
|
|
6271
|
+
* unstressed ending (nev-er, sing-ing). "something" gives some thing,
|
|
6272
|
+
* "forever" for ev er.
|
|
6273
|
+
*/
|
|
6274
|
+
function autoSyllabify(word: string): string[] {
|
|
6275
|
+
const w = word.toLowerCase();
|
|
6276
|
+
// Compounds and suffixes after a silent e: some-thing, love-ly.
|
|
6277
|
+
if (w.length >= 6) {
|
|
6278
|
+
for (const head of SYL_SILENT_E_HEADS)
|
|
6279
|
+
if (w.startsWith(head) && VOWEL.test(w.slice(head.length)))
|
|
6280
|
+
return [
|
|
6281
|
+
word.slice(0, head.length),
|
|
6282
|
+
...autoSyllabify(word.slice(head.length)),
|
|
6283
|
+
];
|
|
6284
|
+
for (const suffix of SYL_SUFFIXES) {
|
|
6285
|
+
const stem = w.slice(0, -suffix.length);
|
|
6286
|
+
if (
|
|
6287
|
+
w.endsWith(suffix) &&
|
|
6288
|
+
stem.length >= 3 &&
|
|
6289
|
+
stem.endsWith("e") &&
|
|
6290
|
+
!VOWEL.test(stem[stem.length - 2]!)
|
|
6291
|
+
)
|
|
6292
|
+
return [
|
|
6293
|
+
...autoSyllabify(word.slice(0, stem.length)),
|
|
6294
|
+
word.slice(stem.length),
|
|
6295
|
+
];
|
|
6296
|
+
}
|
|
6297
|
+
}
|
|
6298
|
+
// Letters into units: a vowel, a consonant, a digraph, or `qu`.
|
|
6299
|
+
type Unit = { at: number; text: string; vowel: boolean };
|
|
6300
|
+
const units: Unit[] = [];
|
|
6301
|
+
for (let i = 0; i < w.length;) {
|
|
6302
|
+
const pair = w.slice(i, i + 2);
|
|
6303
|
+
if (pair === "qu" || SYL_DIGRAPHS.has(pair)) {
|
|
6304
|
+
units.push({ at: i, text: pair, vowel: false });
|
|
6305
|
+
i += 2;
|
|
6306
|
+
continue;
|
|
6307
|
+
}
|
|
6308
|
+
const ch = w[i]!;
|
|
6309
|
+
const prev = units.at(-1);
|
|
6310
|
+
const vowel =
|
|
6311
|
+
VOWEL.test(ch) || (ch === "y" && prev !== undefined && !prev.vowel);
|
|
6312
|
+
units.push({ at: i, text: ch, vowel });
|
|
6313
|
+
i += 1;
|
|
6314
|
+
}
|
|
6315
|
+
// Vowel groups as [first unit, last unit].
|
|
6316
|
+
const groups: [number, number][] = [];
|
|
6317
|
+
for (let u = 0; u < units.length;) {
|
|
6318
|
+
if (units[u]!.vowel) {
|
|
6319
|
+
let v = u;
|
|
6320
|
+
while (v + 1 < units.length && units[v + 1]!.vowel) v += 1;
|
|
6321
|
+
groups.push([u, v]);
|
|
6322
|
+
u = v + 1;
|
|
6323
|
+
} else u += 1;
|
|
6324
|
+
}
|
|
6325
|
+
const lastUnit = units.length - 1;
|
|
6326
|
+
const finalLe =
|
|
6327
|
+
w.endsWith("le") &&
|
|
6328
|
+
units.length >= 3 &&
|
|
6329
|
+
units[lastUnit - 1]!.text === "l" &&
|
|
6330
|
+
!units[lastUnit - 2]!.vowel;
|
|
6331
|
+
const last = groups.at(-1);
|
|
6332
|
+
if (
|
|
6333
|
+
groups.length > 1 &&
|
|
6334
|
+
last &&
|
|
6335
|
+
last[0] === lastUnit &&
|
|
6336
|
+
last[1] === lastUnit &&
|
|
6337
|
+
units[lastUnit]!.text === "e" &&
|
|
6338
|
+
!units[lastUnit - 1]!.vowel &&
|
|
6339
|
+
!finalLe
|
|
6340
|
+
)
|
|
6341
|
+
groups.pop();
|
|
6342
|
+
if (groups.length <= 1) return [word];
|
|
6343
|
+
const cuts: number[] = [];
|
|
6344
|
+
for (let g = 1; g < groups.length; g += 1) {
|
|
6345
|
+
const prev = groups[g - 1]!;
|
|
6346
|
+
const next = groups[g]!;
|
|
6347
|
+
const cluster = units.slice(prev[1] + 1, next[0]);
|
|
6348
|
+
const n = cluster.length;
|
|
6349
|
+
const isLast = g === groups.length - 1;
|
|
6350
|
+
let onset: number; // units of the cluster that start the next syllable
|
|
6351
|
+
if (isLast && finalLe && n >= 2)
|
|
6352
|
+
onset = cluster[n - 2]!.text === "ck" ? 1 : 2;
|
|
6353
|
+
else if (n === 1) {
|
|
6354
|
+
const unit = cluster[0]!.text;
|
|
6355
|
+
const prevText = units
|
|
6356
|
+
.slice(prev[0], prev[1] + 1)
|
|
6357
|
+
.map((u) => u.text)
|
|
6358
|
+
.join("");
|
|
6359
|
+
const ending = w.slice(units[next[0]]!.at);
|
|
6360
|
+
const short = prevText.length === 1 && "eiou".includes(prevText);
|
|
6361
|
+
const closes =
|
|
6362
|
+
SYL_CODA_ONLY.has(unit) ||
|
|
6363
|
+
(short && isLast && SYL_CLOSING_ENDINGS.has(ending)) ||
|
|
6364
|
+
(unit === "r" && short && units[next[0]]!.text === "e");
|
|
6365
|
+
onset = closes ? 0 : 1;
|
|
6366
|
+
} else {
|
|
6367
|
+
onset = 1;
|
|
6368
|
+
for (let k = n - 1; k >= 2; k -= 1)
|
|
6369
|
+
if (
|
|
6370
|
+
SYL_ONSETS.has(
|
|
6371
|
+
cluster
|
|
6372
|
+
.slice(n - k)
|
|
6373
|
+
.map((u) => u.text)
|
|
6374
|
+
.join(""),
|
|
6375
|
+
)
|
|
6376
|
+
) {
|
|
6377
|
+
onset = k;
|
|
6378
|
+
break;
|
|
6379
|
+
}
|
|
6380
|
+
if (SYL_CODA_ONLY.has(cluster[n - 1]!.text)) onset = 0;
|
|
6381
|
+
}
|
|
6382
|
+
const first = units[next[0] - onset]!;
|
|
6383
|
+
cuts.push(onset === 0 ? units[next[0]]!.at : first.at);
|
|
6384
|
+
}
|
|
6385
|
+
const out: string[] = [];
|
|
6386
|
+
let at = 0;
|
|
6387
|
+
for (const cut of cuts) {
|
|
6388
|
+
out.push(word.slice(at, cut));
|
|
6389
|
+
at = cut;
|
|
6390
|
+
}
|
|
6391
|
+
out.push(word.slice(at));
|
|
6392
|
+
return out.filter(Boolean);
|
|
6393
|
+
}
|
|
6394
|
+
|
|
6395
|
+
/** What `assignLyrics` put on each note, and what did not fit. */
|
|
6396
|
+
type LyricAssignment = Readonly<{
|
|
6397
|
+
/** Note id to its lyric (`_` holds); notes `~` skipped are absent. */
|
|
6398
|
+
lyrics: ReadonlyMap<string, string>;
|
|
6399
|
+
/** Syllables left over after the last note. */
|
|
6400
|
+
dropped: readonly string[];
|
|
6401
|
+
/** Words split automatically. */
|
|
6402
|
+
split: readonly string[];
|
|
6403
|
+
}>;
|
|
6404
|
+
|
|
6405
|
+
/**
|
|
6406
|
+
* Lyrics onto `notes` in time order. Hyphens split syllables as typed;
|
|
6407
|
+
* when the text has fewer syllables than there are notes, words typed
|
|
6408
|
+
* whole are split by `autoSyllabify`, and any notes still left hold the
|
|
6409
|
+
* last syllable (melisma) instead of failing. Syllables past the last note
|
|
6410
|
+
* are reported in `dropped`. Notes sharing an onset take one token, on
|
|
6411
|
+
* the top note, with `_` on the others. Each lyric is cut to the 32-character limit.
|
|
6412
|
+
*/
|
|
6413
|
+
function assignLyrics(
|
|
6414
|
+
text: string,
|
|
6415
|
+
notes: readonly Readonly<{ id: string; startTick: number; pitch: number }>[],
|
|
6416
|
+
): LyricAssignment {
|
|
6417
|
+
const sorted = [...notes].sort(
|
|
6418
|
+
(a, b) => a.startTick - b.startTick || b.pitch - a.pitch,
|
|
6419
|
+
);
|
|
6420
|
+
// Notes sharing an onset (a chord or a doubled note) take one token: the
|
|
6421
|
+
// top note carries it and the rest hold.
|
|
6422
|
+
const ordered: (typeof sorted)[number][] = [];
|
|
6423
|
+
const under = new Map<string, string[]>();
|
|
6424
|
+
for (const note of sorted) {
|
|
6425
|
+
const top = ordered.at(-1);
|
|
6426
|
+
if (top && top.startTick === note.startTick)
|
|
6427
|
+
under.get(top.id)!.push(note.id);
|
|
6428
|
+
else {
|
|
6429
|
+
ordered.push(note);
|
|
6430
|
+
under.set(note.id, []);
|
|
6431
|
+
}
|
|
6432
|
+
}
|
|
6433
|
+
let tokens = parseLyric(text);
|
|
6434
|
+
const split: string[] = [];
|
|
6435
|
+
if (tokens.length < ordered.length) {
|
|
6436
|
+
// Split every word typed whole; keep the split only if it still fits.
|
|
6437
|
+
const out: LyricToken[] = [];
|
|
6438
|
+
const words: string[] = [];
|
|
6439
|
+
for (const token of tokens) {
|
|
6440
|
+
const whole =
|
|
6441
|
+
token.kind === "syl" &&
|
|
6442
|
+
token.first &&
|
|
6443
|
+
!tokens.some((t) => t.word === token.word && !t.first);
|
|
6444
|
+
if (!whole) {
|
|
6445
|
+
out.push(token);
|
|
6446
|
+
continue;
|
|
6447
|
+
}
|
|
6448
|
+
const parts = autoSyllabify(token.syl);
|
|
6449
|
+
if (parts.length > 1) words.push(token.syl);
|
|
6450
|
+
parts.forEach((syl, index) =>
|
|
6451
|
+
out.push({ syl, word: token.word, first: index === 0, kind: "syl" }),
|
|
6452
|
+
);
|
|
6453
|
+
}
|
|
6454
|
+
if (out.length <= ordered.length) {
|
|
6455
|
+
tokens = out;
|
|
6456
|
+
split.push(...words);
|
|
6457
|
+
}
|
|
6458
|
+
}
|
|
6459
|
+
const lyrics = new Map<string, string>();
|
|
6460
|
+
const limit = LYRIC_LIMIT;
|
|
6461
|
+
ordered.forEach((note, index) => {
|
|
6462
|
+
const token = tokens[index];
|
|
6463
|
+
if (!token) {
|
|
6464
|
+
if (tokens.length > 0)
|
|
6465
|
+
for (const id of [note.id, ...under.get(note.id)!]) lyrics.set(id, "_");
|
|
6466
|
+
return;
|
|
6467
|
+
}
|
|
6468
|
+
if (token.kind === "rest") return;
|
|
6469
|
+
lyrics.set(
|
|
6470
|
+
note.id,
|
|
6471
|
+
token.kind === "hold" ? "_" : token.syl.slice(0, limit),
|
|
6472
|
+
);
|
|
6473
|
+
for (const id of under.get(note.id)!) lyrics.set(id, "_");
|
|
6474
|
+
});
|
|
6475
|
+
const dropped = tokens
|
|
6476
|
+
.slice(ordered.length)
|
|
6477
|
+
.filter((token) => token.kind === "syl")
|
|
6478
|
+
.map((token) => token.syl);
|
|
6479
|
+
return { lyrics, dropped, split };
|
|
6480
|
+
}
|
|
6481
|
+
// END lyrics
|
|
6482
|
+
|
|
4662
6483
|
// BEGIN instrument words: generated from core/instruments.ts by core/sdk/sync-instruments.ts
|
|
4663
6484
|
/** What an instrument word stores on a track. */
|
|
4664
6485
|
type InstrumentWord = Readonly<{
|
|
@@ -4780,6 +6601,42 @@ const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
|
|
|
4780
6601
|
preset: "dulcimer",
|
|
4781
6602
|
},
|
|
4782
6603
|
{ word: "sehtar", instrument: "string", field: "string", preset: "setar" },
|
|
6604
|
+
// bowed (f061-bowed): bowed presets of the string engine. `cello`,
|
|
6605
|
+
// `contrabass` and `strings` stay legacy words (today's voice);
|
|
6606
|
+
// `bowed-cello`, `string cello` or `bowed cello` reach the engine.
|
|
6607
|
+
{ word: "violin", instrument: "string", field: "string", preset: "violin" },
|
|
6608
|
+
{ word: "viola", instrument: "string", field: "string", preset: "viola" },
|
|
6609
|
+
{ word: "fiddle", instrument: "string", field: "string", preset: "fiddle" },
|
|
6610
|
+
{ word: "erhu", instrument: "string", field: "string", preset: "erhu" },
|
|
6611
|
+
{
|
|
6612
|
+
word: "kamancheh",
|
|
6613
|
+
instrument: "string",
|
|
6614
|
+
field: "string",
|
|
6615
|
+
preset: "kamancheh",
|
|
6616
|
+
},
|
|
6617
|
+
{
|
|
6618
|
+
word: "kemence",
|
|
6619
|
+
instrument: "string",
|
|
6620
|
+
field: "string",
|
|
6621
|
+
preset: "kamancheh",
|
|
6622
|
+
},
|
|
6623
|
+
{ word: "violins", instrument: "string", field: "string", preset: "violins" },
|
|
6624
|
+
{ word: "violas", instrument: "string", field: "string", preset: "violas" },
|
|
6625
|
+
{ word: "cellos", instrument: "string", field: "string", preset: "cellos" },
|
|
6626
|
+
{
|
|
6627
|
+
word: "contrabasses",
|
|
6628
|
+
instrument: "string",
|
|
6629
|
+
field: "string",
|
|
6630
|
+
preset: "contrabasses",
|
|
6631
|
+
},
|
|
6632
|
+
{ word: "pizzicato", instrument: "string", field: "string", preset: "pizz" },
|
|
6633
|
+
{ word: "tremolo", instrument: "string", field: "string", preset: "trem" },
|
|
6634
|
+
{
|
|
6635
|
+
word: "bowed-cello",
|
|
6636
|
+
instrument: "string",
|
|
6637
|
+
field: "string",
|
|
6638
|
+
preset: "cello",
|
|
6639
|
+
},
|
|
4783
6640
|
// f06-rig: guitar track aliases, a guitar voice plus a whole rig. The
|
|
4784
6641
|
// voice is the strings lane's `electric` row (jangle: its 12-string
|
|
4785
6642
|
// `jangle` preset); the pluck only while that row is absent. Never `lead`
|
|
@@ -4844,6 +6701,39 @@ const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
|
|
|
4844
6701
|
field: "keys",
|
|
4845
6702
|
preset: "prepared",
|
|
4846
6703
|
},
|
|
6704
|
+
// keys (f061-organ): tonewheel, combo and pipe organs on the keys
|
|
6705
|
+
// engine. `organ` stays legacy (the sine voice).
|
|
6706
|
+
{
|
|
6707
|
+
word: "tonewheel",
|
|
6708
|
+
instrument: "tonewheel",
|
|
6709
|
+
field: "keys",
|
|
6710
|
+
preset: "tonewheel",
|
|
6711
|
+
},
|
|
6712
|
+
{
|
|
6713
|
+
word: "hammond",
|
|
6714
|
+
instrument: "tonewheel",
|
|
6715
|
+
field: "keys",
|
|
6716
|
+
preset: "tonewheel",
|
|
6717
|
+
},
|
|
6718
|
+
{ word: "b3", instrument: "tonewheel", field: "keys", preset: "tonewheel" },
|
|
6719
|
+
{ word: "gospel", instrument: "tonewheel", field: "keys", preset: "gospel" },
|
|
6720
|
+
{
|
|
6721
|
+
word: "jazzorgan",
|
|
6722
|
+
instrument: "tonewheel",
|
|
6723
|
+
field: "keys",
|
|
6724
|
+
preset: "jazzorgan",
|
|
6725
|
+
},
|
|
6726
|
+
{ word: "combo", instrument: "combo", field: "keys", preset: "combo" },
|
|
6727
|
+
{ word: "farfisa", instrument: "combo", field: "keys", preset: "combo" },
|
|
6728
|
+
{ word: "vox", instrument: "combo", field: "keys", preset: "vox" },
|
|
6729
|
+
{ word: "pipe", instrument: "pipe", field: "keys", preset: "pipe" },
|
|
6730
|
+
{ word: "church", instrument: "pipe", field: "keys", preset: "pipe" },
|
|
6731
|
+
{ word: "pipeorgan", instrument: "pipe", field: "keys", preset: "pipe" },
|
|
6732
|
+
{ word: "churchorgan", instrument: "pipe", field: "keys", preset: "pipe" },
|
|
6733
|
+
{ word: "flutes", instrument: "pipe", field: "keys", preset: "flutes" },
|
|
6734
|
+
{ word: "cornet", instrument: "pipe", field: "keys", preset: "cornet" },
|
|
6735
|
+
{ word: "reeds", instrument: "pipe", field: "keys", preset: "reeds" },
|
|
6736
|
+
{ word: "celeste", instrument: "pipe", field: "keys", preset: "celeste" },
|
|
4847
6737
|
// f06-modal: mallets and bells (core/resonators.ts). `marimba` is legacy;
|
|
4848
6738
|
// `modal` alone gives the modal marimba.
|
|
4849
6739
|
{ word: "modal", instrument: "modal", field: "modal", preset: "marimba" },
|
|
@@ -4897,6 +6787,117 @@ const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
|
|
|
4897
6787
|
field: "modal",
|
|
4898
6788
|
preset: "chimes",
|
|
4899
6789
|
},
|
|
6790
|
+
// keys-electric (0.6.1): electric pianos and clavinet. `keys` stays legacy.
|
|
6791
|
+
{ word: "epiano", instrument: "epiano", field: "keys", preset: "epiano" },
|
|
6792
|
+
{ word: "rhodes", instrument: "epiano", field: "keys", preset: "epiano" },
|
|
6793
|
+
{
|
|
6794
|
+
word: "suitcase",
|
|
6795
|
+
instrument: "epiano",
|
|
6796
|
+
field: "keys",
|
|
6797
|
+
preset: "suitcase",
|
|
6798
|
+
},
|
|
6799
|
+
{ word: "dyno", instrument: "epiano", field: "keys", preset: "dyno" },
|
|
6800
|
+
{ word: "wurli", instrument: "wurli", field: "keys", preset: "wurli" },
|
|
6801
|
+
{ word: "wurlitzer", instrument: "wurli", field: "keys", preset: "wurli" },
|
|
6802
|
+
{ word: "clav", instrument: "clav", field: "keys", preset: "clav" },
|
|
6803
|
+
{ word: "clavinet", instrument: "clav", field: "keys", preset: "clav" },
|
|
6804
|
+
{ word: "funkclav", instrument: "clav", field: "keys", preset: "funkclav" },
|
|
6805
|
+
// f061-guitar: the shoegaze alias, an electric guitar voice plus the
|
|
6806
|
+
// shoegaze rig and its long wash.
|
|
6807
|
+
{ word: "shoegaze", instrument: "pluck", voice: "electric", fx: "shoegaze" },
|
|
6808
|
+
// f061 integration: the two rig names that are free as instrument words
|
|
6809
|
+
// (glide and swell are taken by commands; reach them with `rig glide`).
|
|
6810
|
+
{ word: "dreampop", instrument: "pluck", voice: "electric", fx: "dreampop" },
|
|
6811
|
+
{ word: "ebow", instrument: "pluck", voice: "electric", fx: "ebow" },
|
|
6812
|
+
// f061-gamelan-winds: gamelan, small bells and frame drums.
|
|
6813
|
+
{ word: "crotales", instrument: "modal", field: "modal", preset: "crotales" },
|
|
6814
|
+
{ word: "crotale", instrument: "modal", field: "modal", preset: "crotales" },
|
|
6815
|
+
{ word: "musicbox", instrument: "modal", field: "modal", preset: "musicbox" },
|
|
6816
|
+
{ word: "toypiano", instrument: "modal", field: "modal", preset: "toypiano" },
|
|
6817
|
+
{ word: "saron", instrument: "modal", field: "modal", preset: "saron" },
|
|
6818
|
+
{ word: "demung", instrument: "modal", field: "modal", preset: "demung" },
|
|
6819
|
+
{ word: "slenthem", instrument: "modal", field: "modal", preset: "slenthem" },
|
|
6820
|
+
{ word: "gangsa", instrument: "modal", field: "modal", preset: "gangsa" },
|
|
6821
|
+
{ word: "gender", instrument: "modal", field: "modal", preset: "gender" },
|
|
6822
|
+
{ word: "bonang", instrument: "modal", field: "modal", preset: "bonang" },
|
|
6823
|
+
{ word: "kenong", instrument: "modal", field: "modal", preset: "kenong" },
|
|
6824
|
+
{ word: "kethuk", instrument: "modal", field: "modal", preset: "kethuk" },
|
|
6825
|
+
{ word: "kempul", instrument: "modal", field: "modal", preset: "kempul" },
|
|
6826
|
+
{ word: "daf", instrument: "modal", field: "modal", preset: "daf" },
|
|
6827
|
+
{ word: "bodhran", instrument: "modal", field: "modal", preset: "bodhran" },
|
|
6828
|
+
{ word: "framedrum", instrument: "modal", field: "modal", preset: "bodhran" },
|
|
6829
|
+
{ word: "tabla", instrument: "modal", field: "modal", preset: "tabla" },
|
|
6830
|
+
// f061-gamelan-winds: blown waveguides (core/winds.ts). The legacy word
|
|
6831
|
+
// `wind` keeps its tone; these words and their aliases pick a wind preset.
|
|
6832
|
+
{ word: "flute", instrument: "wind", field: "wind", preset: "flute" },
|
|
6833
|
+
{ word: "recorder", instrument: "wind", field: "wind", preset: "recorder" },
|
|
6834
|
+
{ word: "whistle", instrument: "wind", field: "wind", preset: "whistle" },
|
|
6835
|
+
{ word: "ney", instrument: "wind", field: "wind", preset: "ney" },
|
|
6836
|
+
{
|
|
6837
|
+
word: "shakuhachi",
|
|
6838
|
+
instrument: "wind",
|
|
6839
|
+
field: "wind",
|
|
6840
|
+
preset: "shakuhachi",
|
|
6841
|
+
},
|
|
6842
|
+
{ word: "panpipe", instrument: "wind", field: "wind", preset: "panpipe" },
|
|
6843
|
+
{ word: "suling", instrument: "wind", field: "wind", preset: "suling" },
|
|
6844
|
+
{ word: "bansuri", instrument: "wind", field: "wind", preset: "bansuri" },
|
|
6845
|
+
{ word: "clarinet", instrument: "wind", field: "wind", preset: "clarinet" },
|
|
6846
|
+
{
|
|
6847
|
+
word: "bassclarinet",
|
|
6848
|
+
instrument: "wind",
|
|
6849
|
+
field: "wind",
|
|
6850
|
+
preset: "bassclarinet",
|
|
6851
|
+
},
|
|
6852
|
+
{ word: "oboe", instrument: "wind", field: "wind", preset: "oboe" },
|
|
6853
|
+
{ word: "bassoon", instrument: "wind", field: "wind", preset: "bassoon" },
|
|
6854
|
+
{ word: "sax", instrument: "wind", field: "wind", preset: "sax" },
|
|
6855
|
+
{ word: "altosax", instrument: "wind", field: "wind", preset: "altosax" },
|
|
6856
|
+
{ word: "barisax", instrument: "wind", field: "wind", preset: "barisax" },
|
|
6857
|
+
{ word: "trumpet", instrument: "wind", field: "wind", preset: "trumpet" },
|
|
6858
|
+
{ word: "harmon", instrument: "wind", field: "wind", preset: "harmon" },
|
|
6859
|
+
{ word: "plunger", instrument: "wind", field: "wind", preset: "plunger" },
|
|
6860
|
+
{ word: "trombone", instrument: "wind", field: "wind", preset: "trombone" },
|
|
6861
|
+
{ word: "tuba", instrument: "wind", field: "wind", preset: "tuba" },
|
|
6862
|
+
{ word: "horn", instrument: "wind", field: "wind", preset: "horn" },
|
|
6863
|
+
{ word: "tinwhistle", instrument: "wind", field: "wind", preset: "whistle" },
|
|
6864
|
+
{
|
|
6865
|
+
word: "pennywhistle",
|
|
6866
|
+
instrument: "wind",
|
|
6867
|
+
field: "wind",
|
|
6868
|
+
preset: "whistle",
|
|
6869
|
+
},
|
|
6870
|
+
{ word: "nay", instrument: "wind", field: "wind", preset: "ney" },
|
|
6871
|
+
{ word: "panflute", instrument: "wind", field: "wind", preset: "panpipe" },
|
|
6872
|
+
{ word: "panpipes", instrument: "wind", field: "wind", preset: "panpipe" },
|
|
6873
|
+
{ word: "saxophone", instrument: "wind", field: "wind", preset: "sax" },
|
|
6874
|
+
{ word: "tenorsax", instrument: "wind", field: "wind", preset: "sax" },
|
|
6875
|
+
{ word: "tenor", instrument: "wind", field: "wind", preset: "sax" },
|
|
6876
|
+
{ word: "alto", instrument: "wind", field: "wind", preset: "altosax" },
|
|
6877
|
+
{ word: "bari", instrument: "wind", field: "wind", preset: "barisax" },
|
|
6878
|
+
{ word: "baritonesax", instrument: "wind", field: "wind", preset: "barisax" },
|
|
6879
|
+
{ word: "frenchhorn", instrument: "wind", field: "wind", preset: "horn" },
|
|
6880
|
+
{ word: "mutedtrumpet", instrument: "wind", field: "wind", preset: "harmon" },
|
|
6881
|
+
{ word: "wahtrumpet", instrument: "wind", field: "wind", preset: "plunger" },
|
|
6882
|
+
// f07-sing: the built-in singing voice (core/sing.ts).
|
|
6883
|
+
{ word: "sing", instrument: "sing", field: "sing" },
|
|
6884
|
+
{ word: "aah", instrument: "sing", field: "sing", preset: "aah" },
|
|
6885
|
+
{ word: "ooh", instrument: "sing", field: "sing", preset: "ooh" },
|
|
6886
|
+
{ word: "choir", instrument: "sing", field: "sing", preset: "choir" },
|
|
6887
|
+
{ word: "chorale", instrument: "sing", field: "sing", preset: "chorale" },
|
|
6888
|
+
{ word: "khoomei", instrument: "sing", field: "sing", preset: "khoomei" },
|
|
6889
|
+
{ word: "sygyt", instrument: "sing", field: "sing", preset: "sygyt" },
|
|
6890
|
+
{ word: "kargyraa", instrument: "sing", field: "sing", preset: "kargyraa" },
|
|
6891
|
+
// f07-clips: a track of audio clips; its notes are guides.
|
|
6892
|
+
{ word: "vocal", instrument: "vocal" },
|
|
6893
|
+
// f07-vocoder: the built-in carrier (core/vocoder.ts); set a source with
|
|
6894
|
+
// `/vocoder src <track>`.
|
|
6895
|
+
{
|
|
6896
|
+
word: "vocoder",
|
|
6897
|
+
instrument: "vocoder",
|
|
6898
|
+
field: "vocoder",
|
|
6899
|
+
preset: "classic",
|
|
6900
|
+
},
|
|
4900
6901
|
]);
|
|
4901
6902
|
|
|
4902
6903
|
/**
|
|
@@ -4952,6 +6953,7 @@ const QUALITIES = [
|
|
|
4952
6953
|
"mb6",
|
|
4953
6954
|
"b6",
|
|
4954
6955
|
"7#9",
|
|
6956
|
+
"b5",
|
|
4955
6957
|
] as const;
|
|
4956
6958
|
type Quality = (typeof QUALITIES)[number];
|
|
4957
6959
|
|
|
@@ -4968,6 +6970,7 @@ const QUALITY_INTERVALS: Readonly<Record<Quality, readonly number[]>> =
|
|
|
4968
6970
|
mb6: [0, 3, 7, 8],
|
|
4969
6971
|
b6: [0, 4, 7, 8],
|
|
4970
6972
|
"7#9": [0, 4, 7, 10, 15],
|
|
6973
|
+
b5: [0, 4, 6],
|
|
4971
6974
|
});
|
|
4972
6975
|
|
|
4973
6976
|
/**
|
|
@@ -5014,6 +7017,17 @@ type Chord = Readonly<{
|
|
|
5014
7017
|
extensions: readonly Extension[];
|
|
5015
7018
|
/** Slash bass pitch class, when not the root. */
|
|
5016
7019
|
bass?: number | undefined;
|
|
7020
|
+
/**
|
|
7021
|
+
* Upper tensions beyond the four extension buttons, as semitones above
|
|
7022
|
+
* the root (13 b9, 15 #9, 17 11, 18 #11, 21 13): only typed symbols
|
|
7023
|
+
* such as `C11`, `G13` or `A7b9` carry them (0.6.1).
|
|
7024
|
+
*/
|
|
7025
|
+
tensions?: readonly number[] | undefined;
|
|
7026
|
+
/**
|
|
7027
|
+
* The root's letter, 0..6 for C..B, when a roman numeral spelled it:
|
|
7028
|
+
* `bVII` in C names Bb (not A#) and `vii` in F# names E# (not F).
|
|
7029
|
+
*/
|
|
7030
|
+
letter?: number | undefined;
|
|
5017
7031
|
}>;
|
|
5018
7032
|
|
|
5019
7033
|
function makeChord(
|
|
@@ -5021,6 +7035,7 @@ function makeChord(
|
|
|
5021
7035
|
quality: Quality,
|
|
5022
7036
|
extensions: Iterable<Extension> = [],
|
|
5023
7037
|
bass?: number,
|
|
7038
|
+
tensions: readonly number[] = [],
|
|
5024
7039
|
): Chord {
|
|
5025
7040
|
const held = new Set(extensions);
|
|
5026
7041
|
const own = SECRET_EXTENSION[quality];
|
|
@@ -5032,6 +7047,11 @@ function makeChord(
|
|
|
5032
7047
|
quality,
|
|
5033
7048
|
extensions: Object.freeze(ext),
|
|
5034
7049
|
...(slash !== undefined && slash !== pc ? { bass: slash } : {}),
|
|
7050
|
+
...(tensions.length > 0
|
|
7051
|
+
? {
|
|
7052
|
+
tensions: Object.freeze([...new Set(tensions)].sort((a, b) => a - b)),
|
|
7053
|
+
}
|
|
7054
|
+
: {}),
|
|
5035
7055
|
});
|
|
5036
7056
|
}
|
|
5037
7057
|
|
|
@@ -5039,6 +7059,7 @@ function makeChord(
|
|
|
5039
7059
|
function chordIntervals(chord: Chord): number[] {
|
|
5040
7060
|
const set = new Set(QUALITY_INTERVALS[chord.quality]);
|
|
5041
7061
|
for (const ext of chord.extensions) set.add(EXTENSION_INTERVAL[ext]);
|
|
7062
|
+
for (const step of chord.tensions ?? []) set.add(step);
|
|
5042
7063
|
// m7 and M7 together keep both; 6 with m7 on a dim triad is the dim7's bb7.
|
|
5043
7064
|
return [...set].sort((a, b) => a - b);
|
|
5044
7065
|
}
|
|
@@ -5085,12 +7106,43 @@ function noteName(pc: number, flats = false): string {
|
|
|
5085
7106
|
return (flats ? FLAT_NAMES : SHARP_NAMES)[mod12(pc)]!;
|
|
5086
7107
|
}
|
|
5087
7108
|
|
|
7109
|
+
const LETTERS = "CDEFGAB";
|
|
7110
|
+
const LETTER_PCS = [0, 2, 4, 5, 7, 9, 11] as const;
|
|
7111
|
+
|
|
7112
|
+
/**
|
|
7113
|
+
* `pc` spelled on letter `letter` (0..6, C..B) with one accidental at
|
|
7114
|
+
* most (E#, Cb, Bb); undefined when that would need a double accidental.
|
|
7115
|
+
*/
|
|
7116
|
+
function spellOnLetter(pc: number, letter: number): string | undefined {
|
|
7117
|
+
const l = ((Math.trunc(letter) % 7) + 7) % 7;
|
|
7118
|
+
const diff = ((mod12(pc) - LETTER_PCS[l]! + 18) % 12) - 6;
|
|
7119
|
+
if (Math.abs(diff) > 1) return undefined;
|
|
7120
|
+
return `${LETTERS[l]}${diff === 1 ? "#" : diff === -1 ? "b" : ""}`;
|
|
7121
|
+
}
|
|
7122
|
+
|
|
7123
|
+
/** The letter (0..6, C..B) a key's tonic is spelled on. */
|
|
7124
|
+
function tonicLetter(key: Key): number {
|
|
7125
|
+
return LETTERS.indexOf(noteName(key.tonic, keyUsesFlats(key))[0]!);
|
|
7126
|
+
}
|
|
7127
|
+
|
|
5088
7128
|
const SECRET_SUFFIX: Readonly<Partial<Record<Quality, string>>> = Object.freeze(
|
|
5089
7129
|
{ madd4: "m(add4)", mb6: "m(b6)", b6: "(b6)", "7#9": "7#9" },
|
|
5090
7130
|
);
|
|
5091
7131
|
|
|
5092
7132
|
/** Chord symbol suffix: `m7`, `maj9`, `7sus4`, `dim7`, `m7b5`, `6/9`. */
|
|
5093
7133
|
function chordSuffix(chord: Chord): string {
|
|
7134
|
+
if (chord.tensions?.length) {
|
|
7135
|
+
// A typed extended chord keeps the symbol it was typed as.
|
|
7136
|
+
const typed = TENSION_SUFFIXES.find(
|
|
7137
|
+
([, quality, ext, tensions]) =>
|
|
7138
|
+
quality === chord.quality &&
|
|
7139
|
+
ext.join() === chord.extensions.join() &&
|
|
7140
|
+
tensions.join() === chord.tensions!.join(),
|
|
7141
|
+
);
|
|
7142
|
+
if (typed) return typed[0];
|
|
7143
|
+
const names = chord.tensions.map((step) => TENSION_NAMES[step] ?? step);
|
|
7144
|
+
return `${chordSuffix({ ...chord, tensions: undefined })}(${names.join(",")})`;
|
|
7145
|
+
}
|
|
5094
7146
|
const ext = new Set(chord.extensions);
|
|
5095
7147
|
const b7 = ext.has("m7");
|
|
5096
7148
|
const M7 = ext.has("M7");
|
|
@@ -5151,6 +7203,9 @@ function chordSuffix(chord: Chord): string {
|
|
|
5151
7203
|
base = `${seventh}(no3)`;
|
|
5152
7204
|
if (nine) extras.push("9");
|
|
5153
7205
|
break;
|
|
7206
|
+
case "b5":
|
|
7207
|
+
base = `${ninth}b5`;
|
|
7208
|
+
break;
|
|
5154
7209
|
default:
|
|
5155
7210
|
base = seventh; // secret qualities returned above
|
|
5156
7211
|
}
|
|
@@ -5169,6 +7224,7 @@ function chordSuffix(chord: Chord): string {
|
|
|
5169
7224
|
mb6: "m(b6)",
|
|
5170
7225
|
b6: "(b6)",
|
|
5171
7226
|
"7#9": "7#9",
|
|
7227
|
+
b5: "(b5)",
|
|
5172
7228
|
};
|
|
5173
7229
|
base = triad[q];
|
|
5174
7230
|
if (six && nine && (q === "maj" || q === "min")) return `${base}6/9`;
|
|
@@ -5188,7 +7244,11 @@ function chordSuffix(chord: Chord): string {
|
|
|
5188
7244
|
function chordName(chord: Chord, flats = false): string {
|
|
5189
7245
|
const slash =
|
|
5190
7246
|
chord.bass === undefined ? "" : `/${noteName(chord.bass, flats)}`;
|
|
5191
|
-
|
|
7247
|
+
const root =
|
|
7248
|
+
(chord.letter === undefined
|
|
7249
|
+
? undefined
|
|
7250
|
+
: spellOnLetter(chord.root, chord.letter)) ?? noteName(chord.root, flats);
|
|
7251
|
+
return `${root}${chordSuffix(chord)}${slash}`;
|
|
5192
7252
|
}
|
|
5193
7253
|
|
|
5194
7254
|
/** Suffix → quality and extensions, longest first when parsing. */
|
|
@@ -5253,10 +7313,74 @@ const SUFFIXES: readonly (readonly [string, Quality, readonly Extension[]])[] =
|
|
|
5253
7313
|
["(b6)", "b6", []],
|
|
5254
7314
|
["addb6", "b6", []],
|
|
5255
7315
|
["7#9", "7#9", []],
|
|
7316
|
+
// 0.7: more spellings of the same chords.
|
|
7317
|
+
["-maj7", "min", ["M7"]],
|
|
7318
|
+
["mmaj7", "min", ["M7"]],
|
|
7319
|
+
["mMaj7", "min", ["M7"]],
|
|
7320
|
+
["m(maj7)", "min", ["M7"]],
|
|
7321
|
+
["-Δ7", "min", ["M7"]],
|
|
7322
|
+
["mΔ7", "min", ["M7"]],
|
|
7323
|
+
["add2", "maj", ["9"]],
|
|
7324
|
+
["2", "maj", ["9"]],
|
|
7325
|
+
["madd2", "min", ["9"]],
|
|
7326
|
+
["6add9", "maj", ["6", "9"]],
|
|
7327
|
+
["m6add9", "min", ["6", "9"]],
|
|
7328
|
+
["-6", "min", ["6"]],
|
|
7329
|
+
["-9", "min", ["m7", "9"]],
|
|
7330
|
+
["dom9", "maj", ["m7", "9"]],
|
|
7331
|
+
["aug(maj7)", "aug", ["M7"]],
|
|
7332
|
+
["augmaj7", "aug", ["M7"]],
|
|
7333
|
+
["+maj7", "aug", ["M7"]],
|
|
7334
|
+
["aug9", "aug", ["m7", "9"]],
|
|
7335
|
+
["+9", "aug", ["m7", "9"]],
|
|
7336
|
+
["m(maj9)", "min", ["M7", "9"]],
|
|
7337
|
+
["mmaj9", "min", ["M7", "9"]],
|
|
7338
|
+
["dim(maj7)", "dim", ["M7"]],
|
|
7339
|
+
["7(no3)", "5", ["m7"]],
|
|
7340
|
+
["maj7(no3)", "5", ["M7"]],
|
|
5256
7341
|
];
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
7342
|
+
|
|
7343
|
+
/** Typed upper-tension chords (0.6.1): suffix, quality, buttons, tensions. */
|
|
7344
|
+
const TENSION_SUFFIXES: readonly (readonly [
|
|
7345
|
+
string,
|
|
7346
|
+
Quality,
|
|
7347
|
+
readonly Extension[],
|
|
7348
|
+
readonly number[],
|
|
7349
|
+
])[] = [
|
|
7350
|
+
["11", "maj", ["m7", "9"], [17]],
|
|
7351
|
+
["m11", "min", ["m7", "9"], [17]],
|
|
7352
|
+
["maj11", "maj", ["M7", "9"], [17]],
|
|
7353
|
+
["add11", "maj", [], [17]],
|
|
7354
|
+
["madd11", "min", [], [17]],
|
|
7355
|
+
["13", "maj", ["m7", "9"], [21]],
|
|
7356
|
+
["m13", "min", ["m7", "9"], [21]],
|
|
7357
|
+
["maj13", "maj", ["M7", "9"], [21]],
|
|
7358
|
+
["7b9", "maj", ["m7"], [13]],
|
|
7359
|
+
["7#11", "maj", ["m7"], [18]],
|
|
7360
|
+
["maj7#11", "maj", ["M7"], [18]],
|
|
7361
|
+
["M7#11", "maj", ["M7"], [18]],
|
|
7362
|
+
["7b13", "maj", ["m7"], [20]],
|
|
7363
|
+
["13b9", "maj", ["m7"], [13, 21]],
|
|
7364
|
+
];
|
|
7365
|
+
|
|
7366
|
+
const TENSION_NAMES: Readonly<Record<number, string>> = Object.freeze({
|
|
7367
|
+
13: "b9",
|
|
7368
|
+
15: "#9",
|
|
7369
|
+
17: "11",
|
|
7370
|
+
18: "#11",
|
|
7371
|
+
20: "b13",
|
|
7372
|
+
21: "13",
|
|
7373
|
+
});
|
|
7374
|
+
|
|
7375
|
+
const SUFFIX_TABLE = new Map<string, SuffixEntry>([
|
|
7376
|
+
...SUFFIXES.map(
|
|
7377
|
+
([suffix, quality, ext]) => [suffix, { quality, ext }] as const,
|
|
7378
|
+
),
|
|
7379
|
+
...TENSION_SUFFIXES.map(
|
|
7380
|
+
([suffix, quality, ext, tensions]) =>
|
|
7381
|
+
[suffix, { quality, ext, tensions }] as const,
|
|
7382
|
+
),
|
|
7383
|
+
]);
|
|
5260
7384
|
|
|
5261
7385
|
const LETTER: Readonly<Record<string, number>> = Object.freeze({
|
|
5262
7386
|
c: 0,
|
|
@@ -5281,6 +7405,81 @@ function parsePitchClass(text: string): number | undefined {
|
|
|
5281
7405
|
return mod12(LETTER[match[1]!.toLowerCase()]! + accidental);
|
|
5282
7406
|
}
|
|
5283
7407
|
|
|
7408
|
+
/** Alteration → upper tension (semitones above the root). */
|
|
7409
|
+
const ALTERATION_TENSION: Readonly<Record<string, number>> = Object.freeze({
|
|
7410
|
+
b9: 13,
|
|
7411
|
+
"#9": 15,
|
|
7412
|
+
"11": 17,
|
|
7413
|
+
"#11": 18,
|
|
7414
|
+
"+11": 18,
|
|
7415
|
+
b13: 20,
|
|
7416
|
+
"13": 21,
|
|
7417
|
+
});
|
|
7418
|
+
|
|
7419
|
+
const TOKEN = "b5|#5|\\+5|b9|#9|#11|\\+11|b13|alt|add9|add11|add13|11|13|9|6";
|
|
7420
|
+
/** Bare alterations or parenthesized comma lists of them, in any order. */
|
|
7421
|
+
const ALTERATIONS = new RegExp(
|
|
7422
|
+
`^(?:(?:${TOKEN})|\\((?:${TOKEN})(?:,(?:${TOKEN}))*\\))+$`,
|
|
7423
|
+
);
|
|
7424
|
+
|
|
7425
|
+
type SuffixEntry = {
|
|
7426
|
+
quality: Quality;
|
|
7427
|
+
ext: readonly Extension[];
|
|
7428
|
+
tensions?: readonly number[];
|
|
7429
|
+
};
|
|
7430
|
+
|
|
7431
|
+
/**
|
|
7432
|
+
* A suffix the table does not list, read as a listed base followed by
|
|
7433
|
+
* alterations, bare or in parentheses: `7#5`, `9#11`, `13#11`, `7b9b13`,
|
|
7434
|
+
* `9b5`, `maj7+5`, `7alt`, `7(b9,#9)`. A raised fifth makes the triad
|
|
7435
|
+
* augmented, a lowered one makes a major triad `b5` (a minor one
|
|
7436
|
+
* diminished); `alt` is b9, #9, #11 and b13 over a dominant seventh.
|
|
7437
|
+
*/
|
|
7438
|
+
function parseAlteredSuffix(suffix: string): SuffixEntry | undefined {
|
|
7439
|
+
for (let cut = suffix.length - 1; cut >= 0; cut -= 1) {
|
|
7440
|
+
const base = SUFFIX_TABLE.get(suffix.slice(0, cut));
|
|
7441
|
+
if (!base) continue;
|
|
7442
|
+
const raw = suffix.slice(cut);
|
|
7443
|
+
if (!ALTERATIONS.test(raw)) continue;
|
|
7444
|
+
const rest = raw.replace(/[(),]/g, "");
|
|
7445
|
+
const tokens = rest.match(
|
|
7446
|
+
/b5|#5|\+5|b9|#9|#11|\+11|b13|alt|add9|add11|add13|11|13|9|6/g,
|
|
7447
|
+
);
|
|
7448
|
+
if (!tokens || tokens.join("") !== rest) continue;
|
|
7449
|
+
let quality: Quality = base.quality;
|
|
7450
|
+
const ext = new Set<Extension>(base.ext);
|
|
7451
|
+
const tensions = new Set<number>(base.tensions ?? []);
|
|
7452
|
+
let ok = true;
|
|
7453
|
+
for (const token of tokens) {
|
|
7454
|
+
if (token === "#5" || token === "+5") {
|
|
7455
|
+
if (quality === "maj" || quality === "aug") quality = "aug";
|
|
7456
|
+
else ok = false;
|
|
7457
|
+
} else if (token === "b5") {
|
|
7458
|
+
if (quality === "maj" || quality === "b5") quality = "b5";
|
|
7459
|
+
else if (quality === "min" || quality === "dim") quality = "dim";
|
|
7460
|
+
else ok = false;
|
|
7461
|
+
} else if (token === "alt") {
|
|
7462
|
+
if (quality !== "maj") ok = false;
|
|
7463
|
+
ext.add("m7");
|
|
7464
|
+
for (const step of [13, 15, 18, 20]) tensions.add(step);
|
|
7465
|
+
} else if (token === "9" || token === "add9") ext.add("9");
|
|
7466
|
+
else if (token === "6") ext.add("6");
|
|
7467
|
+
else {
|
|
7468
|
+
const step = ALTERATION_TENSION[token.replace(/^add/, "")];
|
|
7469
|
+
if (step === undefined) ok = false;
|
|
7470
|
+
else tensions.add(step);
|
|
7471
|
+
}
|
|
7472
|
+
}
|
|
7473
|
+
if (!ok) continue;
|
|
7474
|
+
return {
|
|
7475
|
+
quality,
|
|
7476
|
+
ext: [...ext],
|
|
7477
|
+
tensions: [...tensions].sort((a, b) => a - b),
|
|
7478
|
+
};
|
|
7479
|
+
}
|
|
7480
|
+
return undefined;
|
|
7481
|
+
}
|
|
7482
|
+
|
|
5284
7483
|
/** Parse a chord symbol (`Cm7`, `F#dim`, `Bbmaj9`, `G7sus4`, `C/E`). */
|
|
5285
7484
|
function parseChord(symbol: string): Chord | undefined {
|
|
5286
7485
|
if (typeof symbol !== "string" || symbol.length > 24) return undefined;
|
|
@@ -5291,14 +7490,15 @@ function parseChord(symbol: string): Chord | undefined {
|
|
|
5291
7490
|
const match = trimmed.match(/^([A-Ga-g])(#|b|♯|♭)?([^/]*)(?:\/(.+))?$/);
|
|
5292
7491
|
if (!match) return undefined;
|
|
5293
7492
|
const root = parsePitchClass(`${match[1]}${match[2] ?? ""}`);
|
|
5294
|
-
const entry =
|
|
7493
|
+
const entry =
|
|
7494
|
+
SUFFIX_TABLE.get(match[3] ?? "") ?? parseAlteredSuffix(match[3] ?? "");
|
|
5295
7495
|
if (root === undefined || !entry) return undefined;
|
|
5296
7496
|
let bass: number | undefined;
|
|
5297
7497
|
if (match[4] !== undefined) {
|
|
5298
7498
|
bass = parsePitchClass(match[4]);
|
|
5299
7499
|
if (bass === undefined) return undefined;
|
|
5300
7500
|
}
|
|
5301
|
-
return makeChord(root, entry.quality, entry.ext, bass);
|
|
7501
|
+
return makeChord(root, entry.quality, entry.ext, bass, entry.tensions);
|
|
5302
7502
|
}
|
|
5303
7503
|
|
|
5304
7504
|
// ---------------------------------------------------------------------------
|
|
@@ -5351,7 +7551,15 @@ const MODE_ALIASES: Readonly<Record<string, ModeName>> = Object.freeze({
|
|
|
5351
7551
|
});
|
|
5352
7552
|
|
|
5353
7553
|
type ScaleFamily =
|
|
5354
|
-
|
|
7554
|
+
| "pentatonic"
|
|
7555
|
+
| "blues"
|
|
7556
|
+
| "maqam"
|
|
7557
|
+
| "dastgah"
|
|
7558
|
+
| "raga"
|
|
7559
|
+
| "messiaen"
|
|
7560
|
+
| "chromatic"
|
|
7561
|
+
| "overtone"
|
|
7562
|
+
| "quarter-tone";
|
|
5355
7563
|
|
|
5356
7564
|
/**
|
|
5357
7565
|
* Scales beyond the chord modes, for keys such as `D bayati`, `C yaman` or
|
|
@@ -5398,6 +7606,12 @@ const SCALES = Object.freeze({
|
|
|
5398
7606
|
family: "blues",
|
|
5399
7607
|
aliases: ["major blues"],
|
|
5400
7608
|
},
|
|
7609
|
+
"yonanuki-minor": {
|
|
7610
|
+
steps: [0, 2, 3, 7, 8],
|
|
7611
|
+
mode: "minor",
|
|
7612
|
+
family: "pentatonic",
|
|
7613
|
+
aliases: ["yonanuki", "yonanuki minor", "enka minor"],
|
|
7614
|
+
},
|
|
5401
7615
|
hijaz: {
|
|
5402
7616
|
steps: [0, 1, 4, 5, 7, 8, 10],
|
|
5403
7617
|
mode: "phrygian-dominant",
|
|
@@ -5574,6 +7788,32 @@ const SCALES = Object.freeze({
|
|
|
5574
7788
|
mode: "harmonic-minor",
|
|
5575
7789
|
family: "messiaen",
|
|
5576
7790
|
},
|
|
7791
|
+
// All twelve pitch classes: the field of free atonality and the row.
|
|
7792
|
+
chromatic: {
|
|
7793
|
+
steps: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
|
7794
|
+
mode: "minor",
|
|
7795
|
+
family: "chromatic",
|
|
7796
|
+
aliases: ["twelve-tone", "aggregate"],
|
|
7797
|
+
},
|
|
7798
|
+
// Partials 8 to 15 of the harmonic series over the tonic (Grisey,
|
|
7799
|
+
// Murail): the acoustic scale with the 11th and 13th partials' and the
|
|
7800
|
+
// 7th's just pitches, which its named tuning applies.
|
|
7801
|
+
"harmonic-series": {
|
|
7802
|
+
steps: [0, 2, 4, 6, 7, 9, 10, 11],
|
|
7803
|
+
mode: "mixolydian",
|
|
7804
|
+
family: "overtone",
|
|
7805
|
+
intonation: [0, 204, 386, 551, 702, 841, 969, 1088],
|
|
7806
|
+
aliases: ["overtone", "overtone-scale", "partials"],
|
|
7807
|
+
},
|
|
7808
|
+
// Each tempered degree of the major scale beside its quarter-tone
|
|
7809
|
+
// shadow (Haba, Wyschnegradsky): neutral 2nd, 3rd, 6th and 7th and a
|
|
7810
|
+
// quarter-sharp 4th, sounded as note cents over twelve-tone keys.
|
|
7811
|
+
"quarter-tone": {
|
|
7812
|
+
steps: [0, 1.5, 2, 3.5, 4, 5, 5.5, 7, 8.5, 9, 10.5],
|
|
7813
|
+
mode: "major",
|
|
7814
|
+
family: "quarter-tone",
|
|
7815
|
+
aliases: ["quartertone", "24-tone"],
|
|
7816
|
+
},
|
|
5577
7817
|
} as const satisfies Record<string, ScaleInfo>);
|
|
5578
7818
|
type ScaleName = keyof typeof SCALES;
|
|
5579
7819
|
const SCALE_NAMES = Object.keys(SCALES) as ScaleName[];
|
|
@@ -5776,9 +8016,14 @@ function romanOf(key: Key, chord: Chord): string {
|
|
|
5776
8016
|
if (degree < 0) {
|
|
5777
8017
|
// Name chromatic roots against the major scale: bIII, #iv°.
|
|
5778
8018
|
const major = MODES.major.map((step) => mod12(key.tonic + step));
|
|
8019
|
+
const natural = major.indexOf(chord.root);
|
|
5779
8020
|
const flat = major.indexOf(mod12(chord.root + 1));
|
|
5780
8021
|
const sharp = major.indexOf(mod12(chord.root - 1));
|
|
5781
|
-
if (
|
|
8022
|
+
if (natural >= 0) {
|
|
8023
|
+
// A major-scale note the mode alters: ♮II in Phrygian.
|
|
8024
|
+
degree = natural;
|
|
8025
|
+
accidental = "♮";
|
|
8026
|
+
} else if (flat >= 0) {
|
|
5782
8027
|
degree = flat;
|
|
5783
8028
|
accidental = "b";
|
|
5784
8029
|
} else {
|
|
@@ -5789,7 +8034,6 @@ function romanOf(key: Key, chord: Chord): string {
|
|
|
5789
8034
|
const lower =
|
|
5790
8035
|
chord.quality === "min" ||
|
|
5791
8036
|
chord.quality === "dim" ||
|
|
5792
|
-
chord.quality === "5" ||
|
|
5793
8037
|
chord.quality === "madd4" ||
|
|
5794
8038
|
chord.quality === "mb6";
|
|
5795
8039
|
const numeral = NUMERALS[degree]!;
|
|
@@ -5806,7 +8050,33 @@ function romanOf(key: Key, chord: Chord): string {
|
|
|
5806
8050
|
: ext.has("M7")
|
|
5807
8051
|
? "maj7"
|
|
5808
8052
|
: "";
|
|
5809
|
-
|
|
8053
|
+
// The short numeral when it reads back as this chord; else the chord's
|
|
8054
|
+
// own suffix in brackets (`I[7]`, `i[m6]`, `V[7#9]`), which is exact.
|
|
8055
|
+
const plain = `${accidental}${body}${mark}${seventh}`;
|
|
8056
|
+
const bare = makeChord(
|
|
8057
|
+
chord.root,
|
|
8058
|
+
chord.quality,
|
|
8059
|
+
chord.extensions,
|
|
8060
|
+
undefined,
|
|
8061
|
+
chord.tensions,
|
|
8062
|
+
);
|
|
8063
|
+
for (const candidate of [
|
|
8064
|
+
plain,
|
|
8065
|
+
`${accidental}${body}${mark}${seventh === "7" ? "dom7" : seventh}`,
|
|
8066
|
+
])
|
|
8067
|
+
if (sameChord(parseRoman(key, candidate), bare)) return candidate;
|
|
8068
|
+
return `${accidental}${body}[${chordSuffix(bare)}]`;
|
|
8069
|
+
}
|
|
8070
|
+
|
|
8071
|
+
function sameChord(a: Chord | undefined, b: Chord): boolean {
|
|
8072
|
+
return (
|
|
8073
|
+
a !== undefined &&
|
|
8074
|
+
a.root === b.root &&
|
|
8075
|
+
a.quality === b.quality &&
|
|
8076
|
+
a.bass === b.bass &&
|
|
8077
|
+
a.extensions.join() === b.extensions.join() &&
|
|
8078
|
+
(a.tensions ?? []).join() === (b.tensions ?? []).join()
|
|
8079
|
+
);
|
|
5810
8080
|
}
|
|
5811
8081
|
|
|
5812
8082
|
/**
|
|
@@ -5819,31 +8089,64 @@ function romanOf(key: Key, chord: Chord): string {
|
|
|
5819
8089
|
* major). `7` adds the diatonic seventh; `maj7`/`M7` and `dom7` are exact.
|
|
5820
8090
|
*/
|
|
5821
8091
|
function parseRoman(key: Key, text: string): Chord | undefined {
|
|
5822
|
-
|
|
8092
|
+
return romanIn(key, text, tonicLetter(key));
|
|
8093
|
+
}
|
|
8094
|
+
|
|
8095
|
+
/** `parseRoman` with the tonic spelled on `tonic` (0..6, C..B). */
|
|
8096
|
+
function romanIn(key: Key, text: string, tonic: number): Chord | undefined {
|
|
8097
|
+
if (typeof text !== "string" || text.length > 32) return undefined;
|
|
5823
8098
|
const trimmed = text.trim();
|
|
8099
|
+
const exact = trimmed.match(
|
|
8100
|
+
/^(b|#|♭|♯|♮)?(vii|vi|v|iv|iii|ii|i|VII|VI|V|IV|III|II|I)\[([^\]]*)\]$/,
|
|
8101
|
+
);
|
|
8102
|
+
if (exact) {
|
|
8103
|
+
// `I[7]`: the numeral names the root, the bracket is a chord suffix.
|
|
8104
|
+
const degree = NUMERALS.indexOf(exact[2]!.toLowerCase());
|
|
8105
|
+
const shift =
|
|
8106
|
+
exact[1] === "b" || exact[1] === "♭"
|
|
8107
|
+
? -1
|
|
8108
|
+
: exact[1] === "♮" || !exact[1]
|
|
8109
|
+
? 0
|
|
8110
|
+
: 1;
|
|
8111
|
+
const root = !exact[1]
|
|
8112
|
+
? scaleOf(key)[degree]
|
|
8113
|
+
: mod12(key.tonic + MODES.major[degree]! + shift);
|
|
8114
|
+
if (root === undefined) return undefined;
|
|
8115
|
+
const letter = (tonic + degree) % 7;
|
|
8116
|
+
const chord = parseChord(
|
|
8117
|
+
`${spellOnLetter(root, letter) ?? noteName(root)}${exact[3]!}`,
|
|
8118
|
+
);
|
|
8119
|
+
return chord && Object.freeze({ ...chord, letter });
|
|
8120
|
+
}
|
|
5824
8121
|
const slash = trimmed.match(/^(.+)\/(.+)$/);
|
|
5825
8122
|
if (slash) {
|
|
5826
8123
|
// V/x: the chord built on the degree of x in the key (secondary function).
|
|
5827
|
-
const target =
|
|
8124
|
+
const target = romanIn(key, slash[2]!, tonic);
|
|
5828
8125
|
if (!target) return undefined;
|
|
5829
|
-
|
|
5830
|
-
|
|
8126
|
+
return romanIn(
|
|
8127
|
+
{ tonic: target.root, mode: "major" },
|
|
8128
|
+
slash[1]!,
|
|
8129
|
+
target.letter ?? tonic,
|
|
8130
|
+
);
|
|
5831
8131
|
}
|
|
5832
8132
|
const match = trimmed.match(
|
|
5833
|
-
/^(b
|
|
8133
|
+
/^(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)?$/,
|
|
5834
8134
|
);
|
|
5835
8135
|
if (!match) return undefined;
|
|
5836
8136
|
const accidental =
|
|
5837
|
-
match[1] === "b" || match[1] === "♭"
|
|
8137
|
+
match[1] === "b" || match[1] === "♭"
|
|
8138
|
+
? -1
|
|
8139
|
+
: match[1] === "#" || match[1] === "♯"
|
|
8140
|
+
? 1
|
|
8141
|
+
: 0;
|
|
5838
8142
|
const numeral = match[2]!;
|
|
5839
8143
|
const lower = numeral === numeral.toLowerCase();
|
|
5840
8144
|
const degree = NUMERALS.indexOf(numeral.toLowerCase());
|
|
5841
8145
|
const mark = match[3];
|
|
5842
8146
|
const suffix = match[4] ?? "";
|
|
5843
|
-
const root =
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
: mod12(key.tonic + MODES.major[degree]! + accidental);
|
|
8147
|
+
const root = !match[1]
|
|
8148
|
+
? scaleOf(key)[degree]!
|
|
8149
|
+
: mod12(key.tonic + MODES.major[degree]! + accidental);
|
|
5847
8150
|
const inKey = degreeOf(key, root);
|
|
5848
8151
|
const triad = inKey === undefined ? undefined : diatonicChord(key, inKey);
|
|
5849
8152
|
const seventh =
|
|
@@ -5897,7 +8200,10 @@ function parseRoman(key: Key, text: string): Chord | undefined {
|
|
|
5897
8200
|
quality = "sus2";
|
|
5898
8201
|
break;
|
|
5899
8202
|
}
|
|
5900
|
-
return
|
|
8203
|
+
return Object.freeze({
|
|
8204
|
+
...makeChord(root, quality, ext),
|
|
8205
|
+
letter: (tonic + degree) % 7,
|
|
8206
|
+
});
|
|
5901
8207
|
}
|
|
5902
8208
|
|
|
5903
8209
|
// ---------------------------------------------------------------------------
|
|
@@ -5939,7 +8245,17 @@ function rotate(pitches: readonly number[], steps: number): number[] {
|
|
|
5939
8245
|
|
|
5940
8246
|
function rootPosition(chord: Chord, anchor = 60): number[] {
|
|
5941
8247
|
const rootPitch = anchor + mod12(chord.root - anchor);
|
|
5942
|
-
|
|
8248
|
+
const steps = chordIntervals(chord);
|
|
8249
|
+
// A natural 11 over a major 3rd and a 7th is the avoid-note clash (E
|
|
8250
|
+
// under F a minor 9th up in C11), so the voicing drops the 3rd: C11 is
|
|
8251
|
+
// C G Bb D F, the sus voicing; C13 already leaves the 11 out.
|
|
8252
|
+
const clash =
|
|
8253
|
+
steps.includes(4) &&
|
|
8254
|
+
steps.includes(17) &&
|
|
8255
|
+
(steps.includes(10) || steps.includes(11));
|
|
8256
|
+
return steps
|
|
8257
|
+
.filter((step) => !(clash && step === 4))
|
|
8258
|
+
.map((step) => rootPitch + step);
|
|
5943
8259
|
}
|
|
5944
8260
|
|
|
5945
8261
|
/** Open voicings: `open` drops the second voice from the top an octave
|
|
@@ -6096,6 +8412,8 @@ const PERFORM_MODES = [
|
|
|
6096
8412
|
"harp",
|
|
6097
8413
|
"slop",
|
|
6098
8414
|
"pattern",
|
|
8415
|
+
// 0.6.1: fretboard-voiced strokes (see `voiceGuitar`, `strokeVoicing`).
|
|
8416
|
+
"guitar",
|
|
6099
8417
|
] as const;
|
|
6100
8418
|
type PerformMode = (typeof PERFORM_MODES)[number];
|
|
6101
8419
|
|
|
@@ -6115,6 +8433,23 @@ type PerformOptions = Readonly<{
|
|
|
6115
8433
|
pattern?: string | number;
|
|
6116
8434
|
/** 0..1. */
|
|
6117
8435
|
velocity?: number;
|
|
8436
|
+
/** Guitar mode: the stroke grid (a STROKE_PATTERNS name or D U d u x - .). */
|
|
8437
|
+
strokes?: string;
|
|
8438
|
+
/** Guitar mode: grid step in beats, default 1/2. */
|
|
8439
|
+
step?: number;
|
|
8440
|
+
/** Guitar mode: seconds a full six-string down stroke takes, default 0.022. */
|
|
8441
|
+
speed?: number;
|
|
8442
|
+
/** Guitar mode: tempo in BPM that `speed` converts with, default 120. */
|
|
8443
|
+
tempo?: number;
|
|
8444
|
+
/** Guitar mode: the guitar's tuning, capo, hand, ring and position. */
|
|
8445
|
+
guitar?: GuitarSetup;
|
|
8446
|
+
/** Guitar mode: the chord's root pitch class (default: the lowest note). */
|
|
8447
|
+
root?: number;
|
|
8448
|
+
/**
|
|
8449
|
+
* Guitar mode: a slash bass pitch class that must sound lowest, even when
|
|
8450
|
+
* it is not a chord tone (B/E keeps its E pedal). Default: the lowest note.
|
|
8451
|
+
*/
|
|
8452
|
+
slash?: number;
|
|
6118
8453
|
}>;
|
|
6119
8454
|
|
|
6120
8455
|
type PerformedNote = Readonly<{
|
|
@@ -6431,6 +8766,24 @@ function perform(
|
|
|
6431
8766
|
}
|
|
6432
8767
|
return out.sort((a, b) => a.start - b.start || a.pitch - b.pitch);
|
|
6433
8768
|
}
|
|
8769
|
+
case "guitar": {
|
|
8770
|
+
const bass = options.slash ?? notes[0]!;
|
|
8771
|
+
const voicing = voiceGuitar(
|
|
8772
|
+
notes.map(mod12),
|
|
8773
|
+
mod12(bass),
|
|
8774
|
+
options.root ?? mod12(bass),
|
|
8775
|
+
options.guitar,
|
|
8776
|
+
);
|
|
8777
|
+
if (!voicing) return notes.map((pitch) => at(pitch, start, end));
|
|
8778
|
+
return strokeVoicing(voicing, start, length, {
|
|
8779
|
+
...(options.strokes !== undefined ? { strokes: options.strokes } : {}),
|
|
8780
|
+
...(options.step !== undefined ? { step: options.step } : {}),
|
|
8781
|
+
...(options.speed !== undefined ? { speed: options.speed } : {}),
|
|
8782
|
+
...(options.tempo !== undefined ? { tempo: options.tempo } : {}),
|
|
8783
|
+
velocity,
|
|
8784
|
+
strings: guitarStrings(options.guitar?.tune).length,
|
|
8785
|
+
});
|
|
8786
|
+
}
|
|
6434
8787
|
case "harp": {
|
|
6435
8788
|
const gap = Math.max(0, options.strum ?? DEFAULT_STRUM * 2);
|
|
6436
8789
|
return spanned.map((pitch, index) =>
|
|
@@ -6465,6 +8818,380 @@ function perform(
|
|
|
6465
8818
|
}
|
|
6466
8819
|
}
|
|
6467
8820
|
|
|
8821
|
+
// ---------------------------------------------------------------------------
|
|
8822
|
+
// Fretboard (0.6.1): guitar voicings and strokes
|
|
8823
|
+
|
|
8824
|
+
/**
|
|
8825
|
+
* Open-string MIDI pitches, low string to high, for `guitar tune <name>`.
|
|
8826
|
+
* Ported from the guitar design lane (proto/guitar/strum.ts TUNINGS).
|
|
8827
|
+
*/
|
|
8828
|
+
const GUITAR_TUNINGS = Object.freeze({
|
|
8829
|
+
standard: [40, 45, 50, 55, 59, 64],
|
|
8830
|
+
dropd: [38, 45, 50, 55, 59, 64],
|
|
8831
|
+
doubledropd: [38, 45, 50, 55, 59, 62],
|
|
8832
|
+
dadgad: [38, 45, 50, 55, 57, 62],
|
|
8833
|
+
openg: [38, 43, 50, 55, 59, 62],
|
|
8834
|
+
opend: [38, 45, 50, 54, 57, 62],
|
|
8835
|
+
opene: [40, 47, 52, 56, 59, 64],
|
|
8836
|
+
halfdown: [39, 44, 49, 54, 58, 63],
|
|
8837
|
+
nashville: [52, 57, 62, 67, 59, 64],
|
|
8838
|
+
bass: [28, 33, 38, 43],
|
|
8839
|
+
ukulele: [67, 60, 64, 69],
|
|
8840
|
+
requinto: [45, 50, 55, 60, 64, 69],
|
|
8841
|
+
} as const satisfies Record<string, readonly number[]>);
|
|
8842
|
+
type GuitarTuningName = keyof typeof GUITAR_TUNINGS;
|
|
8843
|
+
const GUITAR_TUNING_NAMES = Object.keys(GUITAR_TUNINGS) as GuitarTuningName[];
|
|
8844
|
+
|
|
8845
|
+
/** How a guitar is set up for voicing chords (`Track.guitar`). */
|
|
8846
|
+
type GuitarSetup = Readonly<{
|
|
8847
|
+
/** A GUITAR_TUNINGS name or open-string pitches low to high. */
|
|
8848
|
+
tune?: string | readonly number[];
|
|
8849
|
+
/** Capo fret 0..12. */
|
|
8850
|
+
capo?: number;
|
|
8851
|
+
/** Hand stretch in frets 3..6 (default 4: one fret per finger). */
|
|
8852
|
+
hand?: number;
|
|
8853
|
+
/** 0 closed shapes (no open strings) .. 1 prefer ringing open strings. */
|
|
8854
|
+
ring?: number;
|
|
8855
|
+
/** Preferred fret position 0..12 (0 open position). */
|
|
8856
|
+
position?: number;
|
|
8857
|
+
}>;
|
|
8858
|
+
|
|
8859
|
+
/** One fretted chord: frets per string low to high (-1 muted). */
|
|
8860
|
+
type GuitarVoicing = Readonly<{
|
|
8861
|
+
frets: readonly number[];
|
|
8862
|
+
/** Sounding pitches, low string to high. */
|
|
8863
|
+
pitches: readonly number[];
|
|
8864
|
+
/** The string each pitch sounds on. */
|
|
8865
|
+
strings: readonly number[];
|
|
8866
|
+
}>;
|
|
8867
|
+
|
|
8868
|
+
/** Open-string pitches for a tuning name or list (standard when unknown). */
|
|
8869
|
+
function guitarStrings(tune: GuitarSetup["tune"]): readonly number[] {
|
|
8870
|
+
if (Array.isArray(tune)) return tune as readonly number[];
|
|
8871
|
+
const name = String(tune ?? "standard")
|
|
8872
|
+
.toLowerCase()
|
|
8873
|
+
.replace(/[\s_-]/g, "");
|
|
8874
|
+
return (
|
|
8875
|
+
(GUITAR_TUNINGS as Record<string, readonly number[]>)[name] ??
|
|
8876
|
+
GUITAR_TUNINGS.standard
|
|
8877
|
+
);
|
|
8878
|
+
}
|
|
8879
|
+
|
|
8880
|
+
/**
|
|
8881
|
+
* Fingers a fretting uses. Strings at the lowest fret count as one barre
|
|
8882
|
+
* only when no open string lies between them (a barre cannot skip an open
|
|
8883
|
+
* string); otherwise one finger each.
|
|
8884
|
+
*/
|
|
8885
|
+
function guitarFingers(frets: readonly number[]): number {
|
|
8886
|
+
const fretted = frets.filter((f) => f > 0);
|
|
8887
|
+
if (fretted.length === 0) return 0;
|
|
8888
|
+
const min = Math.min(...fretted);
|
|
8889
|
+
const at = frets.flatMap((f, i) => (f === min ? [i] : []));
|
|
8890
|
+
const between = frets.slice(at[0]!, at[at.length - 1]! + 1);
|
|
8891
|
+
const barre = at.length > 1 && !between.includes(0);
|
|
8892
|
+
return (barre ? 1 : at.length) + fretted.filter((f) => f > min).length;
|
|
8893
|
+
}
|
|
8894
|
+
|
|
8895
|
+
/**
|
|
8896
|
+
* The chord tones a voicing must keep, most required first, after dropping
|
|
8897
|
+
* `drops` tones in the guitarist's order: the 5th, then the 9th; the root,
|
|
8898
|
+
* the 3rd and the 7th always stay (after the design lane's review).
|
|
8899
|
+
*/
|
|
8900
|
+
function requiredTones(tones: readonly number[], root: number, drops: number) {
|
|
8901
|
+
// The 5th, then the 11th (only beside a 3rd: a sus4 keeps its 4th), then
|
|
8902
|
+
// the 9th. dawg's symbols reach an 11th through m(add4)/madd4 (+9).
|
|
8903
|
+
const third = tones.some((pc) => [3, 4].includes(mod12(pc - root)));
|
|
8904
|
+
const order = (third ? [7, 5, 2] : [7, 2]).map((step) => mod12(root + step));
|
|
8905
|
+
const dropped = order.filter((pc) => tones.includes(pc)).slice(0, drops);
|
|
8906
|
+
return tones.filter((pc) => !dropped.includes(pc));
|
|
8907
|
+
}
|
|
8908
|
+
|
|
8909
|
+
/**
|
|
8910
|
+
* The most playable fretting of pitch classes `tones` over `bass` on a
|
|
8911
|
+
* guitar `setup`: the bass is the lowest sounding note, every required
|
|
8912
|
+
* tone sounds, at most four fingers within a `hand`-fret stretch, mutes
|
|
8913
|
+
* only under the bass or one inside, and no barre over an open string.
|
|
8914
|
+
* Cost prefers more strings, low positions, open strings (by `ring`), a
|
|
8915
|
+
* fifth present, no doubled third, and small moves from `previous`.
|
|
8916
|
+
* Undefined when nothing fits. Ported from proto/guitar/strum.ts.
|
|
8917
|
+
*/
|
|
8918
|
+
function voiceGuitar(
|
|
8919
|
+
tones: readonly number[],
|
|
8920
|
+
bass: number,
|
|
8921
|
+
root: number,
|
|
8922
|
+
setup: GuitarSetup = {},
|
|
8923
|
+
previous?: readonly number[],
|
|
8924
|
+
): GuitarVoicing | undefined {
|
|
8925
|
+
// Play mode voices every pad press; dense chords at a wide hand take
|
|
8926
|
+
// ~15 ms to search, so repeats come from a small LRU (results are frozen).
|
|
8927
|
+
const key = JSON.stringify([
|
|
8928
|
+
[...new Set([...tones, bass].map(mod12))],
|
|
8929
|
+
mod12(bass),
|
|
8930
|
+
mod12(root),
|
|
8931
|
+
setup.tune ?? null,
|
|
8932
|
+
setup.capo ?? null,
|
|
8933
|
+
setup.hand ?? null,
|
|
8934
|
+
setup.ring ?? null,
|
|
8935
|
+
setup.position ?? null,
|
|
8936
|
+
previous ?? null,
|
|
8937
|
+
]);
|
|
8938
|
+
if (voicingCache.has(key)) {
|
|
8939
|
+
const hit = voicingCache.get(key);
|
|
8940
|
+
voicingCache.delete(key);
|
|
8941
|
+
voicingCache.set(key, hit);
|
|
8942
|
+
return hit;
|
|
8943
|
+
}
|
|
8944
|
+
const voiced = searchGuitar(tones, bass, root, setup, previous);
|
|
8945
|
+
voicingCache.set(key, voiced);
|
|
8946
|
+
if (voicingCache.size > VOICING_CACHE_SIZE)
|
|
8947
|
+
voicingCache.delete(voicingCache.keys().next().value!);
|
|
8948
|
+
return voiced;
|
|
8949
|
+
}
|
|
8950
|
+
|
|
8951
|
+
const VOICING_CACHE_SIZE = 256;
|
|
8952
|
+
const voicingCache = new Map<string, GuitarVoicing | undefined>();
|
|
8953
|
+
|
|
8954
|
+
function searchGuitar(
|
|
8955
|
+
tones: readonly number[],
|
|
8956
|
+
bass: number,
|
|
8957
|
+
root: number,
|
|
8958
|
+
setup: GuitarSetup,
|
|
8959
|
+
previous?: readonly number[],
|
|
8960
|
+
): GuitarVoicing | undefined {
|
|
8961
|
+
const pcs = [...new Set([...tones, bass].map(mod12))];
|
|
8962
|
+
const capo = clampInt(setup.capo ?? 0, 0, 12);
|
|
8963
|
+
const open = guitarStrings(setup.tune).map((pitch) => pitch + capo);
|
|
8964
|
+
const n = open.length;
|
|
8965
|
+
const ring = Math.min(1, Math.max(0, setup.ring ?? 0.5));
|
|
8966
|
+
const stretch = clampInt(setup.hand ?? 4, 3, 6);
|
|
8967
|
+
const fifth = mod12(root + 7);
|
|
8968
|
+
const third = pcs.find((pc) => [3, 4].includes(mod12(pc - root)));
|
|
8969
|
+
const prev = previous ? [...previous].sort((a, b) => a - b) : undefined;
|
|
8970
|
+
for (let drops = 0; drops <= 3; drops += 1) {
|
|
8971
|
+
const required = requiredTones(pcs, root, drops);
|
|
8972
|
+
let best: { frets: number[]; cost: number } | undefined;
|
|
8973
|
+
const choose = (frets: readonly number[]) => {
|
|
8974
|
+
const idx = frets.flatMap((f, i) => (f < 0 ? [] : [i]));
|
|
8975
|
+
if (idx.length < Math.min(n, n >= 6 ? 4 : 3)) return;
|
|
8976
|
+
const interior = frets.slice(idx[0]).filter((f) => f < 0).length;
|
|
8977
|
+
if (interior > (n >= 6 ? 1 : 0)) return;
|
|
8978
|
+
const pitches = idx.map((i) => open[i]! + frets[i]!);
|
|
8979
|
+
if (mod12(Math.min(...pitches)) !== mod12(bass)) return;
|
|
8980
|
+
const sounding = new Set(pitches.map(mod12));
|
|
8981
|
+
if (required.some((pc) => !sounding.has(pc))) return;
|
|
8982
|
+
const fingers = guitarFingers(frets);
|
|
8983
|
+
if (fingers > 4) return;
|
|
8984
|
+
const fretted = frets.filter((f) => f > 0);
|
|
8985
|
+
const span = fretted.length
|
|
8986
|
+
? Math.max(...fretted) - Math.min(...fretted)
|
|
8987
|
+
: 0;
|
|
8988
|
+
if (span > stretch - 1) return;
|
|
8989
|
+
const opens = frets.filter((f) => f === 0).length;
|
|
8990
|
+
if (ring === 0 && opens > 0) return;
|
|
8991
|
+
const pos = fretted.length ? Math.min(...fretted) : 0;
|
|
8992
|
+
let cost =
|
|
8993
|
+
span * 0.6 +
|
|
8994
|
+
pos * 0.25 +
|
|
8995
|
+
fingers * 0.4 -
|
|
8996
|
+
idx.length -
|
|
8997
|
+
opens * 0.7 * ring;
|
|
8998
|
+
cost += interior * 1.5;
|
|
8999
|
+
if (!sounding.has(fifth) && pcs.includes(fifth)) cost += 0.6;
|
|
9000
|
+
if (
|
|
9001
|
+
third !== undefined &&
|
|
9002
|
+
pitches.filter((p) => mod12(p) === third).length > 1
|
|
9003
|
+
)
|
|
9004
|
+
cost += 0.5;
|
|
9005
|
+
if (setup.position !== undefined)
|
|
9006
|
+
cost += Math.abs(pos - setup.position) * 0.5;
|
|
9007
|
+
if (prev && prev.length) {
|
|
9008
|
+
const cur = [...pitches].sort((a, b) => a - b);
|
|
9009
|
+
let move = 0;
|
|
9010
|
+
for (let i = 0; i < cur.length; i += 1)
|
|
9011
|
+
move += Math.abs(cur[i]! - prev[Math.min(i, prev.length - 1)]!);
|
|
9012
|
+
cost += move * 0.05;
|
|
9013
|
+
}
|
|
9014
|
+
if (
|
|
9015
|
+
!best ||
|
|
9016
|
+
cost < best.cost - 1e-9 ||
|
|
9017
|
+
(Math.abs(cost - best.cost) <= 1e-9 && frets.join() < best.frets.join())
|
|
9018
|
+
)
|
|
9019
|
+
best = { frets: [...frets], cost };
|
|
9020
|
+
};
|
|
9021
|
+
for (let p = 1; p <= 12; p += 1) {
|
|
9022
|
+
const options = open.map((o) => {
|
|
9023
|
+
const list = [-1];
|
|
9024
|
+
if (pcs.includes(mod12(o))) list.push(0);
|
|
9025
|
+
for (let f = p; f < p + stretch; f += 1)
|
|
9026
|
+
if (pcs.includes(mod12(o + f))) list.push(f);
|
|
9027
|
+
return list;
|
|
9028
|
+
});
|
|
9029
|
+
const frets = new Array<number>(n).fill(-1);
|
|
9030
|
+
const walk = (s: number): void => {
|
|
9031
|
+
if (s === n) return choose(frets);
|
|
9032
|
+
for (const f of options[s]!) {
|
|
9033
|
+
frets[s] = f;
|
|
9034
|
+
walk(s + 1);
|
|
9035
|
+
}
|
|
9036
|
+
};
|
|
9037
|
+
walk(0);
|
|
9038
|
+
}
|
|
9039
|
+
if (best) {
|
|
9040
|
+
const { frets } = best as { frets: number[] };
|
|
9041
|
+
const strings = frets.flatMap((f, i) => (f < 0 ? [] : [i]));
|
|
9042
|
+
return Object.freeze({
|
|
9043
|
+
frets: Object.freeze(frets),
|
|
9044
|
+
pitches: Object.freeze(strings.map((i) => open[i]! + frets[i]!)),
|
|
9045
|
+
strings: Object.freeze(strings),
|
|
9046
|
+
});
|
|
9047
|
+
}
|
|
9048
|
+
}
|
|
9049
|
+
return undefined;
|
|
9050
|
+
}
|
|
9051
|
+
|
|
9052
|
+
/** A voicing as tab, low string first: `x 3 2 0 1 0`. */
|
|
9053
|
+
function guitarTab(frets: readonly number[]): string {
|
|
9054
|
+
return frets.map((f) => (f < 0 ? "x" : String(f))).join(" ");
|
|
9055
|
+
}
|
|
9056
|
+
|
|
9057
|
+
/**
|
|
9058
|
+
* Stroke grids for perform mode `guitar`, one character per step: D down
|
|
9059
|
+
* (all strings), U up (top four), d light down (top four), u light up (top
|
|
9060
|
+
* three), x muted chuck, `-` or `.` rest (strings ring on). After the
|
|
9061
|
+
* design lane's STRUM_PATTERNS.
|
|
9062
|
+
*/
|
|
9063
|
+
const STROKE_PATTERNS = Object.freeze({
|
|
9064
|
+
down: "D",
|
|
9065
|
+
folk: "D-DU-UDU",
|
|
9066
|
+
pop: "D-DU-UD-",
|
|
9067
|
+
punk: "DDDDDDDD",
|
|
9068
|
+
funk: "xUxUDUxUxUDUxUxU",
|
|
9069
|
+
reggae: "-D-D",
|
|
9070
|
+
waltz: "Ddd",
|
|
9071
|
+
jangle: "D-DUDUDU",
|
|
9072
|
+
island: "D-DU-UDU",
|
|
9073
|
+
} as const satisfies Record<string, string>);
|
|
9074
|
+
const STROKE_PATTERN_NAMES = Object.keys(STROKE_PATTERNS);
|
|
9075
|
+
const STROKE_CHARS = /^[DUdux.\-]+$/;
|
|
9076
|
+
|
|
9077
|
+
/** Default seconds a full six-string down stroke takes (22 ms). */
|
|
9078
|
+
const DEFAULT_STROKE_SPEED = 0.022;
|
|
9079
|
+
/** Default stroke grid step in beats (8ths). */
|
|
9080
|
+
const DEFAULT_STROKE_STEP = 0.5;
|
|
9081
|
+
|
|
9082
|
+
/** A stroke grid by name, or a literal grid of D U d u x - .; undefined if bad. */
|
|
9083
|
+
function strokeGrid(text: string | undefined): string | undefined {
|
|
9084
|
+
const raw = (text ?? "down").trim();
|
|
9085
|
+
const named = (STROKE_PATTERNS as Record<string, string>)[raw.toLowerCase()];
|
|
9086
|
+
if (named) return named;
|
|
9087
|
+
const grid = raw.replace(/[|\s]/g, "");
|
|
9088
|
+
return grid.length > 0 && grid.length <= 64 && STROKE_CHARS.test(grid)
|
|
9089
|
+
? grid
|
|
9090
|
+
: undefined;
|
|
9091
|
+
}
|
|
9092
|
+
|
|
9093
|
+
/**
|
|
9094
|
+
* Strum fretted `voicing` from `start` for `length` beats with stroke grid
|
|
9095
|
+
* `strokes` (repeats every grid length, one step per `step` beats). A full
|
|
9096
|
+
* down stroke sweeps the six strings in `speed` seconds at `tempo` BPM;
|
|
9097
|
+
* each struck string rings until it is struck again or the chord ends; a
|
|
9098
|
+
* chuck (x) is a short muted hit that stops the strings. Velocity accents
|
|
9099
|
+
* downbeats and lightens upstrokes.
|
|
9100
|
+
*/
|
|
9101
|
+
function strokeVoicing(
|
|
9102
|
+
voicing: GuitarVoicing,
|
|
9103
|
+
start: number,
|
|
9104
|
+
length: number,
|
|
9105
|
+
options: Readonly<{
|
|
9106
|
+
strokes?: string;
|
|
9107
|
+
step?: number;
|
|
9108
|
+
speed?: number;
|
|
9109
|
+
tempo?: number;
|
|
9110
|
+
velocity?: number;
|
|
9111
|
+
strings?: number;
|
|
9112
|
+
}> = {},
|
|
9113
|
+
): PerformedNote[] {
|
|
9114
|
+
const grid = strokeGrid(options.strokes) ?? "D";
|
|
9115
|
+
const step =
|
|
9116
|
+
options.step && options.step > 0 ? options.step : DEFAULT_STROKE_STEP;
|
|
9117
|
+
const tempo = options.tempo && options.tempo > 0 ? options.tempo : 120;
|
|
9118
|
+
const sweep =
|
|
9119
|
+
(Math.max(0, options.speed ?? DEFAULT_STROKE_SPEED) * tempo) / 60;
|
|
9120
|
+
const base = options.velocity ?? 0.8;
|
|
9121
|
+
const total = Math.max(2, options.strings ?? 6);
|
|
9122
|
+
const sounding = voicing.strings;
|
|
9123
|
+
const end = start + length;
|
|
9124
|
+
type Open = { note: PerformedNote; index: number };
|
|
9125
|
+
const ringing = new Map<number, Open>();
|
|
9126
|
+
const out: PerformedNote[] = [];
|
|
9127
|
+
const stop = (string: number, at: number) => {
|
|
9128
|
+
const held = ringing.get(string);
|
|
9129
|
+
if (!held) return;
|
|
9130
|
+
const cut = round6(
|
|
9131
|
+
Math.max(1e-3, Math.min(held.note.length, at - held.note.start)),
|
|
9132
|
+
);
|
|
9133
|
+
out[held.index] = { ...held.note, length: cut };
|
|
9134
|
+
ringing.delete(string);
|
|
9135
|
+
};
|
|
9136
|
+
const steps = Math.max(1, Math.round(length / step));
|
|
9137
|
+
for (let s = 0; s < steps; s += 1) {
|
|
9138
|
+
const ch = grid[s % grid.length]!;
|
|
9139
|
+
if (ch === "-" || ch === ".") continue;
|
|
9140
|
+
const t0 = start + s * step;
|
|
9141
|
+
if (t0 >= end - 1e-9) break;
|
|
9142
|
+
const beat = s * step;
|
|
9143
|
+
const accent =
|
|
9144
|
+
Math.abs(beat - Math.round(beat)) < 1e-9
|
|
9145
|
+
? Math.round(beat) % 2 === 0
|
|
9146
|
+
? 1
|
|
9147
|
+
: 0.92
|
|
9148
|
+
: 0.82;
|
|
9149
|
+
let order: number[];
|
|
9150
|
+
let velocity = base * accent;
|
|
9151
|
+
let ring = end - t0;
|
|
9152
|
+
const all = sounding.map((_, k) => k);
|
|
9153
|
+
switch (ch) {
|
|
9154
|
+
case "D":
|
|
9155
|
+
order = all;
|
|
9156
|
+
break;
|
|
9157
|
+
case "U":
|
|
9158
|
+
order = all.slice(-4).reverse();
|
|
9159
|
+
velocity *= 0.85;
|
|
9160
|
+
break;
|
|
9161
|
+
case "d":
|
|
9162
|
+
order = all.slice(-4);
|
|
9163
|
+
velocity *= 0.62;
|
|
9164
|
+
break;
|
|
9165
|
+
case "u":
|
|
9166
|
+
order = all.slice(-3).reverse();
|
|
9167
|
+
velocity *= 0.55;
|
|
9168
|
+
break;
|
|
9169
|
+
default:
|
|
9170
|
+
order = all;
|
|
9171
|
+
velocity *= 0.5;
|
|
9172
|
+
ring = Math.min(ring, 0.03 * (tempo / 60));
|
|
9173
|
+
}
|
|
9174
|
+
const first = sounding[order[0]!]!;
|
|
9175
|
+
order.forEach((k, position) => {
|
|
9176
|
+
const string = sounding[k]!;
|
|
9177
|
+
const at = t0 + (Math.abs(string - first) / (total - 1)) * sweep;
|
|
9178
|
+
if (at >= end - 1e-9) return;
|
|
9179
|
+
stop(string, at);
|
|
9180
|
+
const note: PerformedNote = {
|
|
9181
|
+
pitch: voicing.pitches[k]!,
|
|
9182
|
+
start: round6(at),
|
|
9183
|
+
length: round6(Math.max(1e-3, Math.min(ring, end - at))),
|
|
9184
|
+
velocity: round6(
|
|
9185
|
+
Math.max(0.05, Math.min(1, velocity * (1 - 0.04 * position))),
|
|
9186
|
+
),
|
|
9187
|
+
};
|
|
9188
|
+
out.push(note);
|
|
9189
|
+
if (ch !== "x") ringing.set(string, { note, index: out.length - 1 });
|
|
9190
|
+
});
|
|
9191
|
+
}
|
|
9192
|
+
return out.sort((a, b) => a.start - b.start || a.pitch - b.pitch);
|
|
9193
|
+
}
|
|
9194
|
+
|
|
6468
9195
|
// ---------------------------------------------------------------------------
|
|
6469
9196
|
// Progressions
|
|
6470
9197
|
|
|
@@ -6848,6 +9575,14 @@ function renderProgression(options: RenderOptions): RenderedProgression {
|
|
|
6848
9575
|
...perform(chord.pitches, at, span, {
|
|
6849
9576
|
...options.perform,
|
|
6850
9577
|
seed: (options.perform?.seed ?? 0) + index,
|
|
9578
|
+
...(options.perform?.mode === "guitar"
|
|
9579
|
+
? {
|
|
9580
|
+
root: options.chords[index]!.root,
|
|
9581
|
+
...(options.chords[index]!.bass !== undefined
|
|
9582
|
+
? { slash: options.chords[index]!.bass }
|
|
9583
|
+
: {}),
|
|
9584
|
+
}
|
|
9585
|
+
: {}),
|
|
6851
9586
|
}),
|
|
6852
9587
|
);
|
|
6853
9588
|
const source = options.chords[index]!;
|
|
@@ -6876,6 +9611,362 @@ function renderProgression(options: RenderOptions): RenderedProgression {
|
|
|
6876
9611
|
}
|
|
6877
9612
|
// END chord engine
|
|
6878
9613
|
|
|
9614
|
+
// ---------------------------------------------------------------------------
|
|
9615
|
+
// Audio clips, takes and lyrics (SDK 1.32.0)
|
|
9616
|
+
|
|
9617
|
+
/** Options for `audio()`: times in beats, offsets and lengths in seconds. */
|
|
9618
|
+
export type AudioOptions = Readonly<{
|
|
9619
|
+
/** Stable clip id; defaults to `clip`, `clip2`, ... by position. */
|
|
9620
|
+
id?: string;
|
|
9621
|
+
/** Beat the clip starts on, default 0. */
|
|
9622
|
+
at?: number;
|
|
9623
|
+
/** Seconds into the file where the clip starts, default 0. */
|
|
9624
|
+
offset?: number;
|
|
9625
|
+
/** Seconds of the file it plays, default to the end. */
|
|
9626
|
+
dur?: number;
|
|
9627
|
+
/** Linear gain 0..4, default 1. */
|
|
9628
|
+
gain?: number;
|
|
9629
|
+
/** Equal-power fade in, seconds (default 5 ms). */
|
|
9630
|
+
fadeInTime?: number;
|
|
9631
|
+
/** Equal-power fade out, seconds (default 5 ms). */
|
|
9632
|
+
fadeTime?: number;
|
|
9633
|
+
/** Play the slice backwards. */
|
|
9634
|
+
rev?: boolean;
|
|
9635
|
+
/** The track take this clip plays from (its clock drift applies). */
|
|
9636
|
+
take?: string;
|
|
9637
|
+
mute?: boolean;
|
|
9638
|
+
/** The words sung in the clip, for the highway and lyrics. */
|
|
9639
|
+
text?: string;
|
|
9640
|
+
/** A text-to-speech clip's time map (0.7.1); kept as written. */
|
|
9641
|
+
say?: Readonly<Record<string, unknown>>;
|
|
9642
|
+
/** The file's pin; dawg fills it from the file when absent. */
|
|
9643
|
+
sha256?: string;
|
|
9644
|
+
}>;
|
|
9645
|
+
|
|
9646
|
+
/** One audio clip as `audio()` builds it. */
|
|
9647
|
+
export type AudioSpec = Readonly<
|
|
9648
|
+
{ kind: "audio"; src: string; at: number } & Omit<AudioOptions, "at">
|
|
9649
|
+
>;
|
|
9650
|
+
|
|
9651
|
+
const AUDIO_KEYS = [
|
|
9652
|
+
"id",
|
|
9653
|
+
"at",
|
|
9654
|
+
"offset",
|
|
9655
|
+
"dur",
|
|
9656
|
+
"gain",
|
|
9657
|
+
"fadeInTime",
|
|
9658
|
+
"fadeTime",
|
|
9659
|
+
"rev",
|
|
9660
|
+
"take",
|
|
9661
|
+
"mute",
|
|
9662
|
+
"text",
|
|
9663
|
+
"say",
|
|
9664
|
+
"sha256",
|
|
9665
|
+
] as const;
|
|
9666
|
+
|
|
9667
|
+
/**
|
|
9668
|
+
* An audio file on the track's timeline (SDK 1.32.0). `src` is relative
|
|
9669
|
+
* to the track folder (`samples/lead.wav`) or the project
|
|
9670
|
+
* (`tracks/vox/samples/lead.wav`).
|
|
9671
|
+
*
|
|
9672
|
+
* ```ts
|
|
9673
|
+
* clips: [audio("samples/verse.wav", { at: 16, gain: 0.8, fadeTime: 0.2 })]
|
|
9674
|
+
* ```
|
|
9675
|
+
*/
|
|
9676
|
+
export function audio(src: string, options: AudioOptions = {}): AudioSpec {
|
|
9677
|
+
const file = text(src, "audio src");
|
|
9678
|
+
if (!isRecord(options))
|
|
9679
|
+
throw new DawgSdkError("audio options must be an object");
|
|
9680
|
+
for (const key of Object.keys(options))
|
|
9681
|
+
if (!(AUDIO_KEYS as readonly string[]).includes(key))
|
|
9682
|
+
throw new DawgSdkError(
|
|
9683
|
+
`audio has an unknown option "${key.slice(0, 32)}" (${AUDIO_KEYS.join(" ")})`,
|
|
9684
|
+
);
|
|
9685
|
+
const out: Record<string, unknown> = {
|
|
9686
|
+
kind: "audio",
|
|
9687
|
+
src: file,
|
|
9688
|
+
at: beat(options.at ?? 0, "audio at"),
|
|
9689
|
+
};
|
|
9690
|
+
if (options.id !== undefined) out.id = text(options.id, "audio id");
|
|
9691
|
+
for (const key of [
|
|
9692
|
+
"offset",
|
|
9693
|
+
"dur",
|
|
9694
|
+
"gain",
|
|
9695
|
+
"fadeInTime",
|
|
9696
|
+
"fadeTime",
|
|
9697
|
+
] as const)
|
|
9698
|
+
if (options[key] !== undefined) {
|
|
9699
|
+
const value = finite(options[key], `audio ${key}`);
|
|
9700
|
+
if (value < 0) throw new DawgSdkError(`audio ${key} must be ≥ 0`);
|
|
9701
|
+
out[key] = value;
|
|
9702
|
+
}
|
|
9703
|
+
for (const key of ["rev", "mute"] as const)
|
|
9704
|
+
if (options[key] !== undefined) {
|
|
9705
|
+
if (typeof options[key] !== "boolean")
|
|
9706
|
+
throw new DawgSdkError(`audio ${key} must be true or false`);
|
|
9707
|
+
if (options[key]) out[key] = true;
|
|
9708
|
+
}
|
|
9709
|
+
if (options.take !== undefined) out.take = text(options.take, "audio take");
|
|
9710
|
+
if (options.text !== undefined) {
|
|
9711
|
+
if (typeof options.text !== "string" || options.text.length > 2000)
|
|
9712
|
+
throw new DawgSdkError("audio text must be at most 2000 characters");
|
|
9713
|
+
out.text = options.text;
|
|
9714
|
+
}
|
|
9715
|
+
if (options.say !== undefined) {
|
|
9716
|
+
if (!isRecord(options.say))
|
|
9717
|
+
throw new DawgSdkError("audio say must be an object");
|
|
9718
|
+
out.say = options.say;
|
|
9719
|
+
}
|
|
9720
|
+
if (options.sha256 !== undefined) {
|
|
9721
|
+
if (
|
|
9722
|
+
typeof options.sha256 !== "string" ||
|
|
9723
|
+
!/^[0-9a-f]{64}$/.test(options.sha256)
|
|
9724
|
+
)
|
|
9725
|
+
throw new DawgSdkError(
|
|
9726
|
+
"audio sha256 must be 64 lowercase hex characters",
|
|
9727
|
+
);
|
|
9728
|
+
out.sha256 = options.sha256;
|
|
9729
|
+
}
|
|
9730
|
+
return Object.freeze(out) as AudioSpec;
|
|
9731
|
+
}
|
|
9732
|
+
|
|
9733
|
+
/**
|
|
9734
|
+
* Copies of `clip` every `every` beats after it while they start before
|
|
9735
|
+
* `until` (SDK 1.32.0), the clip first: `...repeatAudio(hook, { every: 8,
|
|
9736
|
+
* until: 64 })`. Copies of a clip with an id get `<id>-r2`, `<id>-r3`, ...
|
|
9737
|
+
*/
|
|
9738
|
+
export function repeatAudio(
|
|
9739
|
+
clip: AudioSpec,
|
|
9740
|
+
options: Readonly<{ every: number; until: number }>,
|
|
9741
|
+
): readonly AudioSpec[] {
|
|
9742
|
+
if (!isRecord(clip) || clip.kind !== "audio")
|
|
9743
|
+
throw new DawgSdkError("repeatAudio needs a clip from audio()");
|
|
9744
|
+
if (!isRecord(options))
|
|
9745
|
+
throw new DawgSdkError("repeatAudio needs { every, until } in beats");
|
|
9746
|
+
const every = positive(options.every, "repeatAudio every");
|
|
9747
|
+
const until = beat(options.until, "repeatAudio until");
|
|
9748
|
+
const out: AudioSpec[] = [clip];
|
|
9749
|
+
for (
|
|
9750
|
+
let at = clip.at + every, pass = 2;
|
|
9751
|
+
at < until - 1e-9 && out.length < 256;
|
|
9752
|
+
at += every, pass += 1
|
|
9753
|
+
)
|
|
9754
|
+
out.push(
|
|
9755
|
+
Object.freeze({
|
|
9756
|
+
...clip,
|
|
9757
|
+
at,
|
|
9758
|
+
...(clip.id !== undefined ? { id: `${clip.id}-r${pass}` } : {}),
|
|
9759
|
+
}) as AudioSpec,
|
|
9760
|
+
);
|
|
9761
|
+
return Object.freeze(out);
|
|
9762
|
+
}
|
|
9763
|
+
|
|
9764
|
+
/** Options for `take()`: `at`, `in` and `out` in beats, the rest in seconds. */
|
|
9765
|
+
export type TakeOptions = Readonly<{
|
|
9766
|
+
/** Beat the recording's first sample lines up with. */
|
|
9767
|
+
at?: number;
|
|
9768
|
+
/** Punch range in beats, default `at` to `at + 4`. */
|
|
9769
|
+
in?: number;
|
|
9770
|
+
out?: number;
|
|
9771
|
+
/** Seconds into the file where the take starts. */
|
|
9772
|
+
offset?: number;
|
|
9773
|
+
/** Round-trip latency compensated, seconds. */
|
|
9774
|
+
latency?: number;
|
|
9775
|
+
latencyAssumed?: boolean;
|
|
9776
|
+
/** Clock drift in parts per million (±1000). */
|
|
9777
|
+
ppm?: number;
|
|
9778
|
+
/** 0..1 fit of the alignment. */
|
|
9779
|
+
fit?: number;
|
|
9780
|
+
warn?: string;
|
|
9781
|
+
/** Manual nudge in milliseconds (±250). */
|
|
9782
|
+
nudge?: number;
|
|
9783
|
+
sha256?: string;
|
|
9784
|
+
}>;
|
|
9785
|
+
|
|
9786
|
+
/** One take as `take()` builds it. */
|
|
9787
|
+
export type TakeSpec = Readonly<
|
|
9788
|
+
{
|
|
9789
|
+
kind: "take";
|
|
9790
|
+
name: string;
|
|
9791
|
+
src: string;
|
|
9792
|
+
at: number;
|
|
9793
|
+
in: number;
|
|
9794
|
+
out: number;
|
|
9795
|
+
} & Omit<TakeOptions, "at" | "in" | "out">
|
|
9796
|
+
>;
|
|
9797
|
+
|
|
9798
|
+
const TAKE_KEYS = [
|
|
9799
|
+
"at",
|
|
9800
|
+
"in",
|
|
9801
|
+
"out",
|
|
9802
|
+
"offset",
|
|
9803
|
+
"latency",
|
|
9804
|
+
"latencyAssumed",
|
|
9805
|
+
"ppm",
|
|
9806
|
+
"fit",
|
|
9807
|
+
"warn",
|
|
9808
|
+
"nudge",
|
|
9809
|
+
"sha256",
|
|
9810
|
+
] as const;
|
|
9811
|
+
|
|
9812
|
+
/**
|
|
9813
|
+
* A take (SDK 1.32.0): one recorded or imported pass the track's clips can
|
|
9814
|
+
* play from (`audio(src, { take: "take-1" })`). Recording itself is 0.7.1.
|
|
9815
|
+
*/
|
|
9816
|
+
export function take(
|
|
9817
|
+
name: string,
|
|
9818
|
+
src: string,
|
|
9819
|
+
options: TakeOptions = {},
|
|
9820
|
+
): TakeSpec {
|
|
9821
|
+
const label = `take ${text(name, "take name")}`;
|
|
9822
|
+
if (!isRecord(options))
|
|
9823
|
+
throw new DawgSdkError(`${label} options must be an object`);
|
|
9824
|
+
for (const key of Object.keys(options))
|
|
9825
|
+
if (!(TAKE_KEYS as readonly string[]).includes(key))
|
|
9826
|
+
throw new DawgSdkError(
|
|
9827
|
+
`${label} has an unknown option "${key.slice(0, 32)}" (${TAKE_KEYS.join(" ")})`,
|
|
9828
|
+
);
|
|
9829
|
+
const at = beat(options.at ?? 0, `${label} at`);
|
|
9830
|
+
const from = beat(options.in ?? at, `${label} in`);
|
|
9831
|
+
const to = beat(options.out ?? from + 4, `${label} out`);
|
|
9832
|
+
if (to <= from) throw new DawgSdkError(`${label}: out must be after in`);
|
|
9833
|
+
const out: Record<string, unknown> = {
|
|
9834
|
+
kind: "take",
|
|
9835
|
+
name,
|
|
9836
|
+
src: text(src, `${label} src`),
|
|
9837
|
+
at,
|
|
9838
|
+
in: from,
|
|
9839
|
+
out: to,
|
|
9840
|
+
};
|
|
9841
|
+
for (const key of ["offset", "latency", "ppm", "fit", "nudge"] as const)
|
|
9842
|
+
if (options[key] !== undefined)
|
|
9843
|
+
out[key] = finite(options[key], `${label} ${key}`);
|
|
9844
|
+
if (options.latencyAssumed) out.latencyAssumed = true;
|
|
9845
|
+
if (options.warn !== undefined)
|
|
9846
|
+
out.warn = text(options.warn, `${label} warn`);
|
|
9847
|
+
if (options.sha256 !== undefined)
|
|
9848
|
+
out.sha256 = text(options.sha256 as unknown, `${label} sha256`);
|
|
9849
|
+
return Object.freeze(out) as TakeSpec;
|
|
9850
|
+
}
|
|
9851
|
+
|
|
9852
|
+
/**
|
|
9853
|
+
* Sings `text` on `notes` in time order (SDK 1.32.0): spaces split words,
|
|
9854
|
+
* `-` splits syllables, `_` holds the previous syllable over the next note
|
|
9855
|
+
* (melisma), `~` skips a note. With fewer syllables than notes, words typed
|
|
9856
|
+
* whole split by vowel groups and leftover notes hold the last syllable;
|
|
9857
|
+
* syllables past the last note are dropped. Same rules as `/lyrics`.
|
|
9858
|
+
*
|
|
9859
|
+
* ```ts
|
|
9860
|
+
* notes: lyrics("sun-lit morn-ing glow", seq("C4 D4 E4 G4 E4"))
|
|
9861
|
+
* ```
|
|
9862
|
+
*/
|
|
9863
|
+
export function lyrics<N extends NoteSpec>(
|
|
9864
|
+
text: string,
|
|
9865
|
+
notes: readonly N[],
|
|
9866
|
+
): readonly N[] {
|
|
9867
|
+
if (typeof text !== "string" || text.length > 2000)
|
|
9868
|
+
throw new DawgSdkError("lyrics text must be at most 2000 characters");
|
|
9869
|
+
if (!Array.isArray(notes))
|
|
9870
|
+
throw new DawgSdkError("lyrics needs an array of notes");
|
|
9871
|
+
const keyed = notes.map((n, index) => ({
|
|
9872
|
+
id: String(index),
|
|
9873
|
+
startTick: Math.round(n.start * 960),
|
|
9874
|
+
pitch: n.pitch,
|
|
9875
|
+
}));
|
|
9876
|
+
const { lyrics: sung } = assignLyrics(text, keyed);
|
|
9877
|
+
return Object.freeze(
|
|
9878
|
+
notes.map((n, index) => {
|
|
9879
|
+
const lyric = sung.get(String(index));
|
|
9880
|
+
if (lyric === undefined) {
|
|
9881
|
+
const { lyric: _drop, ...rest } = n as N & { lyric?: string };
|
|
9882
|
+
return Object.freeze(rest) as unknown as N;
|
|
9883
|
+
}
|
|
9884
|
+
return Object.freeze({ ...n, lyric }) as N;
|
|
9885
|
+
}),
|
|
9886
|
+
);
|
|
9887
|
+
}
|
|
9888
|
+
|
|
9889
|
+
/** A clip for `song()`, ticks resolved and its default id filled. */
|
|
9890
|
+
function storedClip(
|
|
9891
|
+
clip: AudioSpec,
|
|
9892
|
+
index: number,
|
|
9893
|
+
ticks: (beats: number) => number,
|
|
9894
|
+
): Record<string, unknown> {
|
|
9895
|
+
const { kind: _kind, at, id, ...rest } = clip;
|
|
9896
|
+
return Object.freeze({
|
|
9897
|
+
id: id ?? defaultClipId(index),
|
|
9898
|
+
...rest,
|
|
9899
|
+
startTick: ticks(at),
|
|
9900
|
+
});
|
|
9901
|
+
}
|
|
9902
|
+
|
|
9903
|
+
/** The id a clip without one gets: `clip`, `clip2`, `clip3`, ... */
|
|
9904
|
+
export function defaultClipId(index: number): string {
|
|
9905
|
+
return index === 0 ? "clip" : `clip${index + 1}`;
|
|
9906
|
+
}
|
|
9907
|
+
|
|
9908
|
+
function storedTake(
|
|
9909
|
+
spec: TakeSpec,
|
|
9910
|
+
ticks: (beats: number) => number,
|
|
9911
|
+
): Record<string, unknown> {
|
|
9912
|
+
const { kind: _kind, at, in: from, out: to, ...rest } = spec;
|
|
9913
|
+
return Object.freeze({
|
|
9914
|
+
offset: 0,
|
|
9915
|
+
latency: 0,
|
|
9916
|
+
...rest,
|
|
9917
|
+
startTick: ticks(at),
|
|
9918
|
+
inTick: ticks(from),
|
|
9919
|
+
outTick: ticks(to),
|
|
9920
|
+
});
|
|
9921
|
+
}
|
|
9922
|
+
|
|
9923
|
+
/** `track({ clips, takes })` as TrackSpec fields, paths project-relative. */
|
|
9924
|
+
function trackClips(
|
|
9925
|
+
input: TrackInput,
|
|
9926
|
+
name: string,
|
|
9927
|
+
slug: string,
|
|
9928
|
+
): { clips?: readonly AudioSpec[]; takes?: readonly TakeSpec[] } {
|
|
9929
|
+
const local = (src: string) => {
|
|
9930
|
+
const path = src.replace(/^\.\//, "");
|
|
9931
|
+
return path.startsWith("tracks/") || path.startsWith("pack:")
|
|
9932
|
+
? path
|
|
9933
|
+
: `tracks/${slug}/${path}`;
|
|
9934
|
+
};
|
|
9935
|
+
const out: { clips?: readonly AudioSpec[]; takes?: readonly TakeSpec[] } = {};
|
|
9936
|
+
if (input.clips !== undefined) {
|
|
9937
|
+
if (!Array.isArray(input.clips))
|
|
9938
|
+
throw new DawgSdkError(
|
|
9939
|
+
`track ${name}: clips must be an array of audio()`,
|
|
9940
|
+
);
|
|
9941
|
+
const flat = (input.clips as readonly unknown[]).flat();
|
|
9942
|
+
const clips = flat.map((item, index) => {
|
|
9943
|
+
if (!isRecord(item) || item.kind !== "audio")
|
|
9944
|
+
throw new DawgSdkError(
|
|
9945
|
+
`track ${name}: clips[${index}] must come from audio() or repeatAudio()`,
|
|
9946
|
+
);
|
|
9947
|
+
const clip = item as AudioSpec;
|
|
9948
|
+
return Object.freeze({ ...clip, src: local(clip.src) }) as AudioSpec;
|
|
9949
|
+
});
|
|
9950
|
+
if (clips.length > 256)
|
|
9951
|
+
throw new DawgSdkError(`track ${name}: at most 256 clips`);
|
|
9952
|
+
if (clips.length > 0) out.clips = Object.freeze(clips);
|
|
9953
|
+
}
|
|
9954
|
+
if (input.takes !== undefined) {
|
|
9955
|
+
if (!Array.isArray(input.takes))
|
|
9956
|
+
throw new DawgSdkError(`track ${name}: takes must be an array of take()`);
|
|
9957
|
+
const takes = input.takes.map((item, index) => {
|
|
9958
|
+
if (!isRecord(item) || item.kind !== "take")
|
|
9959
|
+
throw new DawgSdkError(
|
|
9960
|
+
`track ${name}: takes[${index}] must come from take()`,
|
|
9961
|
+
);
|
|
9962
|
+
const spec = item as TakeSpec;
|
|
9963
|
+
return Object.freeze({ ...spec, src: local(spec.src) }) as TakeSpec;
|
|
9964
|
+
});
|
|
9965
|
+
if (takes.length > 0) out.takes = Object.freeze(takes);
|
|
9966
|
+
}
|
|
9967
|
+
return out;
|
|
9968
|
+
}
|
|
9969
|
+
|
|
6879
9970
|
// ---------------------------------------------------------------------------
|
|
6880
9971
|
// Internals
|
|
6881
9972
|
|