@hraness/dawg 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
package/core/score.ts CHANGED
@@ -20,7 +20,23 @@ import {
20
20
  type FxLane,
21
21
  type TrackFx,
22
22
  } from "./fx.ts";
23
+ import {
24
+ MODAL_INSTRUMENT,
25
+ normalizeModal,
26
+ normalizeWind,
27
+ WIND_INSTRUMENT,
28
+ type TrackModal,
29
+ type TrackWind,
30
+ } from "./resonators.ts";
23
31
  import { normalizeSynth, type TrackSynth } from "./synth.ts";
32
+ import { normalizeString, type TrackString } from "./strings.ts";
33
+ import {
34
+ isGranularInstrument,
35
+ normalizeGranular,
36
+ type TrackGranular,
37
+ } from "./granular.ts";
38
+ import { isKeysFamily, normalizeKeys, type TrackKeys } from "./keys.ts";
39
+ import { GUITAR_TUNING_NAMES } from "./chords.ts";
24
40
  import {
25
41
  checkSongTime,
26
42
  normalizeSongTime,
@@ -33,8 +49,11 @@ import {
33
49
  import {
34
50
  ExpressionValidationError,
35
51
  normalizeNoteExpression,
52
+ normalizePedal,
36
53
  normalizeTrackPerformance,
54
+ SOSTENUTO_STATES,
37
55
  type NoteExpression,
56
+ type PedalEvent,
38
57
  type NoteExpressionPatch,
39
58
  type TrackPerformance,
40
59
  } from "./expression.ts";
@@ -99,6 +118,17 @@ export const SCORE_LIMITS = Object.freeze({
99
118
  maxSampleAccelerate: 8,
100
119
  /** Sampler `squiz` ratio bound. */
101
120
  maxSampleSquiz: 32,
121
+ /** Sampler `bpm` (source tempo) bounds. */
122
+ minSampleBpm: 20,
123
+ maxSampleBpm: 400,
124
+ /** Sampler `len` (window length in beats) bound. */
125
+ maxSampleLenBeats: 1024,
126
+ /** Sampler `shift` and `formant` bound in semitones (0.6.1). */
127
+ maxSampleShift: 24,
128
+ /** Sampler `fadeTime`/`fadeInTime` bound in seconds (0.6.1). */
129
+ maxSampleFadeSeconds: 2,
130
+ /** Longest `resample` render in seconds (0.6.1). */
131
+ maxResampleSeconds: 600,
102
132
  /** Per sample file, enforced by the decoder and the import tool. */
103
133
  maxSampleFileBytes: 50 * 1024 * 1024,
104
134
  maxSampleSeconds: 600,
@@ -373,6 +403,54 @@ export type Track = Readonly<{
373
403
  * `ref`/`root` over the song tuning. Absent follows the song tuning.
374
404
  */
375
405
  tuning?: Tuning;
406
+ /**
407
+ * String engine settings (`core/strings.ts`): a preset plus overrides.
408
+ * Played only when `instrument` is "string"; absent keeps today's voice.
409
+ */
410
+ string?: TrackString;
411
+ /**
412
+ * Granular instrument settings (0.6, `core/granular.ts`). Played when
413
+ * `instrument` is `"granular"`; kept when the instrument changes.
414
+ */
415
+ granular?: TrackGranular;
416
+ /**
417
+ * Optional (0.6): modelled piano settings (`core/keys.ts`), `{ preset?,
418
+ * ...overrides }`. The modelled piano plays only when `instrument` is a
419
+ * piano family (grand upright felt honkytonk prepared) and this is
420
+ * present; `{}` is the family's defaults.
421
+ */
422
+ keys?: TrackKeys;
423
+ /**
424
+ * Optional (0.6): modal percussion (`core/resonators.ts`), a preset plus
425
+ * overrides. Plays only when `instrument` is `"modal"`; any other
426
+ * instrument with this field is rejected. Absent keeps today's tone.
427
+ */
428
+ modal?: TrackModal;
429
+ /**
430
+ * Optional (0.6.1): una corda (soft pedal) events, same shape as `pedal`.
431
+ * Modelled pianos read it at each onset (half is half the shift); other
432
+ * instruments ignore it.
433
+ */
434
+ softPedal?: readonly PedalEvent[];
435
+ /**
436
+ * Optional (0.6.1): sostenuto pedal events (down and up only). Keys held
437
+ * when it goes down ring until it lifts; later keys damp as usual.
438
+ */
439
+ sostenuto?: readonly PedalEvent[];
440
+ /**
441
+ * Optional (0.6.1): how the track is fretted when chords are strummed
442
+ * (`strum`, perform mode `guitar`): tuning, capo, hand stretch, how much
443
+ * open strings ring and a preferred position. Notes only; never read at
444
+ * render, so it changes no sound by itself.
445
+ */
446
+ guitar?: TrackGuitar;
447
+ /**
448
+ * Optional (0.6.1): a blown waveguide (`core/winds.ts`), a preset plus
449
+ * overrides. Plays only when `instrument` is `"wind"`; without it the
450
+ * legacy `wind` tone plays unchanged. Any other instrument with this
451
+ * field is rejected.
452
+ */
453
+ wind?: TrackWind;
376
454
  }> &
377
455
  /**
378
456
  * Performance (`core/expression.ts`): glide default, sustain pedal
@@ -449,8 +527,80 @@ export type SampleRef = Readonly<{
449
527
  * (1 is off, up to 32).
450
528
  */
451
529
  squiz?: number;
530
+ /**
531
+ * Optional: the sample's own tempo (20..400). The window then follows
532
+ * the song's tempo map (ramps included): a 174 BPM break in a 128 BPM
533
+ * song plays 128/174 as fast. `fitmode` picks how.
534
+ */
535
+ bpm?: number;
536
+ /**
537
+ * Optional: how a fitted sample changes its time. `repitch` (default)
538
+ * changes speed and pitch together like tape; `beats` cuts the window at
539
+ * its onsets and places each slice on its new time, unstretched (drums,
540
+ * speech); `tones` time-stretches with a phase vocoder and keeps the
541
+ * pitch (pads, loops, vocals). Needs `bpm`, `len` or `fit`.
542
+ */
543
+ fitmode?: SampleFitMode;
544
+ /**
545
+ * Optional: the window's length in beats (0 < len ≤ 1024), followed
546
+ * through the song's tempo map. `fit` wins over `bpm`, `bpm` over `len`.
547
+ */
548
+ len?: number;
549
+ /**
550
+ * Optional (0.6.1): pitch shift in semitones (−24..24) at constant
551
+ * length, a cached phase-vocoder preprocess (`src/audio/dsp/shift.ts`).
552
+ * Applied after any fit, so it follows `fitmode`.
553
+ */
554
+ shift?: number;
555
+ /**
556
+ * Optional (0.6.1, with `shift`): formant shift in semitones (−24..24).
557
+ * Absent: formants follow the pitch (chipmunk); 0 keeps them in place.
558
+ */
559
+ formant?: number;
560
+ /** Optional (0.6.1, Strudel `fadeTime`): release fade in seconds (0..2). */
561
+ fadeTime?: number;
562
+ /** Optional (0.6.1, Strudel `fadeInTime`): attack fade in seconds (0..2). */
563
+ fadeInTime?: number;
564
+ /**
565
+ * Optional (0.6.1): where `resample` rendered this file from; kept for
566
+ * reference only (never read by the renderer).
567
+ */
568
+ from?: SampleProvenance;
569
+ /**
570
+ * Optional (0.6.1, SFZ `lovel`/`hivel`): the MIDI velocity range
571
+ * [lo, hi] (0..127, lo ≤ hi) this voice plays. Keyed voices with the same
572
+ * root are velocity layers; a one-shot voice outside its range is silent
573
+ * unless its `rr` group has a voice for that velocity.
574
+ */
575
+ vel?: readonly [number, number];
576
+ /**
577
+ * Optional (0.6.1, SFZ `seq_length`): round-robin group name. One-shot
578
+ * voices in a group are one sound (any member's slot plays it); voices
579
+ * that match a note's velocity take turns, A B A B, in note order.
580
+ */
581
+ rr?: string;
582
+ }>;
583
+
584
+ /** `SampleRef.from`: the source of a resampled file (0.6.1). */
585
+ export type SampleProvenance = Readonly<{
586
+ /** `track:<id>`, `orbit:<n>` or `master`. */
587
+ source: string;
588
+ /** Section name the render covered. */
589
+ section?: string;
590
+ /** Bars a..b (1-based, inclusive) the render covered. */
591
+ bars?: readonly [number, number];
592
+ /** sha256 of the canonical score JSON the file was rendered from. */
593
+ score: string;
452
594
  }>;
453
595
 
596
+ /** Sampler `fitmode`: how a fitted window changes its time. */
597
+ export type SampleFitMode = "repitch" | "beats" | "tones";
598
+ export const SAMPLE_FIT_MODES: readonly SampleFitMode[] = Object.freeze([
599
+ "repitch",
600
+ "beats",
601
+ "tones",
602
+ ]);
603
+
454
604
  /** Sample `unit`: rate, cycles (bars) or seconds. */
455
605
  export type SampleUnit = "r" | "c" | "s";
456
606
  export const SAMPLE_UNITS: readonly SampleUnit[] = Object.freeze([
@@ -474,14 +624,22 @@ export type TrackReverb = Readonly<{
474
624
  predelay?: number;
475
625
  /**
476
626
  * Optional (Strudel `iresponse`/`ir`): convolve with this impulse instead
477
- * of the algorithmic tail. `builtin:room|hall|plate`, a pinned pack sound
627
+ * of the algorithmic tail. `builtin:room|hall|plate|reverse|gate|spring`, a pinned pack sound
478
628
  * or a project file; `size`, `fade` and `dim` then do nothing.
479
629
  */
480
630
  ir?: SampleRef;
481
631
  }>;
482
632
 
483
633
  /** Generated impulse responses `reverb.ir` can name as `builtin:<name>`. */
484
- export const REVERB_IR_BUILTINS = Object.freeze(["room", "hall", "plate"]);
634
+ export const REVERB_IR_BUILTINS = Object.freeze([
635
+ "room",
636
+ "hall",
637
+ "plate",
638
+ // 0.6.1: a reverse (rising) wash, a gated tail, a spring tank.
639
+ "reverse",
640
+ "gate",
641
+ "spring",
642
+ ]);
485
643
 
486
644
  /**
487
645
  * Validates `reverb.ir`: a string or `{ src, … }`. Bare built-in names and
@@ -683,6 +841,14 @@ export type TrackPatch = Readonly<
683
841
  velocityCurve?: Track["velocityCurve"] | null;
684
842
  humanize?: Track["humanize"] | null;
685
843
  tuning?: Tuning | null;
844
+ string?: TrackString | null;
845
+ granular?: TrackGranular | null;
846
+ keys?: TrackKeys | null;
847
+ modal?: TrackModal | null;
848
+ softPedal?: Track["softPedal"] | null;
849
+ sostenuto?: Track["sostenuto"] | null;
850
+ guitar?: TrackGuitar | null;
851
+ wind?: TrackWind | null;
686
852
  }
687
853
  >;
688
854
 
@@ -727,6 +893,14 @@ export type TrackInput = Readonly<
727
893
  | "velocityCurve"
728
894
  | "humanize"
729
895
  | "tuning"
896
+ | "string"
897
+ | "granular"
898
+ | "keys"
899
+ | "modal"
900
+ | "softPedal"
901
+ | "sostenuto"
902
+ | "guitar"
903
+ | "wind"
730
904
  > &
731
905
  Pick<Track, "id"> & {
732
906
  filter?: TrackFilter | null;
@@ -745,6 +919,14 @@ export type TrackInput = Readonly<
745
919
  velocityCurve?: Track["velocityCurve"] | string | null;
746
920
  humanize?: Track["humanize"] | null;
747
921
  tuning?: Tuning | null;
922
+ string?: TrackString | null;
923
+ granular?: TrackGranular | null;
924
+ keys?: TrackKeys | null;
925
+ modal?: TrackModal | null;
926
+ softPedal?: Track["softPedal"] | null;
927
+ sostenuto?: Track["sostenuto"] | null;
928
+ guitar?: TrackGuitar | null;
929
+ wind?: TrackWind | null;
748
930
  }
749
931
  >;
750
932
 
@@ -1134,9 +1316,31 @@ export function updateTrack(
1134
1316
  ): TrackScore {
1135
1317
  if (!score.tracks.some((track) => track.id === trackId)) return score;
1136
1318
  return score.withTracks(
1137
- score.tracks.map((track) =>
1138
- track.id === trackId ? { ...track, ...patch } : track,
1139
- ),
1319
+ score.tracks.map((track) => {
1320
+ if (track.id !== trackId) return track;
1321
+ const next: Record<string, unknown> = { ...track, ...patch };
1322
+ // A modal field only belongs to a modal track: switching the
1323
+ // instrument away drops it unless the patch sets it too.
1324
+ if (
1325
+ patch.instrument !== undefined &&
1326
+ patch.modal === undefined &&
1327
+ patch.instrument !== MODAL_INSTRUMENT
1328
+ )
1329
+ delete next.modal;
1330
+ // The legacy word `wind` is the legacy tone, so naming any instrument
1331
+ // (`wind` included) without a wind field leaves the wind engine.
1332
+ if (patch.instrument !== undefined && patch.wind === undefined)
1333
+ delete next.wind;
1334
+ // Keys settings only belong to a keys family (piano, electric, organ):
1335
+ // switching to another instrument drops them unless the patch sets them.
1336
+ if (
1337
+ patch.instrument !== undefined &&
1338
+ patch.keys === undefined &&
1339
+ !isKeysFamily(patch.instrument)
1340
+ )
1341
+ delete next.keys;
1342
+ return next as Track;
1343
+ }),
1140
1344
  );
1141
1345
  }
1142
1346
 
@@ -1717,7 +1921,21 @@ function normalizeTrack(input: unknown): Track {
1717
1921
  const fx = fxOrThrow(() => normalizeFx(input.fx));
1718
1922
  const fxAutomation = normalizeFxAutomation(input.fxAutomation);
1719
1923
  const synth = fxOrThrow(() => normalizeSynth(input.synth));
1924
+ const keys = fxOrThrow(() => normalizeKeys(input.keys));
1720
1925
  const wavetable = normalizeWavetable(input.wavetable);
1926
+ const modal = fxOrThrow(() => normalizeModal(input.modal));
1927
+ if (modal && instrument !== MODAL_INSTRUMENT)
1928
+ throw new ScoreValidationError(
1929
+ `track ${id} has modal settings but its instrument is "${instrument}"`,
1930
+ "invalid-track",
1931
+ );
1932
+ const guitar = normalizeGuitar(input.guitar);
1933
+ const wind = fxOrThrow(() => normalizeWind(input.wind));
1934
+ if (wind && instrument !== WIND_INSTRUMENT)
1935
+ throw new ScoreValidationError(
1936
+ `track ${id} has wind settings but its instrument is "${instrument}"`,
1937
+ "invalid-track",
1938
+ );
1721
1939
  const sampler = normalizeSampler(input.sampler);
1722
1940
  const rhythm = normalizeRhythm(input.rhythm, id);
1723
1941
  const time = timeOrThrow(
@@ -1739,6 +1957,37 @@ function normalizeTrack(input: unknown): Track {
1739
1957
  () => normalizeTuning(input.tuning, `track ${id} tuning`),
1740
1958
  "invalid-track",
1741
1959
  );
1960
+ let softPedal: readonly PedalEvent[] | undefined;
1961
+ let sostenuto: readonly PedalEvent[] | undefined;
1962
+ try {
1963
+ softPedal = normalizePedal(
1964
+ input.softPedal,
1965
+ SCORE_LIMITS.maxTick,
1966
+ "softPedal",
1967
+ );
1968
+ sostenuto = normalizePedal(
1969
+ input.sostenuto,
1970
+ SCORE_LIMITS.maxTick,
1971
+ "sostenuto",
1972
+ SOSTENUTO_STATES,
1973
+ );
1974
+ } catch (error) {
1975
+ if (error instanceof ExpressionValidationError)
1976
+ throw new ScoreValidationError(
1977
+ `track ${id} ${error.message}`,
1978
+ "invalid-track",
1979
+ );
1980
+ throw error;
1981
+ }
1982
+ const string = fxOrThrow(() => normalizeString(input.string));
1983
+ // A bare `granular` instrument (set_instrument, `instrument granular`)
1984
+ // gets an empty object, so the engine plays its defaults.
1985
+ const granular =
1986
+ fxOrThrow(() =>
1987
+ normalizeGranular(input.granular, (ref) =>
1988
+ normalizeSampleRef(ref, "granular src"),
1989
+ ),
1990
+ ) ?? (isGranularInstrument(instrument) ? Object.freeze({}) : undefined);
1742
1991
  let kit: string | undefined;
1743
1992
  if (input.kit !== undefined && input.kit !== null) {
1744
1993
  const found =
@@ -1760,7 +2009,12 @@ function normalizeTrack(input: unknown): Track {
1760
2009
  `track ${id} instrument "sampler" needs a sampler`,
1761
2010
  "invalid-track",
1762
2011
  );
1763
- if (sampler && !isSamplerInstrument(instrument))
2012
+ // A granular track keeps the sampler it grains (`grain off` goes back).
2013
+ if (
2014
+ sampler &&
2015
+ !isSamplerInstrument(instrument) &&
2016
+ !isGranularInstrument(instrument)
2017
+ )
1764
2018
  throw new ScoreValidationError(
1765
2019
  `track ${id} has a sampler but its instrument is "${instrument}"`,
1766
2020
  "invalid-track",
@@ -1793,9 +2047,93 @@ function normalizeTrack(input: unknown): Track {
1793
2047
  ...(time ? { time } : {}),
1794
2048
  ...performance,
1795
2049
  ...(tuning ? { tuning } : {}),
2050
+ ...(string ? { string } : {}),
2051
+ ...(granular ? { granular } : {}),
2052
+ ...(keys ? { keys } : {}),
2053
+ ...(modal ? { modal } : {}),
2054
+ ...(softPedal ? { softPedal } : {}),
2055
+ ...(sostenuto ? { sostenuto } : {}),
2056
+ ...(guitar ? { guitar } : {}),
2057
+ ...(wind ? { wind } : {}),
1796
2058
  });
1797
2059
  }
1798
2060
 
2061
+ /** Track.guitar (0.6.1): a tuning name or open-string pitches plus fretting. */
2062
+ export type TrackGuitar = Readonly<{
2063
+ /** A GUITAR_TUNINGS name or 3..12 open-string MIDI pitches, low to high. */
2064
+ tune?: string | readonly number[];
2065
+ /** Capo fret 0..12. */
2066
+ capo?: number;
2067
+ /** Hand stretch in frets 3..6 (default 4). */
2068
+ hand?: number;
2069
+ /** 0 closed shapes .. 1 ringing open strings (default 0.5). */
2070
+ ring?: number;
2071
+ /** Preferred fret position 0..12. */
2072
+ position?: number;
2073
+ }>;
2074
+
2075
+ /** Validates Track.guitar; `{}` and null normalize to absent. */
2076
+ export function normalizeGuitar(input: unknown): TrackGuitar | undefined {
2077
+ if (input === undefined || input === null) return undefined;
2078
+ const fail = (message: string): never => {
2079
+ throw new ScoreValidationError(`track guitar ${message}`, "invalid-track");
2080
+ };
2081
+ if (typeof input !== "object" || Array.isArray(input))
2082
+ fail("must be an object or null");
2083
+ const out: Record<string, unknown> = {};
2084
+ const record = input as Record<string, unknown>;
2085
+ for (const key of Object.keys(record))
2086
+ if (!["tune", "capo", "hand", "ring", "position"].includes(key))
2087
+ fail(`has no field "${key.slice(0, 32)}"`);
2088
+ const tune = record.tune;
2089
+ if (tune !== undefined && tune !== null) {
2090
+ if (typeof tune === "string") {
2091
+ const name = tune.toLowerCase().replace(/[\s_-]/g, "");
2092
+ if (!(GUITAR_TUNING_NAMES as readonly string[]).includes(name))
2093
+ fail(
2094
+ `tune must be one of ${GUITAR_TUNING_NAMES.join(", ")} or a list of pitches`,
2095
+ );
2096
+ out.tune = name;
2097
+ } else if (
2098
+ Array.isArray(tune) &&
2099
+ tune.length >= 3 &&
2100
+ tune.length <= 12 &&
2101
+ tune.every((p) => Number.isInteger(p) && p >= 0 && p <= 127)
2102
+ )
2103
+ out.tune = Object.freeze([...(tune as number[])]);
2104
+ else fail("tune must be a tuning name or 3..12 MIDI pitches");
2105
+ }
2106
+ const int = (key: string, min: number, max: number) => {
2107
+ const value = record[key];
2108
+ if (value === undefined || value === null) return;
2109
+ if (
2110
+ typeof value !== "number" ||
2111
+ !Number.isInteger(value) ||
2112
+ value < min ||
2113
+ value > max
2114
+ )
2115
+ fail(`${key} must be an integer ${min}..${max}`);
2116
+ out[key] = value;
2117
+ };
2118
+ int("capo", 0, 12);
2119
+ int("hand", 3, 6);
2120
+ const ring = record.ring;
2121
+ if (ring !== undefined && ring !== null) {
2122
+ if (
2123
+ typeof ring !== "number" ||
2124
+ !Number.isFinite(ring) ||
2125
+ ring < 0 ||
2126
+ ring > 1
2127
+ )
2128
+ fail("ring must be 0..1");
2129
+ out.ring = Math.round((ring as number) * 1000) / 1000;
2130
+ }
2131
+ int("position", 0, 12);
2132
+ return Object.keys(out).length > 0
2133
+ ? (Object.freeze(out) as TrackGuitar)
2134
+ : undefined;
2135
+ }
2136
+
1799
2137
  export function normalizeRhythm(
1800
2138
  input: unknown,
1801
2139
  trackId: string,
@@ -1918,6 +2256,8 @@ function trackEffectExtras(
1918
2256
  }
1919
2257
 
1920
2258
  const VOICE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
2259
+ /** A sampler round-robin group name (`SampleRef.rr`). */
2260
+ const SAMPLE_RR_GROUP = /^[A-Za-z0-9_]{1,32}$/;
1921
2261
  const SHA256_HEX = /^[0-9a-f]{64}$/;
1922
2262
 
1923
2263
  /** Validates a `Sampler` from unknown; `undefined`/`null` means none. */
@@ -2000,6 +2340,16 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
2000
2340
  fit?: boolean;
2001
2341
  accelerate?: number;
2002
2342
  squiz?: number;
2343
+ bpm?: number;
2344
+ fitmode?: SampleFitMode;
2345
+ len?: number;
2346
+ shift?: number;
2347
+ formant?: number;
2348
+ fadeTime?: number;
2349
+ fadeInTime?: number;
2350
+ from?: SampleProvenance;
2351
+ vel?: readonly [number, number];
2352
+ rr?: string;
2003
2353
  } = { src };
2004
2354
  if (input.sha256 !== undefined) {
2005
2355
  if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
@@ -2163,9 +2513,162 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
2163
2513
  1,
2164
2514
  SCORE_LIMITS.maxSampleSquiz,
2165
2515
  );
2516
+ if (input.bpm !== undefined)
2517
+ ref.bpm = boundedNumber(
2518
+ input.bpm,
2519
+ `${label} bpm`,
2520
+ SCORE_LIMITS.minSampleBpm,
2521
+ SCORE_LIMITS.maxSampleBpm,
2522
+ );
2523
+ if (input.fitmode !== undefined) {
2524
+ if (!SAMPLE_FIT_MODES.includes(input.fitmode as SampleFitMode))
2525
+ throw new ScoreValidationError(
2526
+ `${label} fitmode must be "repitch", "beats" or "tones"`,
2527
+ "invalid-track",
2528
+ );
2529
+ ref.fitmode = input.fitmode as SampleFitMode;
2530
+ }
2531
+ if (input.len !== undefined) {
2532
+ const len = boundedNumber(
2533
+ input.len,
2534
+ `${label} len`,
2535
+ 0,
2536
+ SCORE_LIMITS.maxSampleLenBeats,
2537
+ );
2538
+ if (len === 0)
2539
+ throw new ScoreValidationError(
2540
+ `${label} len must be greater than 0`,
2541
+ "invalid-track",
2542
+ );
2543
+ ref.len = len;
2544
+ }
2545
+ if (input.vel !== undefined) {
2546
+ const vel = input.vel;
2547
+ if (
2548
+ !Array.isArray(vel) ||
2549
+ vel.length !== 2 ||
2550
+ !vel.every((v) => Number.isInteger(v) && v >= 0 && v <= 127) ||
2551
+ vel[0] > vel[1]
2552
+ )
2553
+ throw new ScoreValidationError(
2554
+ `${label} vel must be [lo, hi], MIDI velocities 0..127 with lo ≤ hi`,
2555
+ "invalid-track",
2556
+ );
2557
+ ref.vel = Object.freeze([vel[0] as number, vel[1] as number] as const);
2558
+ }
2559
+ if (input.rr !== undefined) {
2560
+ if (typeof input.rr !== "string" || !SAMPLE_RR_GROUP.test(input.rr))
2561
+ throw new ScoreValidationError(
2562
+ `${label} rr must be a round-robin group name (letters, digits, _; at most 32)`,
2563
+ "invalid-track",
2564
+ );
2565
+ ref.rr = input.rr;
2566
+ }
2567
+ if (
2568
+ ref.fitmode !== undefined &&
2569
+ ref.fitmode !== "repitch" &&
2570
+ ref.bpm === undefined &&
2571
+ ref.len === undefined &&
2572
+ ref.fit !== true
2573
+ )
2574
+ throw new ScoreValidationError(
2575
+ `${label} fitmode ${ref.fitmode} needs bpm, len or fit`,
2576
+ "invalid-track",
2577
+ );
2578
+ if (input.shift !== undefined)
2579
+ ref.shift = boundedNumber(
2580
+ input.shift,
2581
+ `${label} shift`,
2582
+ -SCORE_LIMITS.maxSampleShift,
2583
+ SCORE_LIMITS.maxSampleShift,
2584
+ );
2585
+ if (input.formant !== undefined)
2586
+ ref.formant = boundedNumber(
2587
+ input.formant,
2588
+ `${label} formant`,
2589
+ -SCORE_LIMITS.maxSampleShift,
2590
+ SCORE_LIMITS.maxSampleShift,
2591
+ );
2592
+ if (input.fadeTime !== undefined)
2593
+ ref.fadeTime = boundedNumber(
2594
+ input.fadeTime,
2595
+ `${label} fadeTime`,
2596
+ 0,
2597
+ SCORE_LIMITS.maxSampleFadeSeconds,
2598
+ );
2599
+ if (input.fadeInTime !== undefined)
2600
+ ref.fadeInTime = boundedNumber(
2601
+ input.fadeInTime,
2602
+ `${label} fadeInTime`,
2603
+ 0,
2604
+ SCORE_LIMITS.maxSampleFadeSeconds,
2605
+ );
2606
+ if (input.from !== undefined)
2607
+ ref.from = normalizeProvenance(input.from, `${label} from`);
2166
2608
  return Object.freeze(ref);
2167
2609
  }
2168
2610
 
2611
+ const PROVENANCE_SOURCE =
2612
+ /^(?:track:[A-Za-z0-9_-]{1,64}|orbit:[0-9]{1,2}|master)$/;
2613
+
2614
+ function normalizeProvenance(input: unknown, label: string): SampleProvenance {
2615
+ if (
2616
+ !isRecord(input) ||
2617
+ typeof input.source !== "string" ||
2618
+ !PROVENANCE_SOURCE.test(input.source) ||
2619
+ typeof input.score !== "string" ||
2620
+ !SHA256_HEX.test(input.score)
2621
+ )
2622
+ throw new ScoreValidationError(
2623
+ `${label} must be { source: track:<id>|orbit:<n>|master, score: <sha256> }`,
2624
+ "invalid-track",
2625
+ );
2626
+ const from: {
2627
+ source: string;
2628
+ section?: string;
2629
+ bars?: [number, number];
2630
+ score: string;
2631
+ } = {
2632
+ source: input.source,
2633
+ score: input.score,
2634
+ };
2635
+ if (input.section !== undefined) {
2636
+ if (
2637
+ typeof input.section !== "string" ||
2638
+ input.section.length === 0 ||
2639
+ input.section.length > 64
2640
+ )
2641
+ throw new ScoreValidationError(
2642
+ `${label} section must be a section name`,
2643
+ "invalid-track",
2644
+ );
2645
+ from.section = input.section;
2646
+ }
2647
+ if (input.bars !== undefined) {
2648
+ const bars = input.bars;
2649
+ if (
2650
+ !Array.isArray(bars) ||
2651
+ bars.length !== 2 ||
2652
+ !bars.every((b) => Number.isInteger(b) && b >= 1 && b <= 100_000) ||
2653
+ bars[0] > bars[1]
2654
+ )
2655
+ throw new ScoreValidationError(
2656
+ `${label} bars must be [a, b] with 1 ≤ a ≤ b`,
2657
+ "invalid-track",
2658
+ );
2659
+ from.bars = [bars[0], bars[1]];
2660
+ }
2661
+ // Key order is fixed so the printed JSON is stable.
2662
+ return Object.freeze({
2663
+ source: from.source,
2664
+ ...(from.section !== undefined ? { section: from.section } : {}),
2665
+ ...(from.bars !== undefined
2666
+ ? { bars: Object.freeze(from.bars) as readonly [number, number] }
2667
+ : {}),
2668
+ score: from.score,
2669
+ });
2670
+ }
2671
+
2169
2672
  /** Prefix of a pack sound reference in `SampleRef.src`. */
2170
2673
  export const PACK_PREFIX = "pack:";
2171
2674
 
@@ -2403,10 +2906,28 @@ function normalizeAutomation(
2403
2906
  return freezeArray(points);
2404
2907
  }
2405
2908
 
2909
+ /** The note cap `normalizeNotes` enforces; see `withNoteCap`. */
2910
+ let noteCap: number = SCORE_LIMITS.maxNotes;
2911
+
2912
+ /**
2913
+ * Runs `fn` with the note cap raised to `cap`. Stored scores keep
2914
+ * SCORE_LIMITS.maxNotes; the arranged renderer bakes timed tracks (cycle,
2915
+ * rate) into plain notes and may hold more than a stored score allows.
2916
+ */
2917
+ export function withNoteCap<T>(cap: number, fn: () => T): T {
2918
+ const previous = noteCap;
2919
+ noteCap = Math.max(previous, cap);
2920
+ try {
2921
+ return fn();
2922
+ } finally {
2923
+ noteCap = previous;
2924
+ }
2925
+ }
2926
+
2406
2927
  function normalizeNotes(inputs: readonly unknown[]): Note[] {
2407
- if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxNotes) {
2928
+ if (!Array.isArray(inputs) || inputs.length > noteCap) {
2408
2929
  throw new ScoreValidationError(
2409
- `score cannot contain more than ${SCORE_LIMITS.maxNotes} notes`,
2930
+ `score cannot contain more than ${noteCap} notes`,
2410
2931
  "score-limit",
2411
2932
  );
2412
2933
  }
@@ -104,6 +104,8 @@ async function withSampleHashes(
104
104
  const wavetable = track.wavetable;
105
105
  if (isObject(wavetable) && isObject(wavetable.table))
106
106
  refs.push(wavetable.table);
107
+ const granular = track.granular;
108
+ if (isObject(granular) && isObject(granular.src)) refs.push(granular.src);
107
109
  }
108
110
  for (const ref of refs) {
109
111
  if (