@hraness/dawg 0.6.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 (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/DAWG.md +338 -51
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +5 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +258 -6
  8. package/core/granular.ts +91 -0
  9. package/core/instruments.ts +161 -0
  10. package/core/keys.ts +573 -4
  11. package/core/resonators.ts +291 -3
  12. package/core/score.ts +349 -3
  13. package/core/sdk/print.ts +78 -3
  14. package/core/sdk/v1.ts +1320 -36
  15. package/core/sections.ts +12 -4
  16. package/core/strings.ts +237 -2
  17. package/core/winds.ts +652 -0
  18. package/guides/automation.md +1 -0
  19. package/guides/chords.md +3 -1
  20. package/guides/effects.md +3 -2
  21. package/guides/media.md +1 -1
  22. package/guides/performance.md +3 -0
  23. package/guides/resample.md +26 -0
  24. package/guides/sounds.md +7 -6
  25. package/package.json +1 -1
  26. package/src/agent/agent.ts +8 -2
  27. package/src/agent/chord-tools.ts +153 -1
  28. package/src/agent/expression-tools.ts +91 -0
  29. package/src/agent/ops.ts +4 -1
  30. package/src/agent/preview-tool.ts +2 -1
  31. package/src/agent/resample-tool.ts +131 -0
  32. package/src/agent/tools.ts +200 -15
  33. package/src/agent/xcb-agent.ts +8 -2
  34. package/src/audio/arrange.ts +21 -0
  35. package/src/audio/dsp/envelope.ts +161 -0
  36. package/src/audio/dsp/shift.ts +256 -0
  37. package/src/audio/effects/chain.ts +18 -3
  38. package/src/audio/effects/common.ts +14 -0
  39. package/src/audio/effects/convolution.ts +106 -1
  40. package/src/audio/effects/gaze.ts +354 -0
  41. package/src/audio/engine.ts +46 -5
  42. package/src/audio/fit.ts +45 -0
  43. package/src/audio/granular.ts +352 -21
  44. package/src/audio/instrument-check.ts +9 -5
  45. package/src/audio/instruments.ts +32 -3
  46. package/src/audio/keys/electric.ts +420 -0
  47. package/src/audio/keys/engine.ts +143 -19
  48. package/src/audio/keys/organ.ts +1335 -0
  49. package/src/audio/keys/piano.ts +50 -6
  50. package/src/audio/keys/sympathetic.ts +127 -0
  51. package/src/audio/live-worker.ts +3 -1
  52. package/src/audio/live.ts +136 -9
  53. package/src/audio/preview.ts +15 -1
  54. package/src/audio/resample.ts +285 -0
  55. package/src/audio/resonators.ts +10 -2
  56. package/src/audio/sampler.ts +168 -21
  57. package/src/audio/samples.ts +26 -1
  58. package/src/audio/strings/body.ts +29 -16
  59. package/src/audio/strings/bow.ts +656 -0
  60. package/src/audio/strings/engine.ts +266 -20
  61. package/src/audio/strings/measure.test-helpers.ts +2 -0
  62. package/src/audio/wav.ts +119 -19
  63. package/src/audio/winds/engine.ts +302 -0
  64. package/src/audio/winds/filters.ts +153 -0
  65. package/src/audio/winds/pitch.ts +104 -0
  66. package/src/audio/winds/trim.ts +80 -0
  67. package/src/audio/winds/trims.ts +917 -0
  68. package/src/audio/winds/voice.ts +479 -0
  69. package/src/commands/expression.ts +103 -22
  70. package/src/commands/fx.ts +4 -1
  71. package/src/commands/granular.ts +7 -2
  72. package/src/commands/help.ts +91 -8
  73. package/src/commands/keys.ts +287 -11
  74. package/src/commands/modal.ts +67 -4
  75. package/src/commands/resample.ts +281 -0
  76. package/src/commands/rig.ts +5 -1
  77. package/src/commands/sample.ts +49 -2
  78. package/src/commands/shift.ts +119 -0
  79. package/src/commands/string.ts +28 -2
  80. package/src/commands/strum.ts +473 -0
  81. package/src/commands/wind.ts +244 -0
  82. package/src/main.ts +129 -7
  83. package/src/project/check.ts +5 -1
  84. package/src/render.ts +16 -0
  85. package/src/session/presence.ts +16 -3
  86. package/src/tui/audition.ts +1 -1
  87. package/src/tui/menu.ts +637 -35
  88. package/src/tui/modal-menu.ts +34 -7
  89. package/src/tui/performance-menu.ts +42 -0
  90. package/src/tui/play-chords.ts +104 -3
  91. package/src/tui/play-mode.ts +1 -0
  92. package/src/tui/play-session.ts +34 -9
  93. package/src/tui/wind-menu.ts +144 -0
package/core/score.ts CHANGED
@@ -23,7 +23,10 @@ import {
23
23
  import {
24
24
  MODAL_INSTRUMENT,
25
25
  normalizeModal,
26
+ normalizeWind,
27
+ WIND_INSTRUMENT,
26
28
  type TrackModal,
29
+ type TrackWind,
27
30
  } from "./resonators.ts";
28
31
  import { normalizeSynth, type TrackSynth } from "./synth.ts";
29
32
  import { normalizeString, type TrackString } from "./strings.ts";
@@ -32,7 +35,8 @@ import {
32
35
  normalizeGranular,
33
36
  type TrackGranular,
34
37
  } from "./granular.ts";
35
- import { normalizeKeys, type TrackKeys } from "./keys.ts";
38
+ import { isKeysFamily, normalizeKeys, type TrackKeys } from "./keys.ts";
39
+ import { GUITAR_TUNING_NAMES } from "./chords.ts";
36
40
  import {
37
41
  checkSongTime,
38
42
  normalizeSongTime,
@@ -45,8 +49,11 @@ import {
45
49
  import {
46
50
  ExpressionValidationError,
47
51
  normalizeNoteExpression,
52
+ normalizePedal,
48
53
  normalizeTrackPerformance,
54
+ SOSTENUTO_STATES,
49
55
  type NoteExpression,
56
+ type PedalEvent,
50
57
  type NoteExpressionPatch,
51
58
  type TrackPerformance,
52
59
  } from "./expression.ts";
@@ -116,6 +123,12 @@ export const SCORE_LIMITS = Object.freeze({
116
123
  maxSampleBpm: 400,
117
124
  /** Sampler `len` (window length in beats) bound. */
118
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,
119
132
  /** Per sample file, enforced by the decoder and the import tool. */
120
133
  maxSampleFileBytes: 50 * 1024 * 1024,
121
134
  maxSampleSeconds: 600,
@@ -413,6 +426,31 @@ export type Track = Readonly<{
413
426
  * instrument with this field is rejected. Absent keeps today's tone.
414
427
  */
415
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;
416
454
  }> &
417
455
  /**
418
456
  * Performance (`core/expression.ts`): glide default, sustain pedal
@@ -508,6 +546,51 @@ export type SampleRef = Readonly<{
508
546
  * through the song's tempo map. `fit` wins over `bpm`, `bpm` over `len`.
509
547
  */
510
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;
511
594
  }>;
512
595
 
513
596
  /** Sampler `fitmode`: how a fitted window changes its time. */
@@ -541,14 +624,22 @@ export type TrackReverb = Readonly<{
541
624
  predelay?: number;
542
625
  /**
543
626
  * Optional (Strudel `iresponse`/`ir`): convolve with this impulse instead
544
- * 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
545
628
  * or a project file; `size`, `fade` and `dim` then do nothing.
546
629
  */
547
630
  ir?: SampleRef;
548
631
  }>;
549
632
 
550
633
  /** Generated impulse responses `reverb.ir` can name as `builtin:<name>`. */
551
- 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
+ ]);
552
643
 
553
644
  /**
554
645
  * Validates `reverb.ir`: a string or `{ src, … }`. Bare built-in names and
@@ -754,6 +845,10 @@ export type TrackPatch = Readonly<
754
845
  granular?: TrackGranular | null;
755
846
  keys?: TrackKeys | null;
756
847
  modal?: TrackModal | null;
848
+ softPedal?: Track["softPedal"] | null;
849
+ sostenuto?: Track["sostenuto"] | null;
850
+ guitar?: TrackGuitar | null;
851
+ wind?: TrackWind | null;
757
852
  }
758
853
  >;
759
854
 
@@ -802,6 +897,10 @@ export type TrackInput = Readonly<
802
897
  | "granular"
803
898
  | "keys"
804
899
  | "modal"
900
+ | "softPedal"
901
+ | "sostenuto"
902
+ | "guitar"
903
+ | "wind"
805
904
  > &
806
905
  Pick<Track, "id"> & {
807
906
  filter?: TrackFilter | null;
@@ -824,6 +923,10 @@ export type TrackInput = Readonly<
824
923
  granular?: TrackGranular | null;
825
924
  keys?: TrackKeys | null;
826
925
  modal?: TrackModal | null;
926
+ softPedal?: Track["softPedal"] | null;
927
+ sostenuto?: Track["sostenuto"] | null;
928
+ guitar?: TrackGuitar | null;
929
+ wind?: TrackWind | null;
827
930
  }
828
931
  >;
829
932
 
@@ -1224,6 +1327,18 @@ export function updateTrack(
1224
1327
  patch.instrument !== MODAL_INSTRUMENT
1225
1328
  )
1226
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;
1227
1342
  return next as Track;
1228
1343
  }),
1229
1344
  );
@@ -1814,6 +1929,13 @@ function normalizeTrack(input: unknown): Track {
1814
1929
  `track ${id} has modal settings but its instrument is "${instrument}"`,
1815
1930
  "invalid-track",
1816
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
+ );
1817
1939
  const sampler = normalizeSampler(input.sampler);
1818
1940
  const rhythm = normalizeRhythm(input.rhythm, id);
1819
1941
  const time = timeOrThrow(
@@ -1835,6 +1957,28 @@ function normalizeTrack(input: unknown): Track {
1835
1957
  () => normalizeTuning(input.tuning, `track ${id} tuning`),
1836
1958
  "invalid-track",
1837
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
+ }
1838
1982
  const string = fxOrThrow(() => normalizeString(input.string));
1839
1983
  // A bare `granular` instrument (set_instrument, `instrument granular`)
1840
1984
  // gets an empty object, so the engine plays its defaults.
@@ -1907,9 +2051,89 @@ function normalizeTrack(input: unknown): Track {
1907
2051
  ...(granular ? { granular } : {}),
1908
2052
  ...(keys ? { keys } : {}),
1909
2053
  ...(modal ? { modal } : {}),
2054
+ ...(softPedal ? { softPedal } : {}),
2055
+ ...(sostenuto ? { sostenuto } : {}),
2056
+ ...(guitar ? { guitar } : {}),
2057
+ ...(wind ? { wind } : {}),
1910
2058
  });
1911
2059
  }
1912
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
+
1913
2137
  export function normalizeRhythm(
1914
2138
  input: unknown,
1915
2139
  trackId: string,
@@ -2032,6 +2256,8 @@ function trackEffectExtras(
2032
2256
  }
2033
2257
 
2034
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}$/;
2035
2261
  const SHA256_HEX = /^[0-9a-f]{64}$/;
2036
2262
 
2037
2263
  /** Validates a `Sampler` from unknown; `undefined`/`null` means none. */
@@ -2117,6 +2343,13 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
2117
2343
  bpm?: number;
2118
2344
  fitmode?: SampleFitMode;
2119
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;
2120
2353
  } = { src };
2121
2354
  if (input.sha256 !== undefined) {
2122
2355
  if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
@@ -2309,6 +2542,28 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
2309
2542
  );
2310
2543
  ref.len = len;
2311
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
+ }
2312
2567
  if (
2313
2568
  ref.fitmode !== undefined &&
2314
2569
  ref.fitmode !== "repitch" &&
@@ -2320,9 +2575,100 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
2320
2575
  `${label} fitmode ${ref.fitmode} needs bpm, len or fit`,
2321
2576
  "invalid-track",
2322
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`);
2323
2608
  return Object.freeze(ref);
2324
2609
  }
2325
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
+
2326
2672
  /** Prefix of a pack sound reference in `SampleRef.src`. */
2327
2673
  export const PACK_PREFIX = "pack:";
2328
2674
 
package/core/sdk/print.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  MODAL_PARAMS,
28
28
  type TrackModal,
29
29
  } from "../resonators.ts";
30
+ import { WIND_INSTRUMENT, WIND_PARAMS, type TrackWind } from "../winds.ts";
30
31
  import { rhythmVoicePitch, rowInSync } from "../rhythm.ts";
31
32
  import {
32
33
  BUILTIN_TABLE_PREFIX,
@@ -155,6 +156,7 @@ const RESERVED = new Set([
155
156
  "rig",
156
157
  "granular",
157
158
  "modal",
159
+ "wind",
158
160
  "slices",
159
161
  "euclid",
160
162
  "euclidRot",
@@ -414,6 +416,11 @@ export function printTrack(score: TrackScore, track: Track): string {
414
416
  ? printModal(track.modal, INDENT)
415
417
  : undefined;
416
418
  if (modalCall?.startsWith("modal(")) used.add("modal");
419
+ const windCall =
420
+ track.instrument === WIND_INSTRUMENT && track.wind
421
+ ? printWind(track.wind, INDENT)
422
+ : undefined;
423
+ if (windCall?.startsWith("wind(")) used.add("wind");
417
424
 
418
425
  const entries: string[] = [
419
426
  `id: ${str(track.id)}`,
@@ -432,6 +439,8 @@ export function printTrack(score: TrackScore, track: Track): string {
432
439
  entries.push(`instrument: ${printStringed(track.string!, INDENT)}`);
433
440
  } else if (modalCall) {
434
441
  entries.push(`instrument: ${modalCall}`);
442
+ } else if (windCall) {
443
+ entries.push(`instrument: ${windCall}`);
435
444
  } else entries.push(`instrument: ${str(track.instrument)}`);
436
445
  if (track.kit) entries.push(`kit: ${str(track.kit)}`);
437
446
  if (track.granular && !grained)
@@ -658,6 +667,7 @@ export function printTrack(score: TrackScore, track: Track): string {
658
667
  "rig",
659
668
  "granular",
660
669
  "modal",
670
+ "wind",
661
671
  "euclid",
662
672
  "grid",
663
673
  ]
@@ -808,13 +818,15 @@ function performanceEntries(score: TrackScore, track: Track): string[] {
808
818
  1,
809
819
  )}`,
810
820
  );
811
- if (track.pedal) {
812
- const events = track.pedal.map(
821
+ for (const key of ["pedal", "softPedal", "sostenuto"] as const) {
822
+ const lane = track[key];
823
+ if (!lane) continue;
824
+ const events = lane.map(
813
825
  (event) =>
814
826
  `[${num(event.tick / score.ticksPerBeat)}, ${str(event.state)}]`,
815
827
  );
816
828
  entries.push(
817
- `pedal: ${list(events, INDENT, "pedal: ".length, 1, events.length > 1)}`,
829
+ `${key}: ${list(events, INDENT, `${key}: `.length, 1, events.length > 1)}`,
818
830
  );
819
831
  }
820
832
  if (track.velocityCurve)
@@ -851,6 +863,20 @@ function performanceEntries(score: TrackScore, track: Track): string[] {
851
863
  )}`,
852
864
  );
853
865
  }
866
+ if (track.guitar) {
867
+ const { tune, capo, hand, ring, position } = track.guitar;
868
+ const fields: [string, string][] = [];
869
+ if (tune !== undefined)
870
+ fields.push([
871
+ "tune",
872
+ typeof tune === "string" ? str(tune) : `[${tune.map(num).join(", ")}]`,
873
+ ]);
874
+ if (capo !== undefined) fields.push(["capo", num(capo)]);
875
+ if (hand !== undefined) fields.push(["hand", num(hand)]);
876
+ if (ring !== undefined) fields.push(["ring", num(ring)]);
877
+ if (position !== undefined) fields.push(["position", num(position)]);
878
+ entries.push(`guitar: ${obj(fields, INDENT, "guitar: ".length, 1)}`);
879
+ }
854
880
  return entries;
855
881
  }
856
882
 
@@ -1000,6 +1026,7 @@ function printModal(settings: TrackModal, indent: string): string {
1000
1026
  if (value === undefined) continue;
1001
1027
  params.push([key, typeof value === "number" ? num(value) : str(value)]);
1002
1028
  }
1029
+ if (settings.pair !== undefined) params.push(["pair", str(settings.pair)]);
1003
1030
  const preset = settings.preset;
1004
1031
  if (params.length === 0 && preset && preset !== "marimba") return str(preset);
1005
1032
  const head = preset ? str(preset) : "";
@@ -1012,6 +1039,37 @@ function printModal(settings: TrackModal, indent: string): string {
1012
1039
  return `modal(${lead}{\n${params.map(([k, v]) => `${inner}${k}: ${v},`).join("\n")}\n${indent}})`;
1013
1040
  }
1014
1041
 
1042
+ /**
1043
+ * A wind track's instrument: the preset word alone (`"flute"`) when there
1044
+ * are no overrides, else `wind("sax", { breath: 0.8 })` with keys in
1045
+ * `WIND_PARAMS` order. Every wind preset word is also an instrument word.
1046
+ */
1047
+ function printWind(settings: TrackWind, indent: string): string {
1048
+ const params: [string, string][] = [];
1049
+ for (const key of Object.keys(WIND_PARAMS)) {
1050
+ const value = (settings as Record<string, unknown>)[key];
1051
+ if (value === undefined) continue;
1052
+ params.push([
1053
+ key,
1054
+ typeof value === "number"
1055
+ ? num(value)
1056
+ : typeof value === "boolean"
1057
+ ? String(value)
1058
+ : str(String(value)),
1059
+ ]);
1060
+ }
1061
+ const preset = settings.preset;
1062
+ if (params.length === 0 && preset) return str(preset);
1063
+ const head = preset ? str(preset) : "";
1064
+ if (params.length === 0) return `wind(${head})`;
1065
+ const lead = head ? `${head}, ` : "";
1066
+ const inline = `wind(${lead}{ ${params.map(([k, v]) => `${k}: ${v}`).join(", ")} })`;
1067
+ if (indent.length + "instrument: ".length + inline.length + 1 <= WIDTH)
1068
+ return inline;
1069
+ const inner = indent + INDENT;
1070
+ return `wind(${lead}{\n${params.map(([k, v]) => `${inner}${k}: ${v},`).join("\n")}\n${indent}})`;
1071
+ }
1072
+
1015
1073
  /** `wavetable("basic", { wt: 0.5 })`; pack tables keep their pin. */
1016
1074
  function printWavetable(settings: TrackWavetable, indent: string): string {
1017
1075
  const src = settings.table.src;
@@ -1071,6 +1129,23 @@ function printSample(ref: SampleRef, indent: string, prefix: number): string {
1071
1129
  if (ref.bpm !== undefined) entries.push(["bpm", num(ref.bpm)]);
1072
1130
  if (ref.fitmode !== undefined) entries.push(["fitmode", str(ref.fitmode)]);
1073
1131
  if (ref.len !== undefined) entries.push(["len", num(ref.len)]);
1132
+ if (ref.shift !== undefined) entries.push(["shift", num(ref.shift)]);
1133
+ if (ref.formant !== undefined) entries.push(["formant", num(ref.formant)]);
1134
+ if (ref.fadeInTime !== undefined)
1135
+ entries.push(["fadeInTime", num(ref.fadeInTime)]);
1136
+ if (ref.fadeTime !== undefined) entries.push(["fadeTime", num(ref.fadeTime)]);
1137
+ if (ref.from !== undefined) {
1138
+ const from = ref.from;
1139
+ const parts: [string, string][] = [["source", str(from.source)]];
1140
+ if (from.section !== undefined) parts.push(["section", str(from.section)]);
1141
+ if (from.bars !== undefined)
1142
+ parts.push(["bars", `[${num(from.bars[0])}, ${num(from.bars[1])}]`]);
1143
+ parts.push(["score", str(from.score)]);
1144
+ entries.push(["from", obj(parts, indent + INDENT, "from: ".length, 1)]);
1145
+ }
1146
+ if (ref.vel !== undefined)
1147
+ entries.push(["vel", `[${num(ref.vel[0])}, ${num(ref.vel[1])}]`]);
1148
+ if (ref.rr !== undefined) entries.push(["rr", str(ref.rr)]);
1074
1149
  if (entries.length === 1) return str(ref.src);
1075
1150
  return obj(entries, indent, prefix, 1);
1076
1151
  }