@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
@@ -167,6 +167,8 @@ export type TrackGlide = Readonly<{ time: number; mode: GlideMode }>;
167
167
  export const DEFAULT_GLIDE_SECONDS = 0.06;
168
168
 
169
169
  export const PEDAL_STATES = ["down", "half", "up"] as const;
170
+ /** Sostenuto has no half position (0.6.1). */
171
+ export const SOSTENUTO_STATES = ["down", "up"] as const;
170
172
  export type PedalState = (typeof PEDAL_STATES)[number];
171
173
 
172
174
  /** A sustain pedal (MIDI CC64) change at a tick. */
@@ -386,16 +388,18 @@ export function normalizeTrackGlide(value: unknown): TrackGlide | undefined {
386
388
  export function normalizePedal(
387
389
  value: unknown,
388
390
  maxTick: number,
391
+ label = "pedal",
392
+ states: readonly PedalState[] = PEDAL_STATES,
389
393
  ): readonly PedalEvent[] | undefined {
390
394
  if (value === undefined || value === null) return undefined;
391
395
  if (!Array.isArray(value) || value.length > EXPRESSION_LIMITS.maxPedalEvents)
392
396
  throw new ExpressionValidationError(
393
- `pedal must be an array of at most ${EXPRESSION_LIMITS.maxPedalEvents} events`,
397
+ `${label} must be an array of at most ${EXPRESSION_LIMITS.maxPedalEvents} events`,
394
398
  );
395
399
  const byTick = new Map<number, PedalEvent>();
396
400
  for (const item of value as unknown[]) {
397
- const event = record(item, "pedal event");
398
- onlyKeys(event, ["tick", "state"], "pedal event");
401
+ const event = record(item, `${label} event`);
402
+ onlyKeys(event, ["tick", "state"], `${label} event`);
399
403
  const tick = event.tick;
400
404
  if (
401
405
  typeof tick !== "number" ||
@@ -404,11 +408,11 @@ export function normalizePedal(
404
408
  tick > maxTick
405
409
  )
406
410
  throw new ExpressionValidationError(
407
- `pedal tick must be an integer 0..${maxTick}`,
411
+ `${label} tick must be an integer 0..${maxTick}`,
408
412
  );
409
- if (!PEDAL_STATES.includes(event.state as PedalState))
413
+ if (!states.includes(event.state as PedalState))
410
414
  throw new ExpressionValidationError(
411
- `pedal state must be one of ${PEDAL_STATES.join(", ")}`,
415
+ `${label} state must be one of ${states.join(", ")}`,
412
416
  );
413
417
  // The last event at a tick wins.
414
418
  byTick.set(tick, Object.freeze({ tick, state: event.state as PedalState }));
@@ -551,6 +555,80 @@ export function pedalStateAt(
551
555
  return state;
552
556
  }
553
557
 
558
+ /** A stretch of song ticks `[from, to)` placed at `offset` in a slice. */
559
+ export type PedalPiece = Readonly<{ from: number; to: number; offset: number }>;
560
+
561
+ /**
562
+ * Pedal events for a slice made of `pieces` (a render window, a section
563
+ * pass, an audition region): the state in force at each piece's start is
564
+ * restated at its offset (`carry`, for sustain and una corda; sostenuto
565
+ * passes false, since a pedal already down latches no key struck later),
566
+ * events inside the piece move by `offset - from`, and a pedal still down
567
+ * at the piece's end is lifted there. Undefined when nothing is left.
568
+ */
569
+ export function slicePedal(
570
+ events: readonly PedalEvent[] | undefined,
571
+ pieces: readonly PedalPiece[],
572
+ carry = true,
573
+ ): readonly PedalEvent[] | undefined {
574
+ if (!events || events.length === 0) return events;
575
+ const byTick = new Map<number, PedalEvent>();
576
+ const put = (tick: number, state: PedalState) =>
577
+ byTick.set(tick, Object.freeze({ tick, state }));
578
+ for (const { from, to, offset } of pieces) {
579
+ let state: PedalState = "up";
580
+ if (carry)
581
+ for (const event of events) {
582
+ if (event.tick >= from) break;
583
+ state = event.state;
584
+ }
585
+ // Restate up too: the previous piece may have ended with it down.
586
+ if (state !== "up" || byTick.size > 0) put(offset, state);
587
+ for (const event of events) {
588
+ if (event.tick < from || event.tick >= to) continue;
589
+ put(event.tick - from + offset, event.state);
590
+ state = event.state;
591
+ }
592
+ if (state !== "up" && Number.isFinite(to)) put(offset + to - from, "up");
593
+ }
594
+ const out = [...byTick.values()].sort((a, b) => a.tick - b.tick);
595
+ // Drop events that restate the state already in force.
596
+ const kept: PedalEvent[] = [];
597
+ let last: PedalState = "up";
598
+ for (const event of out)
599
+ if (event.state !== last) {
600
+ kept.push(event);
601
+ last = event.state;
602
+ }
603
+ return kept.length > 0 ? Object.freeze(kept) : undefined;
604
+ }
605
+
606
+ /** The track with its three pedal lanes cut to `pieces` (see slicePedal). */
607
+ export function slicePedals<T extends PedalTrack>(
608
+ track: T,
609
+ pieces: readonly PedalPiece[],
610
+ ): T {
611
+ if (!track.pedal && !track.softPedal && !track.sostenuto) return track;
612
+ const out: Record<string, unknown> = { ...track };
613
+ for (const [field, carry] of [
614
+ ["pedal", true],
615
+ ["softPedal", true],
616
+ ["sostenuto", false],
617
+ ] as const) {
618
+ if (!track[field]) continue;
619
+ const next = slicePedal(track[field], pieces, carry);
620
+ if (next) out[field] = next;
621
+ else delete out[field];
622
+ }
623
+ return out as T;
624
+ }
625
+
626
+ type PedalTrack = Readonly<{
627
+ pedal?: readonly PedalEvent[];
628
+ softPedal?: readonly PedalEvent[];
629
+ sostenuto?: readonly PedalEvent[];
630
+ }>;
631
+
554
632
  // ---------------------------------------------------------------------------
555
633
  // Performance
556
634
 
@@ -674,6 +752,7 @@ export function hasTrackPerformance(track: Track | undefined): boolean {
674
752
  track !== undefined &&
675
753
  (track.glide !== undefined ||
676
754
  track.pedal !== undefined ||
755
+ track.sostenuto !== undefined ||
677
756
  track.velocityCurve !== undefined ||
678
757
  track.humanize !== undefined)
679
758
  );
@@ -869,6 +948,64 @@ export function performNotes(
869
948
  };
870
949
  });
871
950
  }
951
+ // 3b. Sostenuto (0.6.1): a key held (written length after articulation)
952
+ // when the sostenuto goes down keeps its damper up until the sostenuto
953
+ // lifts; keys struck later damp at their own release (or the sustain
954
+ // pedal's). The rod keeps a caught damper raised, so the same pitch
955
+ // struck again while that sostenuto is down rings to the lift too; the
956
+ // earlier instance stops at the restrike so voices do not pile up. A key
957
+ // still held through a lift and a new press is caught again.
958
+ const sostenuto = track?.sostenuto;
959
+ if (sostenuto) {
960
+ const spans: { down: number; lift: number; pitches: Set<number> }[] = [];
961
+ let open: number | undefined;
962
+ for (const event of sostenuto) {
963
+ if (event.state === "down" && open === undefined) open = event.tick;
964
+ else if (event.state === "up" && open !== undefined) {
965
+ spans.push({ down: open, lift: event.tick, pitches: new Set() });
966
+ open = undefined;
967
+ }
968
+ }
969
+ if (open !== undefined)
970
+ spans.push({ down: open, lift: timing.endTick, pitches: new Set() });
971
+ const caughtBy = working.map((item) => {
972
+ const keyUp = item.start + item.bendLength;
973
+ return spans.filter((span) => {
974
+ const held = item.start <= span.down && span.down < keyUp;
975
+ if (held) span.pitches.add(item.note.pitch);
976
+ return held;
977
+ });
978
+ });
979
+ working = working.map((item, index) => {
980
+ const keyUp = item.start + item.bendLength;
981
+ let lift = -Infinity;
982
+ for (const span of caughtBy[index]!) lift = Math.max(lift, span.lift);
983
+ for (const span of spans)
984
+ if (
985
+ span.pitches.has(item.note.pitch) &&
986
+ span.down < item.start &&
987
+ item.start < span.lift
988
+ )
989
+ lift = Math.max(lift, span.lift);
990
+ if (lift === -Infinity) return item;
991
+ const current = item.start + item.duration;
992
+ let end = Math.max(current, Math.min(lift, timing.endTick));
993
+ if (end <= current) return item;
994
+ const restrike = working.find(
995
+ (other) =>
996
+ other !== item &&
997
+ other.note.pitch === item.note.pitch &&
998
+ other.start > item.start &&
999
+ other.start < end,
1000
+ );
1001
+ if (restrike) end = Math.max(current, restrike.start);
1002
+ return {
1003
+ ...item,
1004
+ duration: end - item.start,
1005
+ pedalled: end > keyUp || item.pedalled === true,
1006
+ };
1007
+ });
1008
+ }
872
1009
  // 4. Glide and monophony (on the written timing).
873
1010
  let performed = glideAndMono(
874
1011
  track,
package/core/fx.ts CHANGED
@@ -12,10 +12,11 @@
12
12
  * under `track.fx.<name>` with every parameter stored once enabled.
13
13
  */
14
14
 
15
- import { MODAL_LANE_PARAMS } from "./resonators.ts";
15
+ import { MODAL_LANE_PARAMS, WIND_LANE_PARAMS } from "./resonators.ts";
16
16
  import { SYNTH_LANE_PARAMS } from "./synth.ts";
17
17
  import { STRING_LANE_PARAMS } from "./strings.ts";
18
18
  import { KEYS_LANE_PARAMS } from "./keys.ts";
19
+ import { GRANULAR_LANE_PARAMS } from "./granular.ts";
19
20
  import {
20
21
  FxValidationError,
21
22
  normalizeParam,
@@ -55,9 +56,16 @@ export const FX_CHAIN = Object.freeze([
55
56
  "stomp",
56
57
  "head",
57
58
  "cab",
59
+ // 0.6.1 shoegaze (src/audio/effects/gaze.ts): a held tremolo arm, the
60
+ // dominant note's feedback bloom, a volume swell; double (mono in, stereo
61
+ // out) runs where the chain turns stereo.
62
+ "wobble",
63
+ "bloom",
64
+ "swell",
58
65
  "tremolo",
59
66
  "compressor",
60
67
  "pan",
68
+ "double",
61
69
  "phaser",
62
70
  "chorus",
63
71
  "leslie",
@@ -495,6 +503,135 @@ export const FX_SPECS = Object.freeze({
495
503
  mix: mix(1),
496
504
  },
497
505
  },
506
+ wobble: {
507
+ label: "wobble",
508
+ doc: "held tremolo arm: slow seeded pitch wow and flutter of the whole track",
509
+ simple: ["depth", "rate", "drift"],
510
+ strudel: "(no Strudel equivalent; dawg shoegaze)",
511
+ params: {
512
+ depth: {
513
+ kind: "number",
514
+ min: 0,
515
+ max: 100,
516
+ default: 20,
517
+ step: 1,
518
+ unit: "c",
519
+ automate: true,
520
+ doc: "peak pitch deviation in cents",
521
+ },
522
+ rate: {
523
+ kind: "number",
524
+ min: 0.05,
525
+ max: 8,
526
+ default: 0.5,
527
+ step: 0.05,
528
+ unit: "Hz",
529
+ doc: "wobble rate",
530
+ },
531
+ drift: {
532
+ kind: "number",
533
+ min: 0,
534
+ max: 1,
535
+ default: 0.3,
536
+ step: 0.05,
537
+ doc: "0 periodic .. 1 wandering (seeded, so every render matches)",
538
+ },
539
+ },
540
+ },
541
+ bloom: {
542
+ label: "bloom",
543
+ doc: "feedback bloom: the top held note grows a singing harmonic",
544
+ simple: ["amount", "harm", "delay"],
545
+ strudel: "(no Strudel equivalent; dawg shoegaze)",
546
+ params: {
547
+ amount: {
548
+ kind: "number",
549
+ min: 0,
550
+ max: 1,
551
+ default: 0.5,
552
+ step: 0.05,
553
+ doc: "level of the feedback partial",
554
+ },
555
+ harm: {
556
+ kind: "number",
557
+ min: 1,
558
+ max: 4,
559
+ default: 2,
560
+ step: 1,
561
+ doc: "harmonic that feeds back: 1 the note, 2 its octave, 3 the fifth above",
562
+ },
563
+ delay: {
564
+ kind: "number",
565
+ min: 0,
566
+ max: 4,
567
+ default: 0.6,
568
+ step: 0.05,
569
+ unit: "s",
570
+ doc: "how long a note is held before it starts to feed back",
571
+ },
572
+ time: {
573
+ kind: "number",
574
+ min: 0.05,
575
+ max: 4,
576
+ default: 1,
577
+ step: 0.05,
578
+ unit: "s",
579
+ doc: "how fast the feedback grows",
580
+ },
581
+ },
582
+ },
583
+ swell: {
584
+ label: "swell",
585
+ doc: "volume swell: each strum fades in, no pick attack",
586
+ simple: ["time", "mix"],
587
+ strudel: "(no Strudel equivalent; dawg shoegaze)",
588
+ params: {
589
+ time: {
590
+ kind: "number",
591
+ min: 0.01,
592
+ max: 4,
593
+ default: 0.4,
594
+ step: 0.01,
595
+ unit: "s",
596
+ doc: "rise time after each onset",
597
+ },
598
+ mix: mix(1),
599
+ },
600
+ },
601
+ double: {
602
+ label: "double",
603
+ doc: "automatic double tracking: a seeded second take, spread left and right",
604
+ simple: ["time", "drift", "width"],
605
+ strudel: "(no Strudel equivalent; dawg shoegaze)",
606
+ params: {
607
+ time: {
608
+ kind: "number",
609
+ min: 5,
610
+ max: 60,
611
+ default: 22,
612
+ step: 1,
613
+ unit: "ms",
614
+ doc: "how late the second take plays",
615
+ },
616
+ drift: {
617
+ kind: "number",
618
+ min: 0,
619
+ max: 10,
620
+ default: 3,
621
+ step: 0.5,
622
+ unit: "ms",
623
+ doc: "how far the second take's timing wanders",
624
+ },
625
+ width: {
626
+ kind: "number",
627
+ min: 0,
628
+ max: 1,
629
+ default: 0.6,
630
+ step: 0.05,
631
+ doc: "0 centred .. 1 the two takes hard left and right",
632
+ },
633
+ },
634
+ },
498
635
  tremolo: {
499
636
  label: "tremolo",
500
637
  doc: "volume modulation",
@@ -1006,6 +1143,14 @@ export const CORE_EFFECTS: readonly EffectName[] = Object.freeze([
1006
1143
  "reverb",
1007
1144
  ] as const);
1008
1145
 
1146
+ /** The 0.6.1 shoegaze stages (Effects > Shoegaze), in chain order. */
1147
+ export const SHOEGAZE_EFFECTS: readonly EffectName[] = Object.freeze([
1148
+ "wobble",
1149
+ "bloom",
1150
+ "swell",
1151
+ "double",
1152
+ ] as const);
1153
+
1009
1154
  export function isEffectName(value: unknown): value is EffectName {
1010
1155
  return (
1011
1156
  typeof value === "string" &&
@@ -1076,12 +1221,20 @@ export function normalizeFx(input: unknown): TrackFx | undefined {
1076
1221
 
1077
1222
  /** Automation lane name of an `fx` parameter, e.g. `distort-drive`. */
1078
1223
  export type FxLane =
1079
- `${FxName | "reverb" | "synth" | "string" | "keys" | "modal"}-${string}`;
1224
+ `${FxName | "reverb" | "synth" | "string" | "keys" | "modal" | "grain" | "wind"}-${string}`;
1080
1225
 
1081
1226
  /** Every automatable `fx` parameter as `{ lane, effect, param, spec }`. */
1082
1227
  export const FX_LANES: readonly Readonly<{
1083
1228
  lane: FxLane;
1084
- effect: FxName | "reverb" | "synth" | "string" | "keys" | "modal";
1229
+ effect:
1230
+ | FxName
1231
+ | "reverb"
1232
+ | "synth"
1233
+ | "string"
1234
+ | "keys"
1235
+ | "modal"
1236
+ | "grain"
1237
+ | "wind";
1085
1238
  param: string;
1086
1239
  spec: NumberParam;
1087
1240
  }>[] = Object.freeze([
@@ -1137,6 +1290,25 @@ export const FX_LANES: readonly Readonly<{
1137
1290
  spec,
1138
1291
  }),
1139
1292
  ),
1293
+ // Granular parameters (core/granular.ts, 0.6.1), read every 32 frames;
1294
+ // grain-scoped ones are latched at each grain's onset.
1295
+ ...GRANULAR_LANE_PARAMS.map(({ param, spec }) =>
1296
+ Object.freeze({
1297
+ lane: `grain-${param}` as FxLane,
1298
+ effect: "grain" as const,
1299
+ param,
1300
+ spec,
1301
+ }),
1302
+ ),
1303
+ // Wind parameters (core/winds.ts), read every 32-sample control tick.
1304
+ ...WIND_LANE_PARAMS.map(({ param, spec }) =>
1305
+ Object.freeze({
1306
+ lane: `wind-${param}` as FxLane,
1307
+ effect: "wind" as const,
1308
+ param,
1309
+ spec,
1310
+ }),
1311
+ ),
1140
1312
  ]);
1141
1313
 
1142
1314
  /**
@@ -1222,6 +1394,24 @@ export const FX_PRESETS: Readonly<
1222
1394
  "1x15": { type: "1x15" },
1223
1395
  di: { type: "di" },
1224
1396
  },
1397
+ wobble: {
1398
+ glide: { depth: 35, rate: 0.4, drift: 0.4 },
1399
+ tape: { depth: 12, rate: 0.8, drift: 0.6 },
1400
+ seasick: { depth: 60, rate: 0.25, drift: 0.2 },
1401
+ },
1402
+ bloom: {
1403
+ octave: { amount: 0.5, harm: 2, delay: 0.6, time: 1 },
1404
+ fifth: { amount: 0.4, harm: 3, delay: 0.8, time: 1.2 },
1405
+ ebow: { amount: 0.8, harm: 1, delay: 0.1, time: 0.6 },
1406
+ },
1407
+ swell: {
1408
+ slow: { time: 0.8 },
1409
+ violin: { time: 0.3 },
1410
+ },
1411
+ double: {
1412
+ tight: { time: 12, drift: 1.5, width: 0.5 },
1413
+ wide: { time: 28, drift: 3, width: 0.7 },
1414
+ },
1225
1415
  tremolo: {
1226
1416
  gentle: { sync: 1, depth: 0.3, shape: "sine" },
1227
1417
  "eighth-chop": { sync: 0.5, depth: 0.9, shape: "square" },
@@ -1390,14 +1580,70 @@ export const RIG_PRESETS: Readonly<
1390
1580
  head: { type: "chime", gain: 3, treble: 7 },
1391
1581
  cab: { type: "2x12", mic: 0.2 },
1392
1582
  compressor: { threshold: -20, ratio: 4, attack: 0.01, release: 0.15 },
1583
+ // 0.6.1: the double-tracked jangle of a twelve-string record. A stored
1584
+ // 0.6.0 jangle (no double) keeps rendering as it was; `rig jangle` again
1585
+ // adds it. The SDK's rig("jangle") keeps its 0.6.0 stages (see
1586
+ // RIG_ADDED), so the printer writes this double out explicitly.
1587
+ double: { time: 12, drift: 1.5, width: 0.5 },
1393
1588
  },
1394
1589
  alt: {
1395
1590
  stomp: { type: "rat", gain: 6, tone: 0.4 },
1396
1591
  head: { type: "crunch", gain: 4 },
1397
1592
  cab: { type: "4x12" },
1398
1593
  },
1594
+ // 0.6.1 shoegaze rigs (src/audio/effects/gaze.ts): fuzz into a held
1595
+ // tremolo arm, a feedback bloom, doubled takes and a long wash.
1596
+ shoegaze: {
1597
+ stomp: { type: "fuzz", gain: 7, tone: 0.45 },
1598
+ head: { type: "chime", gain: 4 },
1599
+ cab: { type: "2x12" },
1600
+ wobble: { depth: 25, rate: 0.4, drift: 0.4 },
1601
+ bloom: { amount: 0.4, harm: 2, delay: 0.6, time: 1 },
1602
+ double: { time: 18, drift: 2.5, width: 0.4 },
1603
+ postgain: { gain: 0.86 },
1604
+ reverb: { mix: 0.35, size: 0.9, fade: 4, predelay: 0.02, dim: 6000 },
1605
+ },
1606
+ glide: {
1607
+ stomp: { type: "face", gain: 5, tone: 0.5 },
1608
+ head: { type: "clean", gain: 4, treble: 5 },
1609
+ cab: { type: "2x12" },
1610
+ wobble: { depth: 35, rate: 0.4, drift: 0.4 },
1611
+ postgain: { gain: 0.8 },
1612
+ reverb: { mix: 0.35, size: 0.7, fade: 2.5, predelay: 0.01, dim: 6000 },
1613
+ },
1614
+ dreampop: {
1615
+ head: { type: "clean", gain: 2, treble: 6 },
1616
+ cab: { type: "1x12" },
1617
+ chorus: { rate: 0.6, depth: 0.3, mix: 0.35 },
1618
+ double: { time: 14, drift: 2, width: 0.4 },
1619
+ postgain: { gain: 1.45 },
1620
+ reverb: { mix: 0.4, size: 0.85, fade: 5, predelay: 0.03, dim: 7000 },
1621
+ },
1622
+ swell: {
1623
+ head: { type: "clean", gain: 3, treble: 5 },
1624
+ cab: { type: "1x12" },
1625
+ swell: { time: 0.5 },
1626
+ postgain: { gain: 1.25 },
1627
+ reverb: { mix: 0.45, size: 0.8, fade: 4, predelay: 0.02, dim: 6500 },
1628
+ },
1629
+ ebow: {
1630
+ stomp: { type: "od", gain: 3, tone: 0.5, level: 3 },
1631
+ head: { type: "lead", gain: 5, mid: 6 },
1632
+ cab: { type: "4x12" },
1633
+ swell: { time: 0.25 },
1634
+ bloom: { amount: 0.8, harm: 1, delay: 0.1, time: 0.6 },
1635
+ },
1399
1636
  });
1400
1637
 
1638
+ /**
1639
+ * Effects a rig preset gained after SDK `rig(name)` was fixed. `rig(name)`
1640
+ * leaves them out, so an older song.ts spreading `...rig("jangle")` keeps
1641
+ * its sound; the printer writes them next to the spread, and a stored
1642
+ * track with or without them still reads as that rig.
1643
+ */
1644
+ export const RIG_ADDED: Readonly<Record<string, readonly FxName[]>> =
1645
+ Object.freeze({ jangle: Object.freeze(["double"] as FxName[]) });
1646
+
1401
1647
  export function isRigPreset(name: string): boolean {
1402
1648
  return Object.prototype.hasOwnProperty.call(RIG_PRESETS, name);
1403
1649
  }
@@ -1476,8 +1722,11 @@ export function rigReverb(name: string): FxValues | undefined {
1476
1722
  export function rigPresetOf(fx: TrackFx | undefined): string | undefined {
1477
1723
  if (!fx || !RIG_STAGES.some((stage) => fx[stage])) return undefined;
1478
1724
  for (const name of Object.keys(RIG_PRESETS)) {
1479
- const want = applyRigPreset(undefined, name) ?? {};
1480
- const keys = new Set([...RIG_STAGES, ...Object.keys(want)]);
1725
+ const want: Partial<Record<FxName, FxValues>> = {
1726
+ ...(applyRigPreset(undefined, name) ?? {}),
1727
+ };
1728
+ for (const added of RIG_ADDED[name] ?? []) delete want[added];
1729
+ const keys = new Set<string>([...RIG_STAGES, ...Object.keys(want)]);
1481
1730
  if (
1482
1731
  [...keys].every(
1483
1732
  (key) =>
@@ -1492,7 +1741,10 @@ export function rigPresetOf(fx: TrackFx | undefined): string | undefined {
1492
1741
 
1493
1742
  /** The effects of `fx` a printed `...rig(name)` stands for. */
1494
1743
  export function rigPresetKeys(name: string): readonly FxName[] {
1495
- return Object.keys(RIG_PRESETS[name] ?? {}).filter(isFxName);
1744
+ const added = RIG_ADDED[name] ?? [];
1745
+ return Object.keys(RIG_PRESETS[name] ?? {}).filter(
1746
+ (key): key is FxName => isFxName(key) && !added.includes(key),
1747
+ );
1496
1748
  }
1497
1749
 
1498
1750
  export function effectPresetNames(effect: EffectName): readonly string[] {
package/core/granular.ts CHANGED
@@ -77,6 +77,14 @@ export type TrackGranular = Readonly<{
77
77
  release?: number;
78
78
  veltone?: number;
79
79
  gain?: number;
80
+ /** Optional (0.6.1): grain rate as a note value on the tempo map. */
81
+ sync?: GrainSync;
82
+ /** Optional (0.6.1): snap grain pitches to the scale or chord. */
83
+ quant?: GrainQuant;
84
+ /** Optional (0.6.1): one voice; legato notes retarget it. */
85
+ mono?: boolean;
86
+ /** Optional (0.6.1): the sustain pedal freezes the head. */
87
+ pedal?: boolean;
80
88
  /**
81
89
  * The instrument `grain off` returns to (set when granular turns on;
82
90
  * absent: a sampler, a wavetable, or the source's synth).
@@ -102,6 +110,30 @@ const n = (
102
110
  ...extra,
103
111
  });
104
112
 
113
+ export const GRAIN_SYNC_VALUES = Object.freeze([
114
+ "off",
115
+ "1/64",
116
+ "1/32",
117
+ "1/16t",
118
+ "1/16",
119
+ "1/16d",
120
+ "1/8t",
121
+ "1/8",
122
+ "1/8d",
123
+ "1/4t",
124
+ "1/4",
125
+ "1/4d",
126
+ "1/2",
127
+ "1/1",
128
+ ] as const);
129
+ export type GrainSync = (typeof GRAIN_SYNC_VALUES)[number];
130
+ export const GRAIN_QUANT_VALUES = Object.freeze([
131
+ "off",
132
+ "scale",
133
+ "chord",
134
+ ] as const);
135
+ export type GrainQuant = (typeof GRAIN_QUANT_VALUES)[number];
136
+
105
137
  /**
106
138
  * The granular parameter table: one row per stored number, enum or flag,
107
139
  * in stored key order. It drives validation, the printer, the menu, the
@@ -223,8 +255,63 @@ export const GRANULAR_PARAMS: Readonly<Record<string, ParamSpec>> =
223
255
  }),
224
256
  veltone: n(0, 1, 0, 0.05, "soft notes are darker"),
225
257
  gain: n(0, 4, 1, 0.05, "voice level"),
258
+ // 0.6.1 grainplay (appended; absent keeps 0.6.0 behaviour).
259
+ sync: Object.freeze({
260
+ kind: "enum",
261
+ values: GRAIN_SYNC_VALUES,
262
+ default: "off",
263
+ doc: "grain rate as a note value on the tempo map (off: grain/overlap)",
264
+ }),
265
+ quant: Object.freeze({
266
+ kind: "enum",
267
+ values: GRAIN_QUANT_VALUES,
268
+ default: "off",
269
+ doc: "snap grain pitches to the song scale or the sounding chord",
270
+ }),
271
+ mono: Object.freeze({
272
+ kind: "boolean",
273
+ default: false,
274
+ doc: "one voice: legato notes retarget its pitch, the cloud keeps going",
275
+ }),
276
+ pedal: Object.freeze({
277
+ kind: "boolean",
278
+ default: false,
279
+ doc: "the sustain pedal freezes the head while it is down",
280
+ }),
226
281
  });
227
282
 
283
+ /** Lanes `grain-<param>` (0.6.1): every number row marked `automate`. */
284
+ export const GRANULAR_LANE_PARAMS: readonly Readonly<{
285
+ param: string;
286
+ spec: Extract<ParamSpec, { kind: "number" }>;
287
+ }>[] = Object.freeze(
288
+ Object.entries(GRANULAR_PARAMS)
289
+ .filter(
290
+ (entry): entry is [string, Extract<ParamSpec, { kind: "number" }>] =>
291
+ entry[1].kind === "number" && entry[1].automate === true,
292
+ )
293
+ .map(([param, spec]) => Object.freeze({ param, spec })),
294
+ );
295
+
296
+ /** Note values `sync` takes, with their length in beats. */
297
+ export const GRAIN_SYNC_BEATS: Readonly<Record<string, number>> = Object.freeze(
298
+ {
299
+ "1/64": 1 / 16,
300
+ "1/32": 1 / 8,
301
+ "1/16t": 1 / 6,
302
+ "1/16": 1 / 4,
303
+ "1/16d": 3 / 8,
304
+ "1/8t": 1 / 3,
305
+ "1/8": 1 / 2,
306
+ "1/8d": 3 / 4,
307
+ "1/4t": 2 / 3,
308
+ "1/4": 1,
309
+ "1/4d": 3 / 2,
310
+ "1/2": 2,
311
+ "1/1": 4,
312
+ },
313
+ );
314
+
228
315
  export type GranularParamName = keyof typeof GRANULAR_PARAMS & string;
229
316
 
230
317
  /** Resolved settings the voice reads (presets and defaults applied). */
@@ -256,6 +343,10 @@ export type GranularSettings = Readonly<{
256
343
  release: number;
257
344
  veltone: number;
258
345
  gain: number;
346
+ sync: GrainSync;
347
+ quant: GrainQuant;
348
+ mono: boolean;
349
+ pedal: boolean;
259
350
  }>;
260
351
 
261
352
  /**