@hraness/dawg 0.3.0 → 0.4.1
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 +138 -0
- package/DAWG.md +608 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +411 -295
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1222 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
package/core/score.ts
CHANGED
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
* without rewriting the score.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { normalizeRhythmRow, RHYTHM_LIMITS, type RhythmRow } from "./euclid.ts";
|
|
10
|
+
import { isDrumInstrument } from "./drums.ts";
|
|
11
|
+
import { synthKit, SYNTH_KIT_NAMES } from "./kits.ts";
|
|
12
|
+
|
|
13
|
+
export type { RhythmRow } from "./euclid.ts";
|
|
14
|
+
import {
|
|
15
|
+
FX_LANES,
|
|
16
|
+
FxValidationError,
|
|
17
|
+
normalizeFx,
|
|
18
|
+
normalizeParams,
|
|
19
|
+
TRACK_EFFECT_SPECS,
|
|
20
|
+
type FxLane,
|
|
21
|
+
type TrackFx,
|
|
22
|
+
} from "./fx.ts";
|
|
23
|
+
import { normalizeSynth, type TrackSynth } from "./synth.ts";
|
|
24
|
+
|
|
9
25
|
export const SCORE_VERSION = 1 as const;
|
|
10
26
|
export const DEFAULT_TICKS_PER_BEAT = 480 as const;
|
|
11
27
|
|
|
@@ -33,21 +49,192 @@ export const SCORE_LIMITS = Object.freeze({
|
|
|
33
49
|
maxReverbMix: 1,
|
|
34
50
|
minReverbSize: 0,
|
|
35
51
|
maxReverbSize: 1,
|
|
52
|
+
/** Automation lanes kept in `fxAutomation` per track. */
|
|
53
|
+
maxFxLanes: 64,
|
|
36
54
|
/** Sampler voices per track (score v2, `sampler` field). */
|
|
37
55
|
maxSamplerVoices: 64,
|
|
38
56
|
maxSamplerVoiceNameLength: 32,
|
|
39
57
|
maxSamplePathLength: 256,
|
|
58
|
+
maxSampleUrlLength: 1024,
|
|
40
59
|
maxSampleGain: 2,
|
|
41
60
|
/** |speed| bound; negative speeds play in reverse. */
|
|
42
61
|
maxSampleSpeed: 8,
|
|
43
62
|
maxChokeGroupLength: 32,
|
|
63
|
+
/** Sampler `clip`/`legato` factor bound. */
|
|
64
|
+
maxSampleClip: 16,
|
|
65
|
+
/** |accelerate| bound. */
|
|
66
|
+
maxSampleAccelerate: 8,
|
|
67
|
+
/** Sampler `squiz` ratio bound. */
|
|
68
|
+
maxSampleSquiz: 32,
|
|
44
69
|
/** Per sample file, enforced by the decoder and the import tool. */
|
|
45
70
|
maxSampleFileBytes: 50 * 1024 * 1024,
|
|
46
71
|
maxSampleSeconds: 600,
|
|
47
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* In-memory decoded-sample budget. The disk caps (`.dawg/assets`, pack
|
|
74
|
+
* downloads) live in `src/audio/cache.ts` and follow
|
|
75
|
+
* `DAWG_ASSETS_CACHE_MAX` / `DAWG_PACKS_CACHE_MAX`.
|
|
76
|
+
*/
|
|
48
77
|
maxSampleCacheBytes: 512 * 1024 * 1024,
|
|
49
78
|
} as const);
|
|
50
79
|
|
|
80
|
+
/** Instrument name that selects a track's `wavetable` oscillator. */
|
|
81
|
+
export const WAVETABLE_INSTRUMENT = "wavetable" as const;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Warp modes (Strudel's documented `warpmode` names). Each bends the read
|
|
85
|
+
* phase of the table; see `src/audio/wavetable.ts` for the exact maps.
|
|
86
|
+
*/
|
|
87
|
+
export const WARP_MODES = Object.freeze([
|
|
88
|
+
"none",
|
|
89
|
+
"asym",
|
|
90
|
+
"bendp",
|
|
91
|
+
"bendm",
|
|
92
|
+
"bendmp",
|
|
93
|
+
"sync",
|
|
94
|
+
"quant",
|
|
95
|
+
] as const);
|
|
96
|
+
export type WarpMode = (typeof WARP_MODES)[number];
|
|
97
|
+
|
|
98
|
+
/** Prefix of a table generated in code (`builtin:basic`). */
|
|
99
|
+
export const BUILTIN_TABLE_PREFIX = "builtin:" as const;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A track's wavetable oscillator, with Strudel's parameter names. Every
|
|
103
|
+
* field but `table` is omitted at its default.
|
|
104
|
+
*/
|
|
105
|
+
export type TrackWavetable = Readonly<{
|
|
106
|
+
/**
|
|
107
|
+
* `builtin:<name>`, a pinned pack sound (`pack:uzu-wavetables/wt_digital:1`)
|
|
108
|
+
* or a project WAV (`tracks/<slug>/wavetables/vox.wav`, sha256-pinned).
|
|
109
|
+
*/
|
|
110
|
+
table: SampleRef;
|
|
111
|
+
/** Position in the table, 0..1 (default 0). */
|
|
112
|
+
wt?: number;
|
|
113
|
+
/** Position envelope amount, -1..1 (default 0 = off). */
|
|
114
|
+
wtenv?: number;
|
|
115
|
+
/** Position envelope times in seconds and sustain level 0..1. */
|
|
116
|
+
wtattack?: number;
|
|
117
|
+
wtdecay?: number;
|
|
118
|
+
wtsustain?: number;
|
|
119
|
+
wtrelease?: number;
|
|
120
|
+
/** Position LFO rate in Hz (0..50) and depth 0..1 (default 0 = off). */
|
|
121
|
+
wtrate?: number;
|
|
122
|
+
wtdepth?: number;
|
|
123
|
+
/** Warp amount 0..1 and mode (default none). */
|
|
124
|
+
warp?: number;
|
|
125
|
+
warpmode?: WarpMode;
|
|
126
|
+
/** Randomness of each note's start phase, 0..1 (seeded by the note). */
|
|
127
|
+
wtphaserand?: number;
|
|
128
|
+
}>;
|
|
129
|
+
|
|
130
|
+
/** Strudel parameter name → [min, max, default] for wavetable numbers. */
|
|
131
|
+
export const WAVETABLE_PARAMS = Object.freeze({
|
|
132
|
+
wt: [0, 1, 0],
|
|
133
|
+
wtenv: [-1, 1, 0],
|
|
134
|
+
wtattack: [0, 10, 0.01],
|
|
135
|
+
wtdecay: [0, 10, 0.1],
|
|
136
|
+
wtsustain: [0, 1, 1],
|
|
137
|
+
wtrelease: [0, 10, 0.1],
|
|
138
|
+
wtrate: [0, 50, 0],
|
|
139
|
+
wtdepth: [0, 1, 0],
|
|
140
|
+
warp: [0, 1, 0],
|
|
141
|
+
wtphaserand: [0, 1, 0],
|
|
142
|
+
} as const satisfies Record<string, readonly [number, number, number]>);
|
|
143
|
+
export type WavetableParam = keyof typeof WAVETABLE_PARAMS;
|
|
144
|
+
|
|
145
|
+
const BUILTIN_TABLE = /^builtin:[a-z][a-z0-9_-]{0,31}$/;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A project wavetable file: a project-relative `.wav` path (dawg writes them
|
|
149
|
+
* to `tracks/<slug>/wavetables/<name>.wav`).
|
|
150
|
+
*/
|
|
151
|
+
export function isLocalTableSrc(src: string): boolean {
|
|
152
|
+
return (
|
|
153
|
+
!src.startsWith(PACK_PREFIX) &&
|
|
154
|
+
!src.startsWith(BUILTIN_TABLE_PREFIX) &&
|
|
155
|
+
/\.wav$/i.test(src) &&
|
|
156
|
+
isSafeRelativePath(src)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isWavetableInstrument(instrument: string | undefined): boolean {
|
|
161
|
+
return (
|
|
162
|
+
typeof instrument === "string" &&
|
|
163
|
+
instrument.trim().toLowerCase() === WAVETABLE_INSTRUMENT
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The table a wavetable track plays: its own, else the built-in `basic`. */
|
|
168
|
+
export function wavetableOf(track: Track): TrackWavetable {
|
|
169
|
+
return track.wavetable ?? DEFAULT_WAVETABLE;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const DEFAULT_WAVETABLE: TrackWavetable = Object.freeze({
|
|
173
|
+
table: Object.freeze({ src: `${BUILTIN_TABLE_PREFIX}basic` }),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
export function normalizeWavetable(input: unknown): TrackWavetable | undefined {
|
|
177
|
+
if (input === undefined || input === null) return undefined;
|
|
178
|
+
if (!isRecord(input))
|
|
179
|
+
throw new ScoreValidationError(
|
|
180
|
+
"track wavetable must be an object or null",
|
|
181
|
+
"invalid-track",
|
|
182
|
+
);
|
|
183
|
+
const table = input.table;
|
|
184
|
+
let ref: SampleRef;
|
|
185
|
+
if (
|
|
186
|
+
isRecord(table) &&
|
|
187
|
+
typeof table.src === "string" &&
|
|
188
|
+
table.src.startsWith(BUILTIN_TABLE_PREFIX)
|
|
189
|
+
) {
|
|
190
|
+
if (!BUILTIN_TABLE.test(table.src) || Object.keys(table).length !== 1)
|
|
191
|
+
throw new ScoreValidationError(
|
|
192
|
+
"wavetable table builtin:<name> takes no other fields",
|
|
193
|
+
"invalid-track",
|
|
194
|
+
);
|
|
195
|
+
ref = Object.freeze({ src: table.src });
|
|
196
|
+
} else {
|
|
197
|
+
ref = normalizeSampleRef(table, "wavetable table");
|
|
198
|
+
if (!isPackRef(ref.src) && !isLocalTableSrc(ref.src))
|
|
199
|
+
throw new ScoreValidationError(
|
|
200
|
+
"wavetable table must be builtin:<name>, pack:<pack>/<sound>[:<n>] or a project .wav path",
|
|
201
|
+
"invalid-track",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const out: Record<string, unknown> = { table: ref };
|
|
205
|
+
for (const name of Object.keys(WAVETABLE_PARAMS) as WavetableParam[]) {
|
|
206
|
+
const value = input[name];
|
|
207
|
+
if (value === undefined) continue;
|
|
208
|
+
const [min, max, fallback] = WAVETABLE_PARAMS[name];
|
|
209
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
210
|
+
throw new ScoreValidationError(
|
|
211
|
+
`wavetable ${name} must be a number ${min}..${max}`,
|
|
212
|
+
"invalid-track",
|
|
213
|
+
);
|
|
214
|
+
const clamped = Math.max(min, Math.min(max, value));
|
|
215
|
+
if (clamped !== fallback) out[name] = clamped;
|
|
216
|
+
}
|
|
217
|
+
if (input.warpmode !== undefined) {
|
|
218
|
+
if (!WARP_MODES.includes(input.warpmode as WarpMode))
|
|
219
|
+
throw new ScoreValidationError(
|
|
220
|
+
`wavetable warpmode must be one of ${WARP_MODES.join(", ")}`,
|
|
221
|
+
"invalid-track",
|
|
222
|
+
);
|
|
223
|
+
if (input.warpmode !== "none") out.warpmode = input.warpmode;
|
|
224
|
+
}
|
|
225
|
+
for (const key of Object.keys(input))
|
|
226
|
+
if (
|
|
227
|
+
key !== "table" &&
|
|
228
|
+
key !== "warpmode" &&
|
|
229
|
+
!Object.prototype.hasOwnProperty.call(WAVETABLE_PARAMS, key)
|
|
230
|
+
)
|
|
231
|
+
throw new ScoreValidationError(
|
|
232
|
+
`wavetable has an unknown field ${key.slice(0, 32)}`,
|
|
233
|
+
"invalid-track",
|
|
234
|
+
);
|
|
235
|
+
return Object.freeze(out) as TrackWavetable;
|
|
236
|
+
}
|
|
237
|
+
|
|
51
238
|
/** Instrument name that selects a track's `sampler`. */
|
|
52
239
|
export const SAMPLER_INSTRUMENT = "sampler" as const;
|
|
53
240
|
|
|
@@ -104,11 +291,43 @@ export type Track = Readonly<{
|
|
|
104
291
|
delayMixAutomation?: readonly AutomationPoint[];
|
|
105
292
|
/** Algorithmic stereo reverb send, mixed after the delay. */
|
|
106
293
|
reverb?: TrackReverb;
|
|
294
|
+
/**
|
|
295
|
+
* Optional: insert effects by name (`core/fx.ts`), each with every
|
|
296
|
+
* parameter stored. Rendered in `FX_CHAIN` order.
|
|
297
|
+
*/
|
|
298
|
+
fx?: TrackFx;
|
|
299
|
+
/** Optional: automation for `fx` parameters, keyed `<effect>-<param>`. */
|
|
300
|
+
fxAutomation?: Readonly<Partial<Record<FxLane, readonly AutomationPoint[]>>>;
|
|
301
|
+
/**
|
|
302
|
+
* Optional: synth voice parameters (`core/synth.ts`), Strudel names.
|
|
303
|
+
* Only the parameters a document sets are stored; lanes are
|
|
304
|
+
* `fxAutomation["synth-<param>"]`, read at note onsets.
|
|
305
|
+
*/
|
|
306
|
+
synth?: TrackSynth;
|
|
307
|
+
/**
|
|
308
|
+
* Wavetable oscillator settings (Strudel names). Used when `instrument`
|
|
309
|
+
* is `"wavetable"`; kept when the instrument changes so switching back
|
|
310
|
+
* restores them. Absent means the built-in `basic` table at position 0.
|
|
311
|
+
*/
|
|
312
|
+
wavetable?: TrackWavetable;
|
|
313
|
+
/** Wavetable position (`wt`, 0..1) control points; overrides the static `wt`. */
|
|
314
|
+
wtAutomation?: readonly AutomationPoint[];
|
|
107
315
|
/**
|
|
108
316
|
* Score v2: sample voices; present exactly when `instrument` is
|
|
109
317
|
* `"sampler"`. Documents without it decode unchanged.
|
|
110
318
|
*/
|
|
111
319
|
sampler?: Sampler;
|
|
320
|
+
/**
|
|
321
|
+
* Generated drum rows (Euclidean or grid, see `core/euclid.ts`). The notes
|
|
322
|
+
* they generate are stored as ordinary notes; the rows are the editable
|
|
323
|
+
* source for those voices. Absent when empty.
|
|
324
|
+
*/
|
|
325
|
+
rhythm?: readonly RhythmRow[];
|
|
326
|
+
/**
|
|
327
|
+
* Synthesized drum kit for an `instrument: "kit"` track (`syn808`,
|
|
328
|
+
* `lofi`, … see `core/kits.ts`). Absent plays the default voices.
|
|
329
|
+
*/
|
|
330
|
+
kit?: string;
|
|
112
331
|
}>;
|
|
113
332
|
|
|
114
333
|
/**
|
|
@@ -123,10 +342,17 @@ export type Sampler = Readonly<{
|
|
|
123
342
|
}>;
|
|
124
343
|
|
|
125
344
|
export type SampleRef = Readonly<{
|
|
126
|
-
/**
|
|
345
|
+
/**
|
|
346
|
+
* Project-relative path, normally `tracks/<slug>/samples/<file>`, or a
|
|
347
|
+
* pack sound `pack:<pack>/<sound>[:<n>]` (`pack:tidal-drum-machines/RolandTR909_bd:0`).
|
|
348
|
+
*/
|
|
127
349
|
src: string;
|
|
128
350
|
/** Content hash (64 hex) when known; the decoded cache is keyed by it. */
|
|
129
351
|
sha256?: string;
|
|
352
|
+
/** Pack sounds: the pinned HTTPS file the sound resolved to. */
|
|
353
|
+
url?: string;
|
|
354
|
+
/** Pack sounds: the pack's license (SPDX id or `none stated`). */
|
|
355
|
+
license?: string;
|
|
130
356
|
/** MIDI note the file plays at in keyed mode, default 60. */
|
|
131
357
|
root?: number;
|
|
132
358
|
/** Start fraction 0..1 of the file. */
|
|
@@ -141,20 +367,103 @@ export type SampleRef = Readonly<{
|
|
|
141
367
|
loop?: boolean;
|
|
142
368
|
/** Choke group: a new hit in the group stops the previous one. */
|
|
143
369
|
choke?: string;
|
|
370
|
+
/**
|
|
371
|
+
* Optional (Strudel `loopBegin`/`loopb`, `loopEnd`/`loope`): the looped
|
|
372
|
+
* part of the window, fractions of the file with
|
|
373
|
+
* begin ≤ loopBegin < loopEnd ≤ end. Playback starts at `begin`.
|
|
374
|
+
*/
|
|
375
|
+
loopBegin?: number;
|
|
376
|
+
loopEnd?: number;
|
|
377
|
+
/**
|
|
378
|
+
* Optional (Strudel `clip`/`legato`): the voice lasts the note's length
|
|
379
|
+
* times this, cutting the sample off; a oneshot voice otherwise plays
|
|
380
|
+
* the whole window. 0 < clip ≤ 16.
|
|
381
|
+
*/
|
|
382
|
+
clip?: number;
|
|
383
|
+
/**
|
|
384
|
+
* Optional (Strudel/Tidal `unit`): how `speed` reads. `r` (default) is a
|
|
385
|
+
* rate; `c`: the window lasts 1/|speed| cycles (a cycle is one bar);
|
|
386
|
+
* `s`: the window lasts |speed| seconds. Negative still reverses.
|
|
387
|
+
*/
|
|
388
|
+
unit?: SampleUnit;
|
|
389
|
+
/** Optional (Strudel `fit`): the window lasts exactly the note's length. */
|
|
390
|
+
fit?: boolean;
|
|
391
|
+
/**
|
|
392
|
+
* Optional (Tidal/Strudel `accelerate`): the rate ramps linearly by this
|
|
393
|
+
* many times the starting rate over the voice (−8..8; −1 slows to a stop).
|
|
394
|
+
*/
|
|
395
|
+
accelerate?: number;
|
|
396
|
+
/**
|
|
397
|
+
* Optional (Tidal/Strudel `squiz`): raise the pitch by this ratio inside
|
|
398
|
+
* each zero-crossing cycle, repeating the cycle to keep the length
|
|
399
|
+
* (1 is off, up to 32).
|
|
400
|
+
*/
|
|
401
|
+
squiz?: number;
|
|
144
402
|
}>;
|
|
145
403
|
|
|
404
|
+
/** Sample `unit`: rate, cycles (bars) or seconds. */
|
|
405
|
+
export type SampleUnit = "r" | "c" | "s";
|
|
406
|
+
export const SAMPLE_UNITS: readonly SampleUnit[] = Object.freeze([
|
|
407
|
+
"r",
|
|
408
|
+
"c",
|
|
409
|
+
"s",
|
|
410
|
+
]);
|
|
411
|
+
|
|
146
412
|
export type TrackReverb = Readonly<{
|
|
147
413
|
/** Wet level added to the dry signal, 0..1. */
|
|
148
414
|
mix: number;
|
|
149
415
|
/** Room size 0..1: longer comb feedback and a darker, longer tail. */
|
|
150
416
|
size: number;
|
|
417
|
+
/** Optional: decay time to -60 dB in seconds; overrides the size-derived decay. */
|
|
418
|
+
fade?: number;
|
|
419
|
+
/** Optional: low-pass on the reverb input, Hz. */
|
|
420
|
+
lowpass?: number;
|
|
421
|
+
/** Optional: the tail darkens toward this frequency (Hz) as it decays. */
|
|
422
|
+
dim?: number;
|
|
423
|
+
/** Optional: seconds before the tail starts. */
|
|
424
|
+
predelay?: number;
|
|
425
|
+
/**
|
|
426
|
+
* Optional (Strudel `iresponse`/`ir`): convolve with this impulse instead
|
|
427
|
+
* of the algorithmic tail. `builtin:room|hall|plate`, a pinned pack sound
|
|
428
|
+
* or a project file; `size`, `fade` and `dim` then do nothing.
|
|
429
|
+
*/
|
|
430
|
+
ir?: SampleRef;
|
|
151
431
|
}>;
|
|
152
432
|
|
|
433
|
+
/** Generated impulse responses `reverb.ir` can name as `builtin:<name>`. */
|
|
434
|
+
export const REVERB_IR_BUILTINS = Object.freeze(["room", "hall", "plate"]);
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Validates `reverb.ir`: a string or `{ src, … }`. Bare built-in names and
|
|
438
|
+
* `builtin:<name>` become `{ src: "builtin:<name>" }`; anything else is a
|
|
439
|
+
* sample reference (pack sound or project-relative file).
|
|
440
|
+
*/
|
|
441
|
+
export function normalizeReverbIr(input: unknown): SampleRef {
|
|
442
|
+
const ref = typeof input === "string" ? { src: input } : input;
|
|
443
|
+
if (isRecord(ref) && typeof ref.src === "string") {
|
|
444
|
+
const bare = REVERB_IR_BUILTINS.includes(ref.src);
|
|
445
|
+
if (bare || ref.src.startsWith(BUILTIN_TABLE_PREFIX)) {
|
|
446
|
+
const name = bare ? ref.src : ref.src.slice(BUILTIN_TABLE_PREFIX.length);
|
|
447
|
+
if (!REVERB_IR_BUILTINS.includes(name) || Object.keys(ref).length !== 1)
|
|
448
|
+
throw new ScoreValidationError(
|
|
449
|
+
`track reverb ir builtin must be one of ${REVERB_IR_BUILTINS.map((n) => `builtin:${n}`).join(", ")} with no other fields`,
|
|
450
|
+
"invalid-track",
|
|
451
|
+
);
|
|
452
|
+
return Object.freeze({ src: `${BUILTIN_TABLE_PREFIX}${name}` });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return normalizeSampleRef(ref, "reverb ir");
|
|
456
|
+
}
|
|
457
|
+
|
|
153
458
|
export type TrackFilter = Readonly<{
|
|
154
459
|
/** Cutoff frequency in Hz, 20..20000. */
|
|
155
460
|
cutoff: number;
|
|
156
461
|
/** Resonance 0..1, mapped to a bounded biquad Q. */
|
|
157
462
|
resonance: number;
|
|
463
|
+
/** Optional: filter type; absent is the original low-pass. */
|
|
464
|
+
type?: "lpf" | "hpf" | "bpf";
|
|
465
|
+
/** Optional: slope; absent is the original 12 dB/oct biquad. */
|
|
466
|
+
ftype?: "12db" | "24db" | "ladder";
|
|
158
467
|
}>;
|
|
159
468
|
|
|
160
469
|
export type TrackDelay = Readonly<{
|
|
@@ -164,15 +473,31 @@ export type TrackDelay = Readonly<{
|
|
|
164
473
|
feedback: number;
|
|
165
474
|
/** Wet level added to the dry signal, 0..1. */
|
|
166
475
|
mix: number;
|
|
476
|
+
/** Optional: delay time in seconds; 0/absent follows `beats`. */
|
|
477
|
+
time?: number;
|
|
478
|
+
/** Optional: repeats alternate left/right; absent is the original cross-fed stereo. */
|
|
479
|
+
pingpong?: boolean;
|
|
480
|
+
/** Optional: low-pass (Hz) inside the feedback loop. */
|
|
481
|
+
highcut?: number;
|
|
167
482
|
}>;
|
|
168
483
|
|
|
169
|
-
|
|
170
|
-
|
|
484
|
+
/** The lanes stored in their own track fields (`AUTOMATION_LANES`). */
|
|
485
|
+
export type TrackAutomationParameter =
|
|
486
|
+
| "volume"
|
|
487
|
+
| "pan"
|
|
488
|
+
| "filter"
|
|
489
|
+
| "resonance"
|
|
490
|
+
| "delay-feedback"
|
|
491
|
+
| "delay-mix"
|
|
492
|
+
| "wt";
|
|
493
|
+
|
|
494
|
+
/** Every automation lane: the track-field lanes plus `fx` lanes. */
|
|
495
|
+
export type AutomationParameter = TrackAutomationParameter | FxLane;
|
|
171
496
|
|
|
172
497
|
/** Track field and value range for every automation lane. */
|
|
173
498
|
export const AUTOMATION_LANES: Readonly<
|
|
174
499
|
Record<
|
|
175
|
-
|
|
500
|
+
TrackAutomationParameter,
|
|
176
501
|
Readonly<{
|
|
177
502
|
field:
|
|
178
503
|
| "volumeAutomation"
|
|
@@ -180,7 +505,8 @@ export const AUTOMATION_LANES: Readonly<
|
|
|
180
505
|
| "filterAutomation"
|
|
181
506
|
| "resonanceAutomation"
|
|
182
507
|
| "delayFeedbackAutomation"
|
|
183
|
-
| "delayMixAutomation"
|
|
508
|
+
| "delayMixAutomation"
|
|
509
|
+
| "wtAutomation";
|
|
184
510
|
min: number;
|
|
185
511
|
max: number;
|
|
186
512
|
}>
|
|
@@ -208,17 +534,69 @@ export const AUTOMATION_LANES: Readonly<
|
|
|
208
534
|
min: 0,
|
|
209
535
|
max: SCORE_LIMITS.maxDelayMix,
|
|
210
536
|
},
|
|
537
|
+
wt: { field: "wtAutomation", min: 0, max: 1 },
|
|
211
538
|
});
|
|
212
539
|
|
|
213
|
-
|
|
540
|
+
const FX_LANE_RANGES: ReadonlyMap<
|
|
541
|
+
string,
|
|
542
|
+
Readonly<{ min: number; max: number }>
|
|
543
|
+
> = new Map(
|
|
544
|
+
FX_LANES.map(({ lane, spec }) => [lane, { min: spec.min, max: spec.max }]),
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
export function isTrackAutomationParameter(
|
|
214
548
|
value: unknown,
|
|
215
|
-
): value is
|
|
549
|
+
): value is TrackAutomationParameter {
|
|
216
550
|
return (
|
|
217
551
|
typeof value === "string" &&
|
|
218
552
|
Object.prototype.hasOwnProperty.call(AUTOMATION_LANES, value)
|
|
219
553
|
);
|
|
220
554
|
}
|
|
221
555
|
|
|
556
|
+
export function isAutomationParameter(
|
|
557
|
+
value: unknown,
|
|
558
|
+
): value is AutomationParameter {
|
|
559
|
+
return (
|
|
560
|
+
isTrackAutomationParameter(value) ||
|
|
561
|
+
(typeof value === "string" && FX_LANE_RANGES.has(value))
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Every automation lane name, track-field lanes first. */
|
|
566
|
+
export const AUTOMATION_PARAMETERS: readonly AutomationParameter[] =
|
|
567
|
+
Object.freeze([
|
|
568
|
+
...(Object.keys(AUTOMATION_LANES) as TrackAutomationParameter[]),
|
|
569
|
+
...FX_LANES.map(({ lane }) => lane),
|
|
570
|
+
]);
|
|
571
|
+
|
|
572
|
+
/** Value range of any lane. */
|
|
573
|
+
export function automationRange(
|
|
574
|
+
parameter: AutomationParameter,
|
|
575
|
+
): Readonly<{ min: number; max: number }> {
|
|
576
|
+
if (isTrackAutomationParameter(parameter)) {
|
|
577
|
+
const { min, max } = AUTOMATION_LANES[parameter];
|
|
578
|
+
return { min, max };
|
|
579
|
+
}
|
|
580
|
+
const range = FX_LANE_RANGES.get(parameter);
|
|
581
|
+
if (!range)
|
|
582
|
+
throw new ScoreValidationError(
|
|
583
|
+
`unknown automation parameter: ${String(parameter)}`,
|
|
584
|
+
"invalid-track",
|
|
585
|
+
);
|
|
586
|
+
return range;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** A track's points on any lane (empty when it has none). */
|
|
590
|
+
export function automationPoints(
|
|
591
|
+
track: Track | undefined,
|
|
592
|
+
parameter: AutomationParameter,
|
|
593
|
+
): readonly AutomationPoint[] {
|
|
594
|
+
if (!track) return [];
|
|
595
|
+
if (isTrackAutomationParameter(parameter))
|
|
596
|
+
return track[AUTOMATION_LANES[parameter].field] ?? [];
|
|
597
|
+
return track.fxAutomation?.[parameter as FxLane] ?? [];
|
|
598
|
+
}
|
|
599
|
+
|
|
222
600
|
/** Track fields that score operations may patch; `null` clears an effect. */
|
|
223
601
|
export type TrackPatch = Readonly<
|
|
224
602
|
Partial<
|
|
@@ -236,12 +614,19 @@ export type TrackPatch = Readonly<
|
|
|
236
614
|
| "resonanceAutomation"
|
|
237
615
|
| "delayFeedbackAutomation"
|
|
238
616
|
| "delayMixAutomation"
|
|
617
|
+
| "wtAutomation"
|
|
239
618
|
>
|
|
240
619
|
> & {
|
|
241
620
|
filter?: TrackFilter | null;
|
|
242
621
|
delay?: TrackDelay | null;
|
|
243
622
|
reverb?: TrackReverb | null;
|
|
244
623
|
sampler?: Sampler | null;
|
|
624
|
+
rhythm?: readonly RhythmRow[] | null;
|
|
625
|
+
kit?: string | null;
|
|
626
|
+
fx?: TrackFx | null;
|
|
627
|
+
fxAutomation?: Track["fxAutomation"] | null;
|
|
628
|
+
synth?: TrackSynth | null;
|
|
629
|
+
wavetable?: TrackWavetable | null;
|
|
245
630
|
}
|
|
246
631
|
>;
|
|
247
632
|
|
|
@@ -261,12 +646,30 @@ export type Note = Readonly<{
|
|
|
261
646
|
}>;
|
|
262
647
|
|
|
263
648
|
export type TrackInput = Readonly<
|
|
264
|
-
Omit<
|
|
649
|
+
Omit<
|
|
650
|
+
Partial<Track>,
|
|
651
|
+
| "filter"
|
|
652
|
+
| "delay"
|
|
653
|
+
| "reverb"
|
|
654
|
+
| "sampler"
|
|
655
|
+
| "rhythm"
|
|
656
|
+
| "kit"
|
|
657
|
+
| "fx"
|
|
658
|
+
| "fxAutomation"
|
|
659
|
+
| "synth"
|
|
660
|
+
| "wavetable"
|
|
661
|
+
> &
|
|
265
662
|
Pick<Track, "id"> & {
|
|
266
663
|
filter?: TrackFilter | null;
|
|
267
664
|
delay?: TrackDelay | null;
|
|
268
665
|
reverb?: TrackReverb | null;
|
|
269
666
|
sampler?: Sampler | null;
|
|
667
|
+
rhythm?: readonly RhythmRow[] | null;
|
|
668
|
+
kit?: string | null;
|
|
669
|
+
fx?: TrackFx | null;
|
|
670
|
+
fxAutomation?: Track["fxAutomation"] | null;
|
|
671
|
+
synth?: TrackSynth | null;
|
|
672
|
+
wavetable?: TrackWavetable | null;
|
|
270
673
|
}
|
|
271
674
|
>;
|
|
272
675
|
|
|
@@ -635,9 +1038,18 @@ export function setTrackAutomation(
|
|
|
635
1038
|
`unknown automation parameter: ${String(parameter)}`,
|
|
636
1039
|
"invalid-track",
|
|
637
1040
|
);
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1041
|
+
if (isTrackAutomationParameter(parameter))
|
|
1042
|
+
return updateTrack(score, trackId, {
|
|
1043
|
+
[AUTOMATION_LANES[parameter].field]: points,
|
|
1044
|
+
});
|
|
1045
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
1046
|
+
if (!track) return score;
|
|
1047
|
+
const lanes: Partial<Record<FxLane, readonly AutomationPoint[]>> = {
|
|
1048
|
+
...track.fxAutomation,
|
|
1049
|
+
};
|
|
1050
|
+
if (points.length > 0) lanes[parameter] = points;
|
|
1051
|
+
else delete lanes[parameter];
|
|
1052
|
+
return updateTrack(score, trackId, { fxAutomation: lanes });
|
|
641
1053
|
}
|
|
642
1054
|
|
|
643
1055
|
/** Mute always silences a track; any solo silences every unsoloed track. */
|
|
@@ -875,15 +1287,37 @@ function normalizeTrack(input: unknown): Track {
|
|
|
875
1287
|
SCORE_LIMITS.minFilterCutoff,
|
|
876
1288
|
SCORE_LIMITS.maxFilterCutoff,
|
|
877
1289
|
);
|
|
878
|
-
const lane = (parameter:
|
|
1290
|
+
const lane = (parameter: TrackAutomationParameter) => {
|
|
879
1291
|
const { field, min, max } = AUTOMATION_LANES[parameter];
|
|
880
1292
|
return normalizeAutomation(input[field], field, min, max);
|
|
881
1293
|
};
|
|
882
1294
|
const resonanceAutomation = lane("resonance");
|
|
883
1295
|
const delayFeedbackAutomation = lane("delay-feedback");
|
|
884
1296
|
const delayMixAutomation = lane("delay-mix");
|
|
1297
|
+
const wtAutomation = lane("wt");
|
|
885
1298
|
const reverb = normalizeReverb(input.reverb);
|
|
1299
|
+
const fx = fxOrThrow(() => normalizeFx(input.fx));
|
|
1300
|
+
const fxAutomation = normalizeFxAutomation(input.fxAutomation);
|
|
1301
|
+
const synth = fxOrThrow(() => normalizeSynth(input.synth));
|
|
1302
|
+
const wavetable = normalizeWavetable(input.wavetable);
|
|
886
1303
|
const sampler = normalizeSampler(input.sampler);
|
|
1304
|
+
const rhythm = normalizeRhythm(input.rhythm, id);
|
|
1305
|
+
let kit: string | undefined;
|
|
1306
|
+
if (input.kit !== undefined && input.kit !== null) {
|
|
1307
|
+
const found =
|
|
1308
|
+
typeof input.kit === "string" ? synthKit(input.kit) : undefined;
|
|
1309
|
+
if (!found)
|
|
1310
|
+
throw new ScoreValidationError(
|
|
1311
|
+
`track ${id} kit must be one of ${SYNTH_KIT_NAMES.join(", ")}`,
|
|
1312
|
+
"invalid-track",
|
|
1313
|
+
);
|
|
1314
|
+
if (!isDrumInstrument(instrument))
|
|
1315
|
+
throw new ScoreValidationError(
|
|
1316
|
+
`track ${id} has a kit but its instrument is "${instrument}"`,
|
|
1317
|
+
"invalid-track",
|
|
1318
|
+
);
|
|
1319
|
+
kit = found.name;
|
|
1320
|
+
}
|
|
887
1321
|
if (isSamplerInstrument(instrument) && !sampler)
|
|
888
1322
|
throw new ScoreValidationError(
|
|
889
1323
|
`track ${id} instrument "sampler" needs a sampler`,
|
|
@@ -911,8 +1345,113 @@ function normalizeTrack(input: unknown): Track {
|
|
|
911
1345
|
...(delayFeedbackAutomation.length > 0 ? { delayFeedbackAutomation } : {}),
|
|
912
1346
|
...(delayMixAutomation.length > 0 ? { delayMixAutomation } : {}),
|
|
913
1347
|
...(reverb ? { reverb } : {}),
|
|
1348
|
+
...(fx ? { fx } : {}),
|
|
1349
|
+
...(fxAutomation ? { fxAutomation } : {}),
|
|
1350
|
+
...(synth ? { synth } : {}),
|
|
1351
|
+
...(wavetable ? { wavetable } : {}),
|
|
1352
|
+
...(wtAutomation.length > 0 ? { wtAutomation } : {}),
|
|
914
1353
|
...(sampler ? { sampler } : {}),
|
|
1354
|
+
...(rhythm ? { rhythm } : {}),
|
|
1355
|
+
...(kit ? { kit } : {}),
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
export function normalizeRhythm(
|
|
1360
|
+
input: unknown,
|
|
1361
|
+
trackId: string,
|
|
1362
|
+
): readonly RhythmRow[] | undefined {
|
|
1363
|
+
if (input === undefined || input === null) return undefined;
|
|
1364
|
+
if (!Array.isArray(input) || input.length > RHYTHM_LIMITS.maxRows)
|
|
1365
|
+
throw new ScoreValidationError(
|
|
1366
|
+
`track ${trackId} rhythm must be an array of at most ${RHYTHM_LIMITS.maxRows} rows`,
|
|
1367
|
+
"invalid-track",
|
|
1368
|
+
);
|
|
1369
|
+
if (input.length === 0) return undefined;
|
|
1370
|
+
const rows = input.map((row: unknown, index) => {
|
|
1371
|
+
try {
|
|
1372
|
+
return normalizeRhythmRow(row, `track ${trackId} rhythm[${index}]`);
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
throw new ScoreValidationError(
|
|
1375
|
+
error instanceof Error ? error.message : String(error),
|
|
1376
|
+
"invalid-track",
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
915
1379
|
});
|
|
1380
|
+
const voices = new Set(rows.map((row) => row.voice));
|
|
1381
|
+
if (voices.size !== rows.length)
|
|
1382
|
+
throw new ScoreValidationError(
|
|
1383
|
+
`track ${trackId} rhythm has two rows for one voice`,
|
|
1384
|
+
"invalid-track",
|
|
1385
|
+
);
|
|
1386
|
+
return Object.freeze(rows);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function fxOrThrow<T>(run: () => T): T {
|
|
1390
|
+
try {
|
|
1391
|
+
return run();
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
if (error instanceof FxValidationError)
|
|
1394
|
+
throw new ScoreValidationError(error.message, "invalid-track");
|
|
1395
|
+
throw error;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** Validates `fxAutomation`; empty lanes are dropped, keys in lane order. */
|
|
1400
|
+
function normalizeFxAutomation(
|
|
1401
|
+
input: unknown,
|
|
1402
|
+
): Track["fxAutomation"] | undefined {
|
|
1403
|
+
if (input === undefined || input === null) return undefined;
|
|
1404
|
+
if (!isRecord(input))
|
|
1405
|
+
throw new ScoreValidationError(
|
|
1406
|
+
"track fxAutomation must be an object",
|
|
1407
|
+
"invalid-track",
|
|
1408
|
+
);
|
|
1409
|
+
const keys = Object.keys(input);
|
|
1410
|
+
if (keys.length > SCORE_LIMITS.maxFxLanes)
|
|
1411
|
+
throw new ScoreValidationError(
|
|
1412
|
+
`track fxAutomation holds at most ${SCORE_LIMITS.maxFxLanes} lanes`,
|
|
1413
|
+
"score-limit",
|
|
1414
|
+
);
|
|
1415
|
+
for (const key of keys)
|
|
1416
|
+
if (!FX_LANE_RANGES.has(key))
|
|
1417
|
+
throw new ScoreValidationError(
|
|
1418
|
+
`unknown fx automation lane "${key}"`,
|
|
1419
|
+
"invalid-track",
|
|
1420
|
+
);
|
|
1421
|
+
const out: Partial<Record<FxLane, readonly AutomationPoint[]>> = {};
|
|
1422
|
+
for (const { lane, spec } of FX_LANES) {
|
|
1423
|
+
if (input[lane] === undefined) continue;
|
|
1424
|
+
const points = normalizeAutomation(
|
|
1425
|
+
input[lane],
|
|
1426
|
+
`fxAutomation ${lane}`,
|
|
1427
|
+
spec.min,
|
|
1428
|
+
spec.max,
|
|
1429
|
+
);
|
|
1430
|
+
if (points.length > 0) out[lane] = points;
|
|
1431
|
+
}
|
|
1432
|
+
return Object.keys(out).length > 0 ? Object.freeze(out) : undefined;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/** The optional parameters of a track effect; absent keys stay absent. */
|
|
1436
|
+
function trackEffectExtras(
|
|
1437
|
+
name: keyof typeof TRACK_EFFECT_SPECS,
|
|
1438
|
+
input: Record<string, unknown>,
|
|
1439
|
+
keys: readonly string[],
|
|
1440
|
+
): Record<string, unknown> {
|
|
1441
|
+
const params = TRACK_EFFECT_SPECS[name].params as Record<
|
|
1442
|
+
string,
|
|
1443
|
+
Parameters<typeof normalizeParams>[0][string]
|
|
1444
|
+
>;
|
|
1445
|
+
const subset: Record<string, Parameters<typeof normalizeParams>[0][string]> =
|
|
1446
|
+
{};
|
|
1447
|
+
const values: Record<string, unknown> = {};
|
|
1448
|
+
for (const key of keys) {
|
|
1449
|
+
subset[key] = params[key]!;
|
|
1450
|
+
if (input[key] !== undefined) values[key] = input[key];
|
|
1451
|
+
}
|
|
1452
|
+
return {
|
|
1453
|
+
...fxOrThrow(() => normalizeParams(subset, values, `track ${name}`)),
|
|
1454
|
+
};
|
|
916
1455
|
}
|
|
917
1456
|
|
|
918
1457
|
const VOICE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
@@ -966,19 +1505,24 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
|
966
1505
|
"invalid-track",
|
|
967
1506
|
);
|
|
968
1507
|
const src = input.src;
|
|
1508
|
+
const packRef = typeof src === "string" && src.startsWith(PACK_PREFIX);
|
|
969
1509
|
if (
|
|
970
1510
|
typeof src !== "string" ||
|
|
971
1511
|
src.length === 0 ||
|
|
972
1512
|
src.length > SCORE_LIMITS.maxSamplePathLength ||
|
|
973
|
-
!isSafeRelativePath(src)
|
|
1513
|
+
(packRef ? !isPackRef(src) : !isSafeRelativePath(src))
|
|
974
1514
|
)
|
|
975
1515
|
throw new ScoreValidationError(
|
|
976
|
-
|
|
1516
|
+
packRef
|
|
1517
|
+
? `${label} src must be pack:<pack>/<sound>[:<n>], at most ${SCORE_LIMITS.maxSamplePathLength} characters`
|
|
1518
|
+
: `${label} src must be a project-relative path without "..", at most ${SCORE_LIMITS.maxSamplePathLength} characters`,
|
|
977
1519
|
"invalid-track",
|
|
978
1520
|
);
|
|
979
1521
|
const ref: {
|
|
980
1522
|
src: string;
|
|
981
1523
|
sha256?: string;
|
|
1524
|
+
url?: string;
|
|
1525
|
+
license?: string;
|
|
982
1526
|
root?: number;
|
|
983
1527
|
begin?: number;
|
|
984
1528
|
end?: number;
|
|
@@ -986,6 +1530,13 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
|
986
1530
|
speed?: number;
|
|
987
1531
|
loop?: boolean;
|
|
988
1532
|
choke?: string;
|
|
1533
|
+
loopBegin?: number;
|
|
1534
|
+
loopEnd?: number;
|
|
1535
|
+
clip?: number;
|
|
1536
|
+
unit?: SampleUnit;
|
|
1537
|
+
fit?: boolean;
|
|
1538
|
+
accelerate?: number;
|
|
1539
|
+
squiz?: number;
|
|
989
1540
|
} = { src };
|
|
990
1541
|
if (input.sha256 !== undefined) {
|
|
991
1542
|
if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
|
|
@@ -995,6 +1546,25 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
|
995
1546
|
);
|
|
996
1547
|
ref.sha256 = input.sha256;
|
|
997
1548
|
}
|
|
1549
|
+
if (input.url !== undefined) {
|
|
1550
|
+
if (!packRef || !isPinnedSampleUrl(input.url))
|
|
1551
|
+
throw new ScoreValidationError(
|
|
1552
|
+
`${label} url must be an https URL without credentials, at most ${SCORE_LIMITS.maxSampleUrlLength} characters, on a pack: sound`,
|
|
1553
|
+
"invalid-track",
|
|
1554
|
+
);
|
|
1555
|
+
ref.url = input.url;
|
|
1556
|
+
}
|
|
1557
|
+
if (input.license !== undefined) {
|
|
1558
|
+
if (
|
|
1559
|
+
typeof input.license !== "string" ||
|
|
1560
|
+
!/^[\x20-\x7e]{1,64}$/.test(input.license)
|
|
1561
|
+
)
|
|
1562
|
+
throw new ScoreValidationError(
|
|
1563
|
+
`${label} license must be a short label such as CC0-1.0`,
|
|
1564
|
+
"invalid-track",
|
|
1565
|
+
);
|
|
1566
|
+
ref.license = input.license;
|
|
1567
|
+
}
|
|
998
1568
|
if (input.root !== undefined) {
|
|
999
1569
|
if (
|
|
1000
1570
|
typeof input.root !== "number" ||
|
|
@@ -1067,9 +1637,135 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
|
1067
1637
|
);
|
|
1068
1638
|
ref.choke = input.choke;
|
|
1069
1639
|
}
|
|
1640
|
+
if (input.loopBegin !== undefined || input.loopEnd !== undefined) {
|
|
1641
|
+
const loopBegin = input.loopBegin === undefined ? begin : input.loopBegin;
|
|
1642
|
+
const loopEnd = input.loopEnd === undefined ? end : input.loopEnd;
|
|
1643
|
+
if (
|
|
1644
|
+
typeof loopBegin !== "number" ||
|
|
1645
|
+
typeof loopEnd !== "number" ||
|
|
1646
|
+
!Number.isFinite(loopBegin) ||
|
|
1647
|
+
!Number.isFinite(loopEnd) ||
|
|
1648
|
+
loopBegin < begin ||
|
|
1649
|
+
loopEnd > end ||
|
|
1650
|
+
loopBegin >= loopEnd
|
|
1651
|
+
)
|
|
1652
|
+
throw new ScoreValidationError(
|
|
1653
|
+
`${label} loopBegin/loopEnd must be fractions with begin ≤ loopBegin < loopEnd ≤ end`,
|
|
1654
|
+
"invalid-track",
|
|
1655
|
+
);
|
|
1656
|
+
if (input.loopBegin !== undefined) ref.loopBegin = loopBegin;
|
|
1657
|
+
if (input.loopEnd !== undefined) ref.loopEnd = loopEnd;
|
|
1658
|
+
}
|
|
1659
|
+
if (input.clip !== undefined) {
|
|
1660
|
+
const clip = boundedNumber(
|
|
1661
|
+
input.clip,
|
|
1662
|
+
`${label} clip`,
|
|
1663
|
+
0,
|
|
1664
|
+
SCORE_LIMITS.maxSampleClip,
|
|
1665
|
+
);
|
|
1666
|
+
if (clip === 0)
|
|
1667
|
+
throw new ScoreValidationError(
|
|
1668
|
+
`${label} clip must be greater than 0`,
|
|
1669
|
+
"invalid-track",
|
|
1670
|
+
);
|
|
1671
|
+
ref.clip = clip;
|
|
1672
|
+
}
|
|
1673
|
+
if (input.unit !== undefined) {
|
|
1674
|
+
if (!SAMPLE_UNITS.includes(input.unit as SampleUnit))
|
|
1675
|
+
throw new ScoreValidationError(
|
|
1676
|
+
`${label} unit must be "r", "c" or "s"`,
|
|
1677
|
+
"invalid-track",
|
|
1678
|
+
);
|
|
1679
|
+
ref.unit = input.unit as SampleUnit;
|
|
1680
|
+
}
|
|
1681
|
+
if (input.fit !== undefined) {
|
|
1682
|
+
if (typeof input.fit !== "boolean")
|
|
1683
|
+
throw new ScoreValidationError(
|
|
1684
|
+
`${label} fit must be boolean`,
|
|
1685
|
+
"invalid-track",
|
|
1686
|
+
);
|
|
1687
|
+
ref.fit = input.fit;
|
|
1688
|
+
}
|
|
1689
|
+
if (input.accelerate !== undefined)
|
|
1690
|
+
ref.accelerate = boundedNumber(
|
|
1691
|
+
input.accelerate,
|
|
1692
|
+
`${label} accelerate`,
|
|
1693
|
+
-SCORE_LIMITS.maxSampleAccelerate,
|
|
1694
|
+
SCORE_LIMITS.maxSampleAccelerate,
|
|
1695
|
+
);
|
|
1696
|
+
if (input.squiz !== undefined)
|
|
1697
|
+
ref.squiz = boundedNumber(
|
|
1698
|
+
input.squiz,
|
|
1699
|
+
`${label} squiz`,
|
|
1700
|
+
1,
|
|
1701
|
+
SCORE_LIMITS.maxSampleSquiz,
|
|
1702
|
+
);
|
|
1070
1703
|
return Object.freeze(ref);
|
|
1071
1704
|
}
|
|
1072
1705
|
|
|
1706
|
+
/** Prefix of a pack sound reference in `SampleRef.src`. */
|
|
1707
|
+
export const PACK_PREFIX = "pack:";
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* `pack:<pack>/<sound>[:<n>]`: pack is a lowercase slug, sound a manifest
|
|
1711
|
+
* key, `n` an index (wrapping, like Strudel `s("bd:3")`) or a note name for
|
|
1712
|
+
* pitched packs (`pack:gm/gm_piano:C4`).
|
|
1713
|
+
*/
|
|
1714
|
+
const PACK_REF =
|
|
1715
|
+
/^pack:([a-z0-9][a-z0-9._-]{0,63})\/([A-Za-z0-9][A-Za-z0-9_.~+-]{0,127})(?::([0-9]{1,4}|[A-Ga-g](?:#|s|b)?-?[0-9]))?$/;
|
|
1716
|
+
|
|
1717
|
+
export type PackRef = Readonly<{
|
|
1718
|
+
pack: string;
|
|
1719
|
+
sound: string;
|
|
1720
|
+
/** Index into the sound's files (wraps), or a note-zone name. */
|
|
1721
|
+
n: number | string;
|
|
1722
|
+
}>;
|
|
1723
|
+
|
|
1724
|
+
export function isPackRef(src: string): boolean {
|
|
1725
|
+
return PACK_REF.test(src);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/** Splits `pack:<pack>/<sound>[:<n>]`; undefined for anything else. */
|
|
1729
|
+
export function parsePackRef(src: string): PackRef | undefined {
|
|
1730
|
+
const match = PACK_REF.exec(src);
|
|
1731
|
+
if (!match) return undefined;
|
|
1732
|
+
const n = match[3];
|
|
1733
|
+
return Object.freeze({
|
|
1734
|
+
pack: match[1]!,
|
|
1735
|
+
sound: match[2]!,
|
|
1736
|
+
n: n === undefined ? 0 : /^[0-9]+$/.test(n) ? Number(n) : n,
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
export function formatPackRef(ref: PackRef): string {
|
|
1741
|
+
return `${PACK_PREFIX}${ref.pack}/${ref.sound}${ref.n === 0 ? "" : `:${ref.n}`}`;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/** HTTPS (or loopback HTTP), no credentials, bounded: the URLs a score may pin. */
|
|
1745
|
+
export function isPinnedSampleUrl(value: unknown): value is string {
|
|
1746
|
+
if (
|
|
1747
|
+
typeof value !== "string" ||
|
|
1748
|
+
value.length === 0 ||
|
|
1749
|
+
value.length > SCORE_LIMITS.maxSampleUrlLength ||
|
|
1750
|
+
/[\u0000-\u0020]/.test(value)
|
|
1751
|
+
)
|
|
1752
|
+
return false;
|
|
1753
|
+
let url: URL;
|
|
1754
|
+
try {
|
|
1755
|
+
url = new URL(value);
|
|
1756
|
+
} catch {
|
|
1757
|
+
return false;
|
|
1758
|
+
}
|
|
1759
|
+
if (url.username !== "" || url.password !== "") return false;
|
|
1760
|
+
// Plain HTTP only to loopback (local fixture servers); fetches still refuse
|
|
1761
|
+
// it unless the pack store allows loopback.
|
|
1762
|
+
return (
|
|
1763
|
+
url.protocol === "https:" ||
|
|
1764
|
+
(url.protocol === "http:" &&
|
|
1765
|
+
["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1073
1769
|
/** Relative, forward-slash, no empty/dot segments, no control characters. */
|
|
1074
1770
|
function isSafeRelativePath(path: string): boolean {
|
|
1075
1771
|
if (path.startsWith("/") || /[\\\u0000-\u001f]/.test(path)) return false;
|
|
@@ -1098,7 +1794,17 @@ export function normalizeReverb(input: unknown): TrackReverb | undefined {
|
|
|
1098
1794
|
SCORE_LIMITS.minReverbSize,
|
|
1099
1795
|
SCORE_LIMITS.maxReverbSize,
|
|
1100
1796
|
);
|
|
1101
|
-
|
|
1797
|
+
const extras = trackEffectExtras("reverb", input, [
|
|
1798
|
+
"fade",
|
|
1799
|
+
"lowpass",
|
|
1800
|
+
"dim",
|
|
1801
|
+
"predelay",
|
|
1802
|
+
]);
|
|
1803
|
+
const ir =
|
|
1804
|
+
input.ir === undefined || input.ir === null
|
|
1805
|
+
? undefined
|
|
1806
|
+
: normalizeReverbIr(input.ir);
|
|
1807
|
+
return Object.freeze({ mix, size, ...extras, ...(ir ? { ir } : {}) });
|
|
1102
1808
|
}
|
|
1103
1809
|
|
|
1104
1810
|
export function normalizeFilter(input: unknown): TrackFilter | undefined {
|
|
@@ -1120,7 +1826,12 @@ export function normalizeFilter(input: unknown): TrackFilter | undefined {
|
|
|
1120
1826
|
0,
|
|
1121
1827
|
SCORE_LIMITS.maxFilterResonance,
|
|
1122
1828
|
);
|
|
1123
|
-
|
|
1829
|
+
const extras = trackEffectExtras("filter", input, ["type", "ftype"]);
|
|
1830
|
+
if (extras.ftype === "12db") delete extras.ftype;
|
|
1831
|
+
// `lpf` is the original filter; store it as absent so v1 documents and
|
|
1832
|
+
// explicit `lpf` encode the same.
|
|
1833
|
+
if (extras.type === "lpf") delete extras.type;
|
|
1834
|
+
return Object.freeze({ cutoff, resonance, ...extras });
|
|
1124
1835
|
}
|
|
1125
1836
|
|
|
1126
1837
|
export function normalizeDelay(input: unknown): TrackDelay | undefined {
|
|
@@ -1148,7 +1859,13 @@ export function normalizeDelay(input: unknown): TrackDelay | undefined {
|
|
|
1148
1859
|
0,
|
|
1149
1860
|
SCORE_LIMITS.maxDelayMix,
|
|
1150
1861
|
);
|
|
1151
|
-
|
|
1862
|
+
const extras = trackEffectExtras("delay", input, [
|
|
1863
|
+
"time",
|
|
1864
|
+
"pingpong",
|
|
1865
|
+
"highcut",
|
|
1866
|
+
]);
|
|
1867
|
+
if (extras.time === 0) delete extras.time;
|
|
1868
|
+
return Object.freeze({ beats, feedback, mix, ...extras });
|
|
1152
1869
|
}
|
|
1153
1870
|
|
|
1154
1871
|
function boundedNumber(
|