@hraness/dawg 0.3.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DAWG.md +605 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +280 -278
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1119 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +281 -0
  80. package/tui/highway.ts +7 -1
  81. 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
- /** Decoded PCM cache under `.dawg/assets`, LRU. */
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
- /** Project-relative path, normally `tracks/<slug>/samples/<file>`. */
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
- export type AutomationParameter =
170
- "volume" | "pan" | "filter" | "resonance" | "delay-feedback" | "delay-mix";
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
- AutomationParameter,
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
- export function isAutomationParameter(
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 AutomationParameter {
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<Partial<Track>, "filter" | "delay" | "reverb" | "sampler"> &
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
- return updateTrack(score, trackId, {
639
- [AUTOMATION_LANES[parameter].field]: points,
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: AutomationParameter) => {
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
- `${label} src must be a project-relative path without "..", at most ${SCORE_LIMITS.maxSamplePathLength} characters`,
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
- return Object.freeze({ mix, size });
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
- return Object.freeze({ cutoff, resonance });
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
- return Object.freeze({ beats, feedback, mix });
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(