@hraness/dawg 0.2.0 → 0.4.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 +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -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 +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- 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 +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +397 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/media/wavetable.ts +202 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +49 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
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,8 +49,198 @@ 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,
|
|
54
|
+
/** Sampler voices per track (score v2, `sampler` field). */
|
|
55
|
+
maxSamplerVoices: 64,
|
|
56
|
+
maxSamplerVoiceNameLength: 32,
|
|
57
|
+
maxSamplePathLength: 256,
|
|
58
|
+
maxSampleUrlLength: 1024,
|
|
59
|
+
maxSampleGain: 2,
|
|
60
|
+
/** |speed| bound; negative speeds play in reverse. */
|
|
61
|
+
maxSampleSpeed: 8,
|
|
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,
|
|
69
|
+
/** Per sample file, enforced by the decoder and the import tool. */
|
|
70
|
+
maxSampleFileBytes: 50 * 1024 * 1024,
|
|
71
|
+
maxSampleSeconds: 600,
|
|
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
|
+
*/
|
|
77
|
+
maxSampleCacheBytes: 512 * 1024 * 1024,
|
|
36
78
|
} as const);
|
|
37
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
|
+
|
|
238
|
+
/** Instrument name that selects a track's `sampler`. */
|
|
239
|
+
export const SAMPLER_INSTRUMENT = "sampler" as const;
|
|
240
|
+
|
|
241
|
+
/** First pitch slot assigned to one-shot sampler voices (GM kick). */
|
|
242
|
+
export const SAMPLER_FIRST_SLOT = 36;
|
|
243
|
+
|
|
38
244
|
export class ScoreValidationError extends Error {
|
|
39
245
|
readonly code:
|
|
40
246
|
| "invalid-score"
|
|
@@ -85,20 +291,179 @@ export type Track = Readonly<{
|
|
|
85
291
|
delayMixAutomation?: readonly AutomationPoint[];
|
|
86
292
|
/** Algorithmic stereo reverb send, mixed after the delay. */
|
|
87
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[];
|
|
315
|
+
/**
|
|
316
|
+
* Score v2: sample voices; present exactly when `instrument` is
|
|
317
|
+
* `"sampler"`. Documents without it decode unchanged.
|
|
318
|
+
*/
|
|
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;
|
|
331
|
+
}>;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A track's sample voices (Strudel-aligned). In `oneshot` mode every voice is
|
|
335
|
+
* a drum-like hit addressed by the pitch slot `samplerVoiceSlots` assigns
|
|
336
|
+
* (36, 37, … in voice-name order); in `keyed` mode notes are ordinary pitches
|
|
337
|
+
* and a voice is resampled from its `root`.
|
|
338
|
+
*/
|
|
339
|
+
export type Sampler = Readonly<{
|
|
340
|
+
voices: Readonly<Record<string, SampleRef>>;
|
|
341
|
+
mode: "oneshot" | "keyed";
|
|
342
|
+
}>;
|
|
343
|
+
|
|
344
|
+
export type SampleRef = Readonly<{
|
|
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
|
+
*/
|
|
349
|
+
src: string;
|
|
350
|
+
/** Content hash (64 hex) when known; the decoded cache is keyed by it. */
|
|
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;
|
|
356
|
+
/** MIDI note the file plays at in keyed mode, default 60. */
|
|
357
|
+
root?: number;
|
|
358
|
+
/** Start fraction 0..1 of the file. */
|
|
359
|
+
begin?: number;
|
|
360
|
+
/** End fraction 0..1 of the file, greater than `begin`. */
|
|
361
|
+
end?: number;
|
|
362
|
+
/** Linear gain 0..2. */
|
|
363
|
+
gain?: number;
|
|
364
|
+
/** Playback rate; negative reverses. |speed| ≤ 8, never 0. */
|
|
365
|
+
speed?: number;
|
|
366
|
+
/** Sustain by looping begin..end. */
|
|
367
|
+
loop?: boolean;
|
|
368
|
+
/** Choke group: a new hit in the group stops the previous one. */
|
|
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;
|
|
88
402
|
}>;
|
|
89
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
|
+
|
|
90
412
|
export type TrackReverb = Readonly<{
|
|
91
413
|
/** Wet level added to the dry signal, 0..1. */
|
|
92
414
|
mix: number;
|
|
93
415
|
/** Room size 0..1: longer comb feedback and a darker, longer tail. */
|
|
94
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;
|
|
95
431
|
}>;
|
|
96
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
|
+
|
|
97
458
|
export type TrackFilter = Readonly<{
|
|
98
459
|
/** Cutoff frequency in Hz, 20..20000. */
|
|
99
460
|
cutoff: number;
|
|
100
461
|
/** Resonance 0..1, mapped to a bounded biquad Q. */
|
|
101
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";
|
|
102
467
|
}>;
|
|
103
468
|
|
|
104
469
|
export type TrackDelay = Readonly<{
|
|
@@ -108,15 +473,31 @@ export type TrackDelay = Readonly<{
|
|
|
108
473
|
feedback: number;
|
|
109
474
|
/** Wet level added to the dry signal, 0..1. */
|
|
110
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;
|
|
111
482
|
}>;
|
|
112
483
|
|
|
113
|
-
|
|
114
|
-
|
|
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;
|
|
115
496
|
|
|
116
497
|
/** Track field and value range for every automation lane. */
|
|
117
498
|
export const AUTOMATION_LANES: Readonly<
|
|
118
499
|
Record<
|
|
119
|
-
|
|
500
|
+
TrackAutomationParameter,
|
|
120
501
|
Readonly<{
|
|
121
502
|
field:
|
|
122
503
|
| "volumeAutomation"
|
|
@@ -124,7 +505,8 @@ export const AUTOMATION_LANES: Readonly<
|
|
|
124
505
|
| "filterAutomation"
|
|
125
506
|
| "resonanceAutomation"
|
|
126
507
|
| "delayFeedbackAutomation"
|
|
127
|
-
| "delayMixAutomation"
|
|
508
|
+
| "delayMixAutomation"
|
|
509
|
+
| "wtAutomation";
|
|
128
510
|
min: number;
|
|
129
511
|
max: number;
|
|
130
512
|
}>
|
|
@@ -152,17 +534,69 @@ export const AUTOMATION_LANES: Readonly<
|
|
|
152
534
|
min: 0,
|
|
153
535
|
max: SCORE_LIMITS.maxDelayMix,
|
|
154
536
|
},
|
|
537
|
+
wt: { field: "wtAutomation", min: 0, max: 1 },
|
|
155
538
|
});
|
|
156
539
|
|
|
157
|
-
|
|
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(
|
|
158
548
|
value: unknown,
|
|
159
|
-
): value is
|
|
549
|
+
): value is TrackAutomationParameter {
|
|
160
550
|
return (
|
|
161
551
|
typeof value === "string" &&
|
|
162
552
|
Object.prototype.hasOwnProperty.call(AUTOMATION_LANES, value)
|
|
163
553
|
);
|
|
164
554
|
}
|
|
165
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
|
+
|
|
166
600
|
/** Track fields that score operations may patch; `null` clears an effect. */
|
|
167
601
|
export type TrackPatch = Readonly<
|
|
168
602
|
Partial<
|
|
@@ -180,11 +614,19 @@ export type TrackPatch = Readonly<
|
|
|
180
614
|
| "resonanceAutomation"
|
|
181
615
|
| "delayFeedbackAutomation"
|
|
182
616
|
| "delayMixAutomation"
|
|
617
|
+
| "wtAutomation"
|
|
183
618
|
>
|
|
184
619
|
> & {
|
|
185
620
|
filter?: TrackFilter | null;
|
|
186
621
|
delay?: TrackDelay | null;
|
|
187
622
|
reverb?: TrackReverb | null;
|
|
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;
|
|
188
630
|
}
|
|
189
631
|
>;
|
|
190
632
|
|
|
@@ -204,11 +646,30 @@ export type Note = Readonly<{
|
|
|
204
646
|
}>;
|
|
205
647
|
|
|
206
648
|
export type TrackInput = Readonly<
|
|
207
|
-
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
|
+
> &
|
|
208
662
|
Pick<Track, "id"> & {
|
|
209
663
|
filter?: TrackFilter | null;
|
|
210
664
|
delay?: TrackDelay | null;
|
|
211
665
|
reverb?: TrackReverb | null;
|
|
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;
|
|
212
673
|
}
|
|
213
674
|
>;
|
|
214
675
|
|
|
@@ -348,6 +809,15 @@ export class TrackScore {
|
|
|
348
809
|
return new TrackScore({ ...this.toJSON(), bars });
|
|
349
810
|
}
|
|
350
811
|
|
|
812
|
+
withKey(key: string | null): TrackScore {
|
|
813
|
+
return new TrackScore({ ...this.toJSON(), key });
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Change the meter; ticks are per beat, so notes keep their positions. */
|
|
817
|
+
withMeter(beatsPerBar: number): TrackScore {
|
|
818
|
+
return new TrackScore({ ...this.toJSON(), beatsPerBar });
|
|
819
|
+
}
|
|
820
|
+
|
|
351
821
|
toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
|
|
352
822
|
return {
|
|
353
823
|
version: SCORE_VERSION,
|
|
@@ -476,6 +946,62 @@ export function updateTrack(
|
|
|
476
946
|
);
|
|
477
947
|
}
|
|
478
948
|
|
|
949
|
+
/** Removes a track and every note on it; unknown ids are a no-op. */
|
|
950
|
+
export function removeTrack(score: TrackScore, trackId: string): TrackScore {
|
|
951
|
+
if (!score.tracks.some((track) => track.id === trackId)) return score;
|
|
952
|
+
return new TrackScore({
|
|
953
|
+
...score.toJSON(),
|
|
954
|
+
tracks: score.tracks.filter((track) => track.id !== trackId),
|
|
955
|
+
notes: score.notes.filter((note) => note.trackId !== trackId),
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Moves a track to position `index` (clamped) in score order. */
|
|
960
|
+
export function moveTrack(
|
|
961
|
+
score: TrackScore,
|
|
962
|
+
trackId: string,
|
|
963
|
+
index: number,
|
|
964
|
+
): TrackScore {
|
|
965
|
+
const from = score.tracks.findIndex((track) => track.id === trackId);
|
|
966
|
+
if (from < 0 || !Number.isInteger(index))
|
|
967
|
+
throw new ScoreValidationError(
|
|
968
|
+
"moveTrack needs an existing track and an integer index",
|
|
969
|
+
"invalid-track",
|
|
970
|
+
);
|
|
971
|
+
const to = Math.max(0, Math.min(score.tracks.length - 1, index));
|
|
972
|
+
if (from === to) return score;
|
|
973
|
+
const tracks = [...score.tracks];
|
|
974
|
+
const [moved] = tracks.splice(from, 1);
|
|
975
|
+
tracks.splice(to, 0, moved!);
|
|
976
|
+
return score.withTracks(tracks);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export function isSamplerInstrument(instrument: string | undefined): boolean {
|
|
980
|
+
return (
|
|
981
|
+
typeof instrument === "string" &&
|
|
982
|
+
instrument.trim().toLowerCase() === SAMPLER_INSTRUMENT
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* One-shot voices are addressed by pitch slots so the drum-lane projection
|
|
988
|
+
* and note tools keep working: voice names sorted by code point, slots from
|
|
989
|
+
* `SAMPLER_FIRST_SLOT` upward. Keyed samplers have no slots.
|
|
990
|
+
*/
|
|
991
|
+
export function samplerVoiceSlots(
|
|
992
|
+
sampler: Sampler,
|
|
993
|
+
): ReadonlyMap<string, number> {
|
|
994
|
+
const slots = new Map<string, number>();
|
|
995
|
+
if (sampler.mode !== "oneshot") return slots;
|
|
996
|
+
const names = Object.keys(sampler.voices).sort(compareCodePoints);
|
|
997
|
+
names.forEach((name, index) => slots.set(name, SAMPLER_FIRST_SLOT + index));
|
|
998
|
+
return slots;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function compareCodePoints(a: string, b: string): number {
|
|
1002
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
479
1005
|
export function setVolumeAutomation(
|
|
480
1006
|
score: TrackScore,
|
|
481
1007
|
trackId: string,
|
|
@@ -512,9 +1038,18 @@ export function setTrackAutomation(
|
|
|
512
1038
|
`unknown automation parameter: ${String(parameter)}`,
|
|
513
1039
|
"invalid-track",
|
|
514
1040
|
);
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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 });
|
|
518
1053
|
}
|
|
519
1054
|
|
|
520
1055
|
/** Mute always silences a track; any solo silences every unsoloed track. */
|
|
@@ -569,12 +1104,36 @@ export type ScoreOperation =
|
|
|
569
1104
|
| Readonly<{
|
|
570
1105
|
type: "clearTrack";
|
|
571
1106
|
trackId: string;
|
|
1107
|
+
}>
|
|
1108
|
+
| Readonly<{
|
|
1109
|
+
type: "removeTrack";
|
|
1110
|
+
trackId: string;
|
|
1111
|
+
}>
|
|
1112
|
+
| Readonly<{
|
|
1113
|
+
type: "moveTrack";
|
|
1114
|
+
trackId: string;
|
|
1115
|
+
index: number;
|
|
1116
|
+
}>
|
|
1117
|
+
| Readonly<{
|
|
1118
|
+
type: "setKey";
|
|
1119
|
+
key: string | null;
|
|
1120
|
+
}>
|
|
1121
|
+
| Readonly<{
|
|
1122
|
+
type: "setMeter";
|
|
1123
|
+
beatsPerBar: number;
|
|
572
1124
|
}>;
|
|
573
1125
|
|
|
574
1126
|
export function applyScoreOperation(
|
|
575
1127
|
score: TrackScore,
|
|
576
1128
|
operation: ScoreOperation,
|
|
577
1129
|
): TrackScore {
|
|
1130
|
+
if (operation.type === "removeTrack")
|
|
1131
|
+
return removeTrack(score, operation.trackId);
|
|
1132
|
+
if (operation.type === "moveTrack")
|
|
1133
|
+
return moveTrack(score, operation.trackId, operation.index);
|
|
1134
|
+
if (operation.type === "setKey") return score.withKey(operation.key);
|
|
1135
|
+
if (operation.type === "setMeter")
|
|
1136
|
+
return score.withMeter(operation.beatsPerBar);
|
|
578
1137
|
if (operation.type === "addTrack") return addTrack(score, operation.track);
|
|
579
1138
|
if (operation.type === "addNote") return addNote(score, operation.note);
|
|
580
1139
|
if (operation.type === "removeNote")
|
|
@@ -728,14 +1287,47 @@ function normalizeTrack(input: unknown): Track {
|
|
|
728
1287
|
SCORE_LIMITS.minFilterCutoff,
|
|
729
1288
|
SCORE_LIMITS.maxFilterCutoff,
|
|
730
1289
|
);
|
|
731
|
-
const lane = (parameter:
|
|
1290
|
+
const lane = (parameter: TrackAutomationParameter) => {
|
|
732
1291
|
const { field, min, max } = AUTOMATION_LANES[parameter];
|
|
733
1292
|
return normalizeAutomation(input[field], field, min, max);
|
|
734
1293
|
};
|
|
735
1294
|
const resonanceAutomation = lane("resonance");
|
|
736
1295
|
const delayFeedbackAutomation = lane("delay-feedback");
|
|
737
1296
|
const delayMixAutomation = lane("delay-mix");
|
|
1297
|
+
const wtAutomation = lane("wt");
|
|
738
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);
|
|
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
|
+
}
|
|
1321
|
+
if (isSamplerInstrument(instrument) && !sampler)
|
|
1322
|
+
throw new ScoreValidationError(
|
|
1323
|
+
`track ${id} instrument "sampler" needs a sampler`,
|
|
1324
|
+
"invalid-track",
|
|
1325
|
+
);
|
|
1326
|
+
if (sampler && !isSamplerInstrument(instrument))
|
|
1327
|
+
throw new ScoreValidationError(
|
|
1328
|
+
`track ${id} has a sampler but its instrument is "${instrument}"`,
|
|
1329
|
+
"invalid-track",
|
|
1330
|
+
);
|
|
739
1331
|
return Object.freeze({
|
|
740
1332
|
id,
|
|
741
1333
|
name,
|
|
@@ -753,9 +1345,436 @@ function normalizeTrack(input: unknown): Track {
|
|
|
753
1345
|
...(delayFeedbackAutomation.length > 0 ? { delayFeedbackAutomation } : {}),
|
|
754
1346
|
...(delayMixAutomation.length > 0 ? { delayMixAutomation } : {}),
|
|
755
1347
|
...(reverb ? { reverb } : {}),
|
|
1348
|
+
...(fx ? { fx } : {}),
|
|
1349
|
+
...(fxAutomation ? { fxAutomation } : {}),
|
|
1350
|
+
...(synth ? { synth } : {}),
|
|
1351
|
+
...(wavetable ? { wavetable } : {}),
|
|
1352
|
+
...(wtAutomation.length > 0 ? { wtAutomation } : {}),
|
|
1353
|
+
...(sampler ? { sampler } : {}),
|
|
1354
|
+
...(rhythm ? { rhythm } : {}),
|
|
1355
|
+
...(kit ? { kit } : {}),
|
|
756
1356
|
});
|
|
757
1357
|
}
|
|
758
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
|
+
}
|
|
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
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
const VOICE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
1458
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
1459
|
+
|
|
1460
|
+
/** Validates a `Sampler` from unknown; `undefined`/`null` means none. */
|
|
1461
|
+
export function normalizeSampler(input: unknown): Sampler | undefined {
|
|
1462
|
+
if (input === undefined || input === null) return undefined;
|
|
1463
|
+
if (!isRecord(input))
|
|
1464
|
+
throw new ScoreValidationError(
|
|
1465
|
+
"track sampler must be an object or null",
|
|
1466
|
+
"invalid-track",
|
|
1467
|
+
);
|
|
1468
|
+
const mode = input.mode ?? "oneshot";
|
|
1469
|
+
if (mode !== "oneshot" && mode !== "keyed")
|
|
1470
|
+
throw new ScoreValidationError(
|
|
1471
|
+
'sampler mode must be "oneshot" or "keyed"',
|
|
1472
|
+
"invalid-track",
|
|
1473
|
+
);
|
|
1474
|
+
if (!isRecord(input.voices))
|
|
1475
|
+
throw new ScoreValidationError(
|
|
1476
|
+
"sampler voices must be an object of name → sample",
|
|
1477
|
+
"invalid-track",
|
|
1478
|
+
);
|
|
1479
|
+
const names = Object.keys(input.voices);
|
|
1480
|
+
if (names.length === 0 || names.length > SCORE_LIMITS.maxSamplerVoices)
|
|
1481
|
+
throw new ScoreValidationError(
|
|
1482
|
+
`sampler must have between 1 and ${SCORE_LIMITS.maxSamplerVoices} voices`,
|
|
1483
|
+
"score-limit",
|
|
1484
|
+
);
|
|
1485
|
+
const voices: Record<string, SampleRef> = {};
|
|
1486
|
+
for (const name of names.sort(compareCodePoints)) {
|
|
1487
|
+
if (
|
|
1488
|
+
name.length > SCORE_LIMITS.maxSamplerVoiceNameLength ||
|
|
1489
|
+
!VOICE_NAME.test(name)
|
|
1490
|
+
)
|
|
1491
|
+
throw new ScoreValidationError(
|
|
1492
|
+
`sampler voice name "${name.slice(0, 40)}" must match ${VOICE_NAME.source} and be at most ${SCORE_LIMITS.maxSamplerVoiceNameLength} characters`,
|
|
1493
|
+
"invalid-track",
|
|
1494
|
+
);
|
|
1495
|
+
voices[name] = normalizeSampleRef(input.voices[name], name);
|
|
1496
|
+
}
|
|
1497
|
+
return Object.freeze({ voices: Object.freeze(voices), mode });
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
1501
|
+
const label = `sampler voice ${name}`;
|
|
1502
|
+
if (!isRecord(input))
|
|
1503
|
+
throw new ScoreValidationError(
|
|
1504
|
+
`${label} must be an object`,
|
|
1505
|
+
"invalid-track",
|
|
1506
|
+
);
|
|
1507
|
+
const src = input.src;
|
|
1508
|
+
const packRef = typeof src === "string" && src.startsWith(PACK_PREFIX);
|
|
1509
|
+
if (
|
|
1510
|
+
typeof src !== "string" ||
|
|
1511
|
+
src.length === 0 ||
|
|
1512
|
+
src.length > SCORE_LIMITS.maxSamplePathLength ||
|
|
1513
|
+
(packRef ? !isPackRef(src) : !isSafeRelativePath(src))
|
|
1514
|
+
)
|
|
1515
|
+
throw new ScoreValidationError(
|
|
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`,
|
|
1519
|
+
"invalid-track",
|
|
1520
|
+
);
|
|
1521
|
+
const ref: {
|
|
1522
|
+
src: string;
|
|
1523
|
+
sha256?: string;
|
|
1524
|
+
url?: string;
|
|
1525
|
+
license?: string;
|
|
1526
|
+
root?: number;
|
|
1527
|
+
begin?: number;
|
|
1528
|
+
end?: number;
|
|
1529
|
+
gain?: number;
|
|
1530
|
+
speed?: number;
|
|
1531
|
+
loop?: boolean;
|
|
1532
|
+
choke?: string;
|
|
1533
|
+
loopBegin?: number;
|
|
1534
|
+
loopEnd?: number;
|
|
1535
|
+
clip?: number;
|
|
1536
|
+
unit?: SampleUnit;
|
|
1537
|
+
fit?: boolean;
|
|
1538
|
+
accelerate?: number;
|
|
1539
|
+
squiz?: number;
|
|
1540
|
+
} = { src };
|
|
1541
|
+
if (input.sha256 !== undefined) {
|
|
1542
|
+
if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
|
|
1543
|
+
throw new ScoreValidationError(
|
|
1544
|
+
`${label} sha256 must be 64 lowercase hex characters`,
|
|
1545
|
+
"invalid-track",
|
|
1546
|
+
);
|
|
1547
|
+
ref.sha256 = input.sha256;
|
|
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
|
+
}
|
|
1568
|
+
if (input.root !== undefined) {
|
|
1569
|
+
if (
|
|
1570
|
+
typeof input.root !== "number" ||
|
|
1571
|
+
!Number.isInteger(input.root) ||
|
|
1572
|
+
input.root < 0 ||
|
|
1573
|
+
input.root > 127
|
|
1574
|
+
)
|
|
1575
|
+
throw new ScoreValidationError(
|
|
1576
|
+
`${label} root must be a MIDI integer between 0 and 127`,
|
|
1577
|
+
"invalid-track",
|
|
1578
|
+
);
|
|
1579
|
+
ref.root = input.root;
|
|
1580
|
+
}
|
|
1581
|
+
const begin = input.begin === undefined ? 0 : input.begin;
|
|
1582
|
+
const end = input.end === undefined ? 1 : input.end;
|
|
1583
|
+
if (
|
|
1584
|
+
typeof begin !== "number" ||
|
|
1585
|
+
typeof end !== "number" ||
|
|
1586
|
+
!Number.isFinite(begin) ||
|
|
1587
|
+
!Number.isFinite(end) ||
|
|
1588
|
+
begin < 0 ||
|
|
1589
|
+
end > 1 ||
|
|
1590
|
+
begin >= end
|
|
1591
|
+
)
|
|
1592
|
+
throw new ScoreValidationError(
|
|
1593
|
+
`${label} begin/end must be fractions with 0 ≤ begin < end ≤ 1`,
|
|
1594
|
+
"invalid-track",
|
|
1595
|
+
);
|
|
1596
|
+
if (input.begin !== undefined) ref.begin = begin;
|
|
1597
|
+
if (input.end !== undefined) ref.end = end;
|
|
1598
|
+
if (input.gain !== undefined)
|
|
1599
|
+
ref.gain = boundedNumber(
|
|
1600
|
+
input.gain,
|
|
1601
|
+
`${label} gain`,
|
|
1602
|
+
0,
|
|
1603
|
+
SCORE_LIMITS.maxSampleGain,
|
|
1604
|
+
);
|
|
1605
|
+
if (input.speed !== undefined) {
|
|
1606
|
+
const speed = boundedNumber(
|
|
1607
|
+
input.speed,
|
|
1608
|
+
`${label} speed`,
|
|
1609
|
+
-SCORE_LIMITS.maxSampleSpeed,
|
|
1610
|
+
SCORE_LIMITS.maxSampleSpeed,
|
|
1611
|
+
);
|
|
1612
|
+
if (speed === 0)
|
|
1613
|
+
throw new ScoreValidationError(
|
|
1614
|
+
`${label} speed cannot be 0`,
|
|
1615
|
+
"invalid-track",
|
|
1616
|
+
);
|
|
1617
|
+
ref.speed = speed;
|
|
1618
|
+
}
|
|
1619
|
+
if (input.loop !== undefined) {
|
|
1620
|
+
if (typeof input.loop !== "boolean")
|
|
1621
|
+
throw new ScoreValidationError(
|
|
1622
|
+
`${label} loop must be boolean`,
|
|
1623
|
+
"invalid-track",
|
|
1624
|
+
);
|
|
1625
|
+
ref.loop = input.loop;
|
|
1626
|
+
}
|
|
1627
|
+
if (input.choke !== undefined) {
|
|
1628
|
+
if (
|
|
1629
|
+
typeof input.choke !== "string" ||
|
|
1630
|
+
input.choke.length === 0 ||
|
|
1631
|
+
input.choke.length > SCORE_LIMITS.maxChokeGroupLength ||
|
|
1632
|
+
!VOICE_NAME.test(input.choke)
|
|
1633
|
+
)
|
|
1634
|
+
throw new ScoreValidationError(
|
|
1635
|
+
`${label} choke must be a short group name`,
|
|
1636
|
+
"invalid-track",
|
|
1637
|
+
);
|
|
1638
|
+
ref.choke = input.choke;
|
|
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
|
+
);
|
|
1703
|
+
return Object.freeze(ref);
|
|
1704
|
+
}
|
|
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
|
+
|
|
1769
|
+
/** Relative, forward-slash, no empty/dot segments, no control characters. */
|
|
1770
|
+
function isSafeRelativePath(path: string): boolean {
|
|
1771
|
+
if (path.startsWith("/") || /[\\\u0000-\u001f]/.test(path)) return false;
|
|
1772
|
+
if (/^[A-Za-z]:/.test(path)) return false;
|
|
1773
|
+
return path
|
|
1774
|
+
.split("/")
|
|
1775
|
+
.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
1776
|
+
}
|
|
1777
|
+
|
|
759
1778
|
export function normalizeReverb(input: unknown): TrackReverb | undefined {
|
|
760
1779
|
if (input === undefined || input === null) return undefined;
|
|
761
1780
|
if (!isRecord(input))
|
|
@@ -775,7 +1794,17 @@ export function normalizeReverb(input: unknown): TrackReverb | undefined {
|
|
|
775
1794
|
SCORE_LIMITS.minReverbSize,
|
|
776
1795
|
SCORE_LIMITS.maxReverbSize,
|
|
777
1796
|
);
|
|
778
|
-
|
|
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 } : {}) });
|
|
779
1808
|
}
|
|
780
1809
|
|
|
781
1810
|
export function normalizeFilter(input: unknown): TrackFilter | undefined {
|
|
@@ -797,7 +1826,12 @@ export function normalizeFilter(input: unknown): TrackFilter | undefined {
|
|
|
797
1826
|
0,
|
|
798
1827
|
SCORE_LIMITS.maxFilterResonance,
|
|
799
1828
|
);
|
|
800
|
-
|
|
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 });
|
|
801
1835
|
}
|
|
802
1836
|
|
|
803
1837
|
export function normalizeDelay(input: unknown): TrackDelay | undefined {
|
|
@@ -825,7 +1859,13 @@ export function normalizeDelay(input: unknown): TrackDelay | undefined {
|
|
|
825
1859
|
0,
|
|
826
1860
|
SCORE_LIMITS.maxDelayMix,
|
|
827
1861
|
);
|
|
828
|
-
|
|
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 });
|
|
829
1869
|
}
|
|
830
1870
|
|
|
831
1871
|
function boundedNumber(
|