@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
@@ -13,9 +13,12 @@
13
13
  * Randomness is counter-based (`unit(seed, grain, slot)`), so changing one
14
14
  * parameter does not reshuffle the others' draws.
15
15
  */
16
- import type { PerformedNote } from "../../core/expression.ts";
16
+ import { parseKey, scaleOf } from "../../core/chords.ts";
17
+ import { pedalStateAt, type PerformedNote } from "../../core/expression.ts";
17
18
  import {
18
19
  DEFAULT_GRANULAR_SOURCE,
20
+ GRAIN_SYNC_BEATS,
21
+ GRANULAR_LANE_PARAMS,
19
22
  GRANULAR_SOURCE_VOICE,
20
23
  SYNTH_SOURCE_SECONDS,
21
24
  granularTailSeconds,
@@ -23,7 +26,8 @@ import {
23
26
  resolveGranular,
24
27
  type GranularSettings,
25
28
  } from "../../core/granular.ts";
26
- import type { SampleRef, Track } from "../../core/score.ts";
29
+ import type { FxLane } from "../../core/fx.ts";
30
+ import type { SampleRef, Track, TrackScore } from "../../core/score.ts";
27
31
  import { SYNTH_PRESETS, type TrackSynth } from "../../core/synth.ts";
28
32
  import { noteHz } from "../../core/tuning.ts";
29
33
  import {
@@ -44,6 +48,8 @@ import { warpedSpan } from "./warp.ts";
44
48
  export const CONTROL_FRAMES = 32;
45
49
  /** Grains sounding at once per voice; later grains are skipped by index. */
46
50
  export const MAX_GRAINS = 64;
51
+ /** Most grains overlapping at once (synced or not). */
52
+ const MAX_OVERLAP = 32;
47
53
  /**
48
54
  * Voices per track; the oldest released voice is stolen first, then the
49
55
  * oldest held one, with a 5 ms fade.
@@ -77,6 +83,19 @@ export type GranularVoiceInit = Readonly<{
77
83
  /** Half pedal: the level fades from `from` seconds with `tau`. */
78
84
  damp?: Readonly<{ from: number; tau: number }>;
79
85
  seed: number;
86
+ /**
87
+ * `grain-<param>` lanes (0.6.1): each value at a voice frame, read every
88
+ * 32 frames; grain-scoped ones latch at each grain's onset.
89
+ */
90
+ lanes?: readonly Readonly<{ name: string; at: (frame: number) => number }>[];
91
+ /** `sync` (0.6.1): the voice frame of grid point `k` on the tempo map. */
92
+ grid?: (k: number) => number;
93
+ /** The synced period at grain `k` (default `grid(k + 1) - grid(k)`). */
94
+ gridPeriod?: (k: number) => number;
95
+ /** `quant` (0.6.1): a grain's semitone offset snapped to allowed pitches. */
96
+ quant?: (semis: number, frame: number) => number;
97
+ /** `pedal` (0.6.1): true while the sustain pedal holds the head. */
98
+ held?: (frame: number) => boolean;
80
99
  }>;
81
100
 
82
101
  export type GranularVoice = {
@@ -92,7 +111,12 @@ export type GranularVoice = {
92
111
 
93
112
  /** A streaming grain voice. */
94
113
  export function granularVoice(init: GranularVoiceInit): GranularVoice {
95
- const { source, sr, settings: p, seed } = init;
114
+ const { source, sr, seed } = init;
115
+ const lanes = init.lanes ?? [];
116
+ // Automated rows read a mutable copy; without lanes `p` is the settings.
117
+ const p: { -readonly [K in keyof GranularSettings]: GranularSettings[K] } =
118
+ lanes.length > 0 ? { ...init.settings } : init.settings;
119
+ const live = p as unknown as Record<string, number>;
96
120
  const data = source.data;
97
121
  const srcRate = source.rate;
98
122
  const n0 = Math.max(1, data.length);
@@ -108,22 +132,45 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
108
132
  const spawnEnd = init.gateFrames + releaseFrames;
109
133
  const win = grainWindow(p.window);
110
134
  const table = win.table;
111
- const grainSec = Math.max(0.005, Math.min(2, p.grain));
112
- const overlap = Math.max(0.05, Math.min(32, p.overlap));
113
- const period = (grainSec * sr) / overlap;
114
- const len = Math.max(16, Math.min(maxGrainFrames, Math.round(grainSec * sr)));
115
- const winGain = Math.min(
135
+ let grainSec = Math.max(0.005, Math.min(2, p.grain));
136
+ let overlap = Math.max(0.05, Math.min(MAX_OVERLAP, p.overlap));
137
+ let period = (grainSec * sr) / overlap;
138
+ let len = Math.max(16, Math.min(maxGrainFrames, Math.round(grainSec * sr)));
139
+ let winGain = Math.min(
116
140
  1,
117
141
  1 / Math.sqrt(Math.max(1e-9, overlap * win.meanSq)),
118
142
  );
119
143
  const frameStep = srcRate / sr;
120
- const scanStep = p.freeze ? 0 : p.scan * frameStep;
144
+ let scanStep = p.freeze ? 0 : p.scan * frameStep;
145
+ const grid = init.grid;
146
+ const gridPeriod =
147
+ init.gridPeriod ?? ((at: number) => grid!(at + 1) - grid!(at));
148
+ /** Grain shape from the current grain, overlap and (synced) period. */
149
+ function shape(): void {
150
+ grainSec = Math.max(0.005, Math.min(2, p.grain));
151
+ if (grid) {
152
+ period = Math.max(1, gridPeriod(k));
153
+ overlap = Math.max(0.05, (grainSec * sr) / period);
154
+ // The unsynced cap: a long grain on a fast grid shortens to 32
155
+ // overlapping grains, so the level matches the grains that sound.
156
+ if (overlap > MAX_OVERLAP) {
157
+ overlap = MAX_OVERLAP;
158
+ grainSec = (MAX_OVERLAP * period) / sr;
159
+ }
160
+ } else {
161
+ overlap = Math.max(0.05, Math.min(MAX_OVERLAP, p.overlap));
162
+ period = (grainSec * sr) / overlap;
163
+ }
164
+ len = Math.max(16, Math.min(maxGrainFrames, Math.round(grainSec * sr)));
165
+ winGain = Math.min(1, 1 / Math.sqrt(Math.max(1e-9, overlap * win.meanSq)));
166
+ }
121
167
  const grains: Grain[] = [];
122
168
  const pool: Grain[] = [];
123
169
  let t = 0;
124
170
  let scanned = 0;
125
- let nextGrid = 0;
126
171
  let k = 0;
172
+ let nextGrid = grid ? grid(0) : 0;
173
+ if (grid) shape();
127
174
  let latch = 0;
128
175
  let latched = 0;
129
176
  let lp = 0;
@@ -144,9 +191,9 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
144
191
  const amp = init.velocity * p.gain;
145
192
  const blockL = new Float64Array(CONTROL_FRAMES);
146
193
  const blockR = new Float64Array(CONTROL_FRAMES);
147
- const driftDepth = 0.5 * p.drift * region;
148
194
 
149
195
  function driftAt(sec: number): number {
196
+ const driftDepth = 0.5 * p.drift * region;
150
197
  if (driftDepth === 0) return 0;
151
198
  const x = sec * p.drate;
152
199
  const i = Math.floor(x);
@@ -158,6 +205,7 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
158
205
  }
159
206
 
160
207
  function spawn(): void {
208
+ if (grid) shape();
161
209
  const sec = nextGrid / sr;
162
210
  // Beat repeat: a grid step may latch the head for `hold` steps.
163
211
  if (latch > 0) latch -= 1;
@@ -167,10 +215,11 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
167
215
  }
168
216
  const head = latched;
169
217
  const onset = nextGrid + p.jitter * unit(seed, k, 0) * period;
170
- const semis =
218
+ const raw =
171
219
  p.pitch +
172
220
  p.detune * (unit(seed, k, 2) - 0.5) +
173
221
  (unit(seed, k, 5) < p.shimmer ? p.shimint : 0);
222
+ const semis = init.quant ? init.quant(raw, nextGrid) : raw;
174
223
  const step0 = init.baseRate * 2 ** (semis / 12) * frameStep;
175
224
  let center =
176
225
  begin +
@@ -193,7 +242,7 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
193
242
  grains.push(g);
194
243
  }
195
244
  k += 1;
196
- nextGrid += period;
245
+ nextGrid = grid ? grid(k) : nextGrid + period;
197
246
  }
198
247
 
199
248
  function envelope(i: number): number {
@@ -265,6 +314,7 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
265
314
  }
266
315
  }
267
316
 
317
+ let pedalDown = false;
268
318
  return {
269
319
  totalFrames: total,
270
320
  process(outL, outR, offset, frames) {
@@ -276,13 +326,21 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
276
326
  frames - done,
277
327
  total - t,
278
328
  );
329
+ // Lanes are read on 32-frame control boundaries only, so any block
330
+ // split renders the same samples.
331
+ if (init.held && t % CONTROL_FRAMES === 0) pedalDown = init.held(t);
332
+ if (lanes.length > 0 && t % CONTROL_FRAMES === 0) {
333
+ for (const lane of lanes) live[lane.name] = lane.at(t);
334
+ if (!grid) shape();
335
+ scanStep = p.freeze ? 0 : p.scan * frameStep;
336
+ }
279
337
  // Grid points of this block: the grid keeps running through the
280
338
  // release, so the release fades a living cloud (spec: release ~ T60).
281
339
  while (nextGrid < t + n && t < spawnEnd) spawn();
282
340
  const cents = init.cents ? init.cents(t / sr) : 0;
283
341
  const centsMul = cents === 0 ? 1 : 2 ** (cents / 1200);
284
342
  renderGrains(t, n, centsMul);
285
- scanned += scanStep * n;
343
+ if (!pedalDown) scanned += scanStep * n;
286
344
  for (let i = 0; i < n; i += 1) {
287
345
  let l = blockL[i]!;
288
346
  let r = blockR[i]!;
@@ -559,9 +617,9 @@ export function renderGranularTrack(
559
617
  end: number;
560
618
  cut: number;
561
619
  };
562
- const plans: Plan[] = notes.map((note, index) => {
620
+ const tail = Math.round(granularTailSeconds(track.granular) * sr);
621
+ let plans: Plan[] = notes.map((note, index) => {
563
622
  const { start, length } = spanOf(note, context);
564
- const tail = Math.round(granularTailSeconds(track.granular) * sr);
565
623
  return {
566
624
  note,
567
625
  seed: seeds[index]!,
@@ -571,6 +629,31 @@ export function renderGranularTrack(
571
629
  cut: Infinity,
572
630
  };
573
631
  });
632
+ // Mono (0.6.1): notes that overlap the one before (legato) join its
633
+ // voice, which retargets pitch at each note's start and keeps its grain
634
+ // stream; a detached note cuts the previous voice's tail.
635
+ const chains = new Map<Plan, Plan[]>();
636
+ if (settings.mono && plans.length > 1) {
637
+ const sorted = [...plans].sort((a, b) => a.start - b.start);
638
+ const heads: Plan[] = [];
639
+ let head: Plan | undefined;
640
+ for (const plan of sorted) {
641
+ if (head && plan.start < head.start + head.length) {
642
+ chains.get(head)!.push(plan);
643
+ head.length = Math.max(
644
+ head.length,
645
+ plan.start + plan.length - head.start,
646
+ );
647
+ head.end = head.start + head.length + tail;
648
+ continue;
649
+ }
650
+ if (head && head.end > plan.start) head.cut = plan.start;
651
+ head = plan;
652
+ chains.set(plan, [plan]);
653
+ heads.push(plan);
654
+ }
655
+ plans = heads;
656
+ }
574
657
  const order = plans
575
658
  .map((_, index) => index)
576
659
  .sort((a, b) => plans[a]!.start - plans[b]!.start || a - b);
@@ -598,11 +681,88 @@ export function renderGranularTrack(
598
681
  const outR = new Float64Array(block);
599
682
  const volume = Math.max(0, Math.min(1, track.volume ?? 1));
600
683
  const lane = track.volumeAutomation ?? [];
684
+ const tickOf = (frame: number): number =>
685
+ context.warp
686
+ ? context.warp.tick(frame)
687
+ : frame / Math.max(1e-9, context.samplesPerTick);
688
+ const frameOf = (tick: number): number =>
689
+ context.warp ? context.warp.sample(tick) : tick * context.samplesPerTick;
690
+ const grainLanes = GRANULAR_LANE_PARAMS.flatMap(({ param }) => {
691
+ const points = track.fxAutomation?.[`grain-${param}` as FxLane];
692
+ return points && points.length > 0 ? [{ param, points }] : [];
693
+ });
694
+ const syncBeats =
695
+ settings.sync === "off" ? undefined : GRAIN_SYNC_BEATS[settings.sync];
696
+ const allowed =
697
+ settings.quant === "off"
698
+ ? undefined
699
+ : quantPitchClasses(context.score, settings.quant, context.quantChord);
700
+ const pedal = settings.pedal ? track.pedal : undefined;
601
701
  for (const plan of plans) {
602
702
  const { note } = plan;
603
703
  if (plan.start >= left.length) continue;
604
704
  const hz = noteHz(note.pitch, note.cents, context.tuning);
605
705
  const performance = note.performance;
706
+ const chain = chains.get(plan);
707
+ const startTick = note.startTick;
708
+ const startFrame = plan.start;
709
+ let cents = performance?.cents;
710
+ if (chain && chain.length > 1) {
711
+ // Retarget: the cents of each chained note's pitch over the first's,
712
+ // from its start, plus that note's own bend/glide/vibrato.
713
+ const steps = chain.map((item) => ({
714
+ from: (item.start - startFrame) / sr,
715
+ cents:
716
+ 1200 *
717
+ Math.log2(
718
+ noteHz(item.note.pitch, item.note.cents, context.tuning) / hz,
719
+ ),
720
+ perf: item.note.performance?.cents,
721
+ offset: (item.start - startFrame) / sr,
722
+ }));
723
+ cents = (t: number) => {
724
+ let at = 0;
725
+ while (at + 1 < steps.length && steps[at + 1]!.from <= t) at += 1;
726
+ const step = steps[at]!;
727
+ return step.cents + (step.perf ? step.perf(t - step.offset) : 0);
728
+ };
729
+ }
730
+ const lanes = grainLanes.map(({ param, points }) => {
731
+ const fallback = settings[param as keyof typeof settings] as number;
732
+ return {
733
+ name: param,
734
+ at: (frame: number) =>
735
+ interpolateAutomation(points, tickOf(startFrame + frame), fallback),
736
+ };
737
+ });
738
+ let grid: ((k: number) => number) | undefined;
739
+ let gridPeriod: ((k: number) => number) | undefined;
740
+ if (syncBeats !== undefined) {
741
+ const stepTicks = syncBeats * context.ticksPerBeat;
742
+ const at = startTick / stepTicks;
743
+ const k0 = Math.round(at);
744
+ if (Math.abs(at - k0) < 1e-9)
745
+ grid = (k: number) => frameOf((k0 + k) * stepTicks) - startFrame;
746
+ else {
747
+ // Off the grid (humanize, swing, a played note): the first grain
748
+ // sounds at note-on, the rest follow the grid from the next point.
749
+ const first = Math.floor(at) + 1;
750
+ const point = (k: number) =>
751
+ frameOf((first + k) * stepTicks) - startFrame;
752
+ grid = (k: number) => (k === 0 ? 0 : point(k - 1));
753
+ gridPeriod = (k: number) =>
754
+ k === 0 ? point(1) - point(0) : point(k) - point(k - 1);
755
+ }
756
+ }
757
+ const quant = allowed
758
+ ? (semis: number, frame: number) =>
759
+ snapSemis(note.pitch, semis, allowed(tickOf(startFrame + frame)))
760
+ : undefined;
761
+ const held =
762
+ pedal && pedal.length > 0
763
+ ? (frame: number) =>
764
+ pedalStateAt(pedal, tickOf(startFrame + frame)) === "down"
765
+ : undefined;
606
766
  const voice = granularVoice({
607
767
  source: resolved.source,
608
768
  sr,
@@ -610,9 +770,14 @@ export function renderGranularTrack(
610
770
  baseRate: hz / rootHz,
611
771
  velocity: Math.max(0, Math.min(1, note.velocity)),
612
772
  gateFrames: plan.length,
613
- ...(performance?.cents ? { cents: performance.cents } : {}),
773
+ ...(cents ? { cents } : {}),
614
774
  ...(performance?.damp ? { damp: performance.damp } : {}),
615
775
  seed: plan.seed,
776
+ ...(lanes.length > 0 ? { lanes } : {}),
777
+ ...(grid ? { grid } : {}),
778
+ ...(gridPeriod ? { gridPeriod } : {}),
779
+ ...(quant ? { quant } : {}),
780
+ ...(held ? { held } : {}),
616
781
  });
617
782
  const stop = Math.min(
618
783
  left.length,
@@ -642,6 +807,161 @@ export function renderGranularTrack(
642
807
  }
643
808
  }
644
809
 
810
+ /**
811
+ * The tracks whose notes make the `quant chord` harmony: pitched and not
812
+ * muted in the song. Granular tracks, kits, one-shot samplers and
813
+ * resampled audio (a ref with `from`, one held note) are left out, so a
814
+ * resampled bus does not add its trigger pitch to every chord. Solo is
815
+ * ignored: soloing a track (or a resample's soloed source) keeps the
816
+ * song's chords.
817
+ */
818
+ function harmonicTrack(track: Track): boolean {
819
+ if (track.muted === true) return false;
820
+ if (
821
+ track.granular !== undefined ||
822
+ track.instrument === "kit" ||
823
+ track.kit !== undefined
824
+ )
825
+ return false;
826
+ if (track.sampler || track.instrument === "sampler") {
827
+ const sampler = track.sampler;
828
+ if (!sampler || sampler.mode !== "keyed") return false;
829
+ if (Object.values(sampler.voices).some((ref) => ref.from !== undefined))
830
+ return false;
831
+ }
832
+ return true;
833
+ }
834
+
835
+ /**
836
+ * Pitch classes sounding on the harmonic tracks, as change points:
837
+ * `sets[i]` sounds from `ticks[i]` to `ticks[i + 1]`.
838
+ */
839
+ export type ChordTimeline = Readonly<{
840
+ ticks: readonly number[];
841
+ sets: readonly (readonly number[])[];
842
+ }>;
843
+
844
+ const timelines = new WeakMap<TrackScore, ChordTimeline>();
845
+
846
+ /** The song's chord timeline (computed once per score object). */
847
+ export function chordTimeline(score: TrackScore): ChordTimeline {
848
+ const known = timelines.get(score);
849
+ if (known) return known;
850
+ const harmonic = new Set(
851
+ score.tracks.filter(harmonicTrack).map((track) => track.id),
852
+ );
853
+ const events = new Map<number, number[]>();
854
+ const add = (tick: number, pc: number, delta: number) => {
855
+ let row = events.get(tick);
856
+ if (!row) events.set(tick, (row = new Array<number>(12).fill(0)));
857
+ row[pc] = row[pc]! + delta;
858
+ };
859
+ for (const note of score.notes) {
860
+ if (!harmonic.has(note.trackId) || note.durationTicks <= 0) continue;
861
+ const pc = ((note.pitch % 12) + 12) % 12;
862
+ add(note.startTick, pc, 1);
863
+ add(note.startTick + note.durationTicks, pc, -1);
864
+ }
865
+ const counts = new Array<number>(12).fill(0);
866
+ const ticks: number[] = [];
867
+ const sets: number[][] = [];
868
+ for (const tick of [...events.keys()].sort((a, b) => a - b)) {
869
+ const row = events.get(tick)!;
870
+ for (let pc = 0; pc < 12; pc += 1) counts[pc] = counts[pc]! + row[pc]!;
871
+ ticks.push(tick);
872
+ sets.push(counts.flatMap((count, pc) => (count > 0 ? [pc] : [])));
873
+ }
874
+ const timeline = Object.freeze({ ticks, sets });
875
+ timelines.set(score, timeline);
876
+ return timeline;
877
+ }
878
+
879
+ /** The classes sounding at `tick` (empty when nothing sounds). */
880
+ export function chordAt(
881
+ timeline: ChordTimeline,
882
+ tick: number,
883
+ ): readonly number[] {
884
+ const { ticks, sets } = timeline;
885
+ let lo = 0;
886
+ let hi = ticks.length - 1;
887
+ let found = -1;
888
+ while (lo <= hi) {
889
+ const mid = (lo + hi) >> 1;
890
+ if (ticks[mid]! <= tick) {
891
+ found = mid;
892
+ lo = mid + 1;
893
+ } else hi = mid - 1;
894
+ }
895
+ return found < 0 ? [] : sets[found]!;
896
+ }
897
+
898
+ /**
899
+ * The chord changes between two ticks, relative to `from`, as a stable
900
+ * string: what a `quant chord` render over that span reads.
901
+ */
902
+ export function chordDigest(
903
+ timeline: ChordTimeline,
904
+ from = -Infinity,
905
+ to = Infinity,
906
+ ): string {
907
+ const parts: string[] = [chordAt(timeline, from).join(".")];
908
+ for (let i = 0; i < timeline.ticks.length; i += 1) {
909
+ const tick = timeline.ticks[i]!;
910
+ if (tick > from && tick < to)
911
+ parts.push(
912
+ `${Number.isFinite(from) ? tick - from : tick}:${timeline.sets[i]!.join(".")}`,
913
+ );
914
+ }
915
+ return `chord:${parts.join(",")}`;
916
+ }
917
+
918
+ /**
919
+ * `quant` (0.6.1): the pitch classes grains may land on at a tick. `scale`
920
+ * is the song key's scale (chromatic without a key); `chord` is the pitch
921
+ * classes sounding on the song's harmonic tracks at the tick (see
922
+ * `harmonicTrack`), falling back to the scale when nothing else sounds
923
+ * (Ableton Granulator and Bitwig's scale-quantised grain pitch work the
924
+ * same way). `chord` reads `source` (the whole song for a live note) at
925
+ * `tick + offset`.
926
+ */
927
+ export function quantPitchClasses(
928
+ score: TrackScore,
929
+ mode: "scale" | "chord",
930
+ source?: Readonly<{ score: TrackScore; tick: number }>,
931
+ ): (tick: number) => readonly number[] {
932
+ const key = parseKey(score.key ?? undefined);
933
+ const scale = key ? scaleOf(key) : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
934
+ if (mode === "scale") return () => scale;
935
+ const timeline = chordTimeline(source?.score ?? score);
936
+ const offset = source?.tick ?? 0;
937
+ return (tick: number) => {
938
+ const set = chordAt(timeline, tick + offset);
939
+ return set.length > 0 ? set : scale;
940
+ };
941
+ }
942
+
943
+ /** A grain's semitone offset over `pitch`, snapped to the nearest class. */
944
+ export function snapSemis(
945
+ pitch: number,
946
+ semis: number,
947
+ classes: readonly number[],
948
+ ): number {
949
+ const target = pitch + semis;
950
+ const base = Math.round(target);
951
+ let best = base;
952
+ let bestDistance = Infinity;
953
+ for (let d = 0; d <= 6; d += 1)
954
+ for (const candidate of d === 0 ? [base] : [base - d, base + d]) {
955
+ if (!classes.includes(((candidate % 12) + 12) % 12)) continue;
956
+ const distance = Math.abs(candidate - target);
957
+ if (distance < bestDistance) {
958
+ best = candidate;
959
+ bestDistance = distance;
960
+ }
961
+ }
962
+ return best - pitch;
963
+ }
964
+
645
965
  /** The registered engine (`src/audio/instruments.ts`). */
646
966
  export const GRANULAR_ENGINE: InstrumentEngine = Object.freeze({
647
967
  id: "granular",
@@ -655,10 +975,21 @@ export const GRANULAR_ENGINE: InstrumentEngine = Object.freeze({
655
975
  },
656
976
  tailSeconds: (track: Track) => granularTailSeconds(track.granular),
657
977
  stereo: () => true,
658
- assetDigests(track: Track, bank: SampleBank): readonly string[] {
978
+ assetDigests(
979
+ track: Track,
980
+ bank: SampleBank,
981
+ score?: TrackScore,
982
+ ): readonly string[] {
659
983
  const src = track.granular?.src;
660
- if (src === undefined || typeof src === "string") return [];
661
- const decoded = bank.voices.get(granularSampleKey(track.id));
662
- return [decoded ? decoded.sha256 : "missing"];
984
+ const digests: string[] = [];
985
+ if (src !== undefined && typeof src !== "string") {
986
+ const decoded = bank.voices.get(granularSampleKey(track.id));
987
+ digests.push(decoded ? decoded.sha256 : "missing");
988
+ }
989
+ // `quant chord` reads the other tracks' notes: their chords join the
990
+ // stem key, so a cached stem re-renders when the harmony changes.
991
+ if (score && resolveGranular(track.granular).quant === "chord")
992
+ digests.push(chordDigest(chordTimeline(score)));
993
+ return digests;
663
994
  },
664
995
  });
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { isDrumInstrument } from "../../core/drums.ts";
10
10
  import { INSTRUMENT_WORDS, LEGACY_WORDS } from "../../core/instruments.ts";
11
- import { PIANO_FAMILIES } from "../../core/keys.ts";
11
+ import { KEYS_FAMILIES } from "../../core/keys.ts";
12
12
  import { isSamplerInstrument, type Track } from "../../core/score.ts";
13
13
  import { engineFor, registeredEngines } from "./instruments.ts";
14
14
  import { resolveOscillator } from "./synth/oscillators.ts";
@@ -19,7 +19,7 @@ const LEGACY_TONES = ["square", "saw", "triangle", "bass", "piano", "pluck"];
19
19
 
20
20
  /** The 0.6 voice to point a plain-sine legacy word at. */
21
21
  const LEGACY_ADVICE: Readonly<Record<string, string>> = Object.freeze({
22
- cello: "synth preset strings for a string pad",
22
+ cello: "bowed cello for the bowed cello, synth preset strings for a pad",
23
23
  strings: "synth preset strings for a string pad",
24
24
  organ: "synth preset organ",
25
25
  bell: "modal glock or synth preset bell",
@@ -34,7 +34,7 @@ function knownWords(): readonly string[] {
34
34
  ...new Set([
35
35
  ...AVAILABLE_INSTRUMENTS,
36
36
  ...INSTRUMENT_WORDS.map((row) => row.word),
37
- ...PIANO_FAMILIES,
37
+ ...KEYS_FAMILIES,
38
38
  ...registeredEngines(),
39
39
  "sampler",
40
40
  ]),
@@ -49,9 +49,9 @@ export function playsPlainSine(
49
49
  const name = instrument.trim().toLowerCase();
50
50
  if (name === "" || name.includes("sine")) return false;
51
51
  if (track && engineFor(track)) return false;
52
- // Engine ids and piano families name a 0.6 voice even without its field.
52
+ // Engine ids and keys families name a 0.6 voice even without its field.
53
53
  if (registeredEngines().includes(name)) return false;
54
- if ((PIANO_FAMILIES as readonly string[]).includes(name)) return false;
54
+ if ((KEYS_FAMILIES as readonly string[]).includes(name)) return false;
55
55
  if (isDrumInstrument(name) || isSamplerInstrument(name)) return false;
56
56
  if (name === "wavetable" || resolveOscillator(name)) return false;
57
57
  return !LEGACY_TONES.some((tone) => name.includes(tone));
@@ -74,6 +74,10 @@ export function plainSineAdvice(
74
74
  return `"${name}" is dawg's plain sine (kept for old projects)${advice ? ` · ${advice}` : ""}`;
75
75
  }
76
76
  const near = nearestWord(name, knownWords());
77
+ // A resolver word stored raw (an older project): the word itself now
78
+ // picks a voice when typed again.
79
+ if (near === name)
80
+ return `"${name}" is stored as a bare word and plays a plain sine · type instrument ${name} again for its voice`;
77
81
  return `"${name}" is not a dawg instrument and plays a plain sine${near ? ` · did you mean ${near}?` : ""}`;
78
82
  }
79
83
 
@@ -7,8 +7,9 @@
7
7
  * render takes exactly today's code path.
8
8
  */
9
9
  import type { PerformedNote } from "../../core/expression.ts";
10
- import type { Track } from "../../core/score.ts";
10
+ import type { Track, TrackScore } from "../../core/score.ts";
11
11
  import type { TuningTable } from "../../core/tuning.ts";
12
+ import { WIND_ENGINE } from "./winds/engine.ts";
12
13
  import { MODAL_ENGINE } from "./resonators.ts";
13
14
  import type { SampleBank } from "./samples.ts";
14
15
  import type { RenderContext } from "./wav.ts";
@@ -24,6 +25,9 @@ export type EngineContext = RenderContext &
24
25
  tuning?: TuningTable;
25
26
  }>;
26
27
 
28
+ /** Fade at a ring-out cap (live note length, voice cap, loop fold), in seconds. */
29
+ export const RING_OUT_FADE_SECONDS = 0.25;
30
+
27
31
  export type InstrumentEngine = Readonly<{
28
32
  /** The `Track.instrument` value this engine plays. */
29
33
  id: string;
@@ -52,10 +56,34 @@ export type InstrumentEngine = Readonly<{
52
56
  * (a piano's damper); absent uses `tailSeconds`.
53
57
  */
54
58
  releaseSeconds?(track: Track, pitch: number, hz: number): number;
59
+ /**
60
+ * True when a released live key keeps ringing to the end of its rendered
61
+ * tail (an undamped bar or gong) instead of fading at note-off.
62
+ */
63
+ ringOut?(track: Track): boolean;
55
64
  /** True when the engine writes a separate right channel. */
56
65
  stereo(track: Track): boolean;
57
- /** Digests of any assets the engine reads, joined to the stem cache key. */
58
- assetDigests?(track: Track, bank: SampleBank): readonly string[];
66
+ /**
67
+ * State the engine carries into an arranged window that its pre-roll
68
+ * cannot rebuild (an organ's rotors along a lane), as a string: `history`
69
+ * is the song before the window and `frames` its length in samples.
70
+ * Arranged renders hand it back as `context.seedState[track.id]`.
71
+ */
72
+ windowSeed?(
73
+ history: TrackScore,
74
+ track: Track,
75
+ frames: number,
76
+ sampleRate: number,
77
+ ): string | undefined;
78
+ /**
79
+ * Digests of any assets the engine reads, joined to the stem cache key.
80
+ * `score` is the song, for engines that read other tracks' notes.
81
+ */
82
+ assetDigests?(
83
+ track: Track,
84
+ bank: SampleBank,
85
+ score?: TrackScore,
86
+ ): readonly string[];
59
87
  }>;
60
88
 
61
89
  const engines = new Map<string, InstrumentEngine>();
@@ -109,3 +137,4 @@ registerEngine(STRING_ENGINE);
109
137
  registerEngine(GRANULAR_ENGINE);
110
138
  for (const engine of KEYS_ENGINES) registerEngine(engine);
111
139
  registerEngine(MODAL_ENGINE);
140
+ registerEngine(WIND_ENGINE);