@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
@@ -0,0 +1,615 @@
1
+ /**
2
+ * The string engine (0.6 lane A): renders a track whose instrument is
3
+ * `"string"` and which carries `Track.string`. Each performed note plays
4
+ * one string per unison player (plus an octave course), streamed in blocks
5
+ * with same-pitch restrike and a chord-group voice cap; the summed strings
6
+ * then pass the track's body and sympathetic strings, the preset trim and
7
+ * the track volume, before the track's effects (wav.ts).
8
+ */
9
+ import { parseKey, scaleSteps } from "../../../core/chords.ts";
10
+ import type { PerformedNote } from "../../../core/expression.ts";
11
+ import type { FxLane } from "../../../core/fx.ts";
12
+ import type { Track } from "../../../core/score.ts";
13
+ import {
14
+ resolveString,
15
+ stringPresetTableText,
16
+ type StringValues,
17
+ } from "../../../core/strings.ts";
18
+ import { noteHz } from "../../../core/tuning.ts";
19
+ import { interpolateAutomation } from "../effects/common.ts";
20
+ import { seedHash, unit } from "../dsp/rng.ts";
21
+ import type { EngineContext, InstrumentEngine } from "../instruments.ts";
22
+ import { applyBody, bodyFor, symKeys, sympathetic } from "./body.ts";
23
+ import { clamp } from "./loop.ts";
24
+ import { PluckString, type Exciter, type PluckSpec } from "./pluck.ts";
25
+ import {
26
+ BowedString,
27
+ type BowControl,
28
+ type BowSpec,
29
+ type BowNote,
30
+ } from "./bow.ts";
31
+
32
+ const BLOCK = 128;
33
+ /** Longest a single string may ring (seconds). */
34
+ const MAX_RING = 12;
35
+ /** Sympathetic strings' own T60 at C4 (prototype). */
36
+ const SYM_DECAY = 4;
37
+
38
+ /** Value of `name` at `tick`: the `string-<name>` lane over the stored value. */
39
+ function valueAt(
40
+ track: Track,
41
+ values: StringValues,
42
+ name: string,
43
+ tick: number,
44
+ ): number {
45
+ const base = values[name] as number;
46
+ const points = track.fxAutomation?.[`string-${name}` as FxLane];
47
+ return points && points.length > 0
48
+ ? interpolateAutomation(points, tick, base)
49
+ : base;
50
+ }
51
+
52
+ /** The plucked-string spec for one note (articulations map to timbre). */
53
+ export function noteSpec(
54
+ values: StringValues,
55
+ note: PerformedNote,
56
+ track: Track,
57
+ ): { spec: PluckSpec; velocity: number } {
58
+ const at = (name: string) => valueAt(track, values, name, note.startTick);
59
+ let mute = clamp(at("mute"), 0, 1);
60
+ let bright = clamp(values.bright as number, 0, 1);
61
+ let velocity = clamp(note.velocity, 0, 1);
62
+ const articulation = note.articulation;
63
+ if (articulation === "staccato") mute = Math.max(mute, 0.7);
64
+ if (articulation === "ghost") {
65
+ mute = 1;
66
+ bright *= 0.5;
67
+ }
68
+ if (articulation === "accent" || articulation === "marcato")
69
+ velocity = Math.min(1, velocity + 0.15);
70
+ // Velocity sensitivity: 0 plays every note at full velocity.
71
+ const sens = clamp(values.vel as number, 0, 1);
72
+ velocity = 1 - sens * (1 - velocity);
73
+ const damp = clamp(values.damp as number, 0, 1);
74
+ return {
75
+ spec: {
76
+ // Palm mute: up to 12x shorter ring and a darker loop.
77
+ decay: Math.max(0.02, at("ring") * (1 - 0.92 * mute)),
78
+ track: values.track as number,
79
+ damp: damp + (1 - damp) * 0.6 * mute,
80
+ stiff: values.stiff as number,
81
+ pos: clamp(values.pos as number, 0.01, 0.5),
82
+ exciter: values.exciter as Exciter,
83
+ bright,
84
+ noise: values.noise as number,
85
+ release: values.release as number,
86
+ jawari: clamp(at("buzz"), 0, 1),
87
+ pickup: values.pickup as number,
88
+ },
89
+ velocity,
90
+ };
91
+ }
92
+
93
+ /** Whether resolved values play the bowed voice (`exciter bow`). */
94
+ export function isBowed(values: StringValues): boolean {
95
+ return values.exciter === "bow";
96
+ }
97
+
98
+ /** Play mode keeps a bowed section to two players (live budget). */
99
+ export const LIVE_BOW_UNISON = 2;
100
+
101
+ /** The track as play mode hears it: a bowed section capped at 2 players. */
102
+ export function liveStringTrack(track: Track): Track {
103
+ if (track.instrument !== "string" || !track.string) return track;
104
+ const values = resolveString(track.string);
105
+ if (!isBowed(values) || (values.unison as number) <= LIVE_BOW_UNISON)
106
+ return track;
107
+ return { ...track, string: { ...track.string, unison: LIVE_BOW_UNISON } };
108
+ }
109
+
110
+ /** Bow lanes read every 32 samples (`string-<name>`, absent: the value). */
111
+ const BOW_LANES = ["pressure", "speed", "sord", "dyn"] as const;
112
+
113
+ /** The bowed spec and note fields for one note. */
114
+ export function bowSpec(
115
+ values: StringValues,
116
+ note: PerformedNote,
117
+ track: Track,
118
+ context: EngineContext,
119
+ ): { spec: BowSpec; velocity: number; control?: (t: number) => BowControl } {
120
+ const at = (name: string) => valueAt(track, values, name, note.startTick);
121
+ let velocity = clamp(note.velocity, 0, 1);
122
+ const articulation = note.articulation;
123
+ const sens = clamp(values.vel as number, 0, 1);
124
+ velocity = 1 - sens * (1 - velocity);
125
+ let attack = values.attack as number;
126
+ let release = values.release as number;
127
+ // Detache (spec 4): staccato and ghost are short strokes, quick on and off.
128
+ if (articulation === "staccato" || articulation === "ghost") {
129
+ attack = Math.min(attack, 0.01);
130
+ release = Math.min(release, 0.03);
131
+ }
132
+ // A harder stroke presses harder (spec 4: pressure += 0.3 (vel - 0.5)).
133
+ const press = (p: number) => clamp(p + 0.3 * (velocity - 0.5), 0, 1);
134
+ // Accent and marcato bite: pressure +0.2 for the first 80 ms.
135
+ const bite = articulation === "accent" || articulation === "marcato";
136
+ const spec: BowSpec = {
137
+ decay: Math.max(0.02, at("ring")),
138
+ track: values.track as number,
139
+ damp: clamp(values.damp as number, 0, 1),
140
+ pos: clamp(at("pos"), 0.01, 0.5),
141
+ pressure: press(at("pressure")),
142
+ speed: clamp(at("speed"), 0, 1),
143
+ attack,
144
+ release,
145
+ tremhz: values.tremhz as number,
146
+ sord: clamp(at("sord"), 0, 1),
147
+ };
148
+ const lanes = BOW_LANES.filter(
149
+ (name) =>
150
+ (track.fxAutomation?.[`string-${name}` as FxLane]?.length ?? 0) > 0,
151
+ );
152
+ const dyn = clamp(values.dyn as number, 0, 1);
153
+ if (lanes.length === 0 && dyn === 1 && !bite) return { spec, velocity };
154
+ const fixed: BowControl = {
155
+ pressure: spec.pressure,
156
+ speed: spec.speed,
157
+ sord: spec.sord,
158
+ dyn,
159
+ };
160
+ const bitten: BowControl = {
161
+ ...fixed,
162
+ pressure: clamp(spec.pressure + 0.2, 0, 1),
163
+ };
164
+ if (lanes.length === 0)
165
+ return {
166
+ spec,
167
+ velocity,
168
+ control: bite ? (t) => (t < 0.08 ? bitten : fixed) : () => fixed,
169
+ };
170
+ // Lanes are read in ticks; warped songs read them at the note's tempo.
171
+ const ticksPerSecond = context.sampleRate / context.samplesPerTick;
172
+ const control = (t: number): BowControl => {
173
+ const tick = note.startTick + t * ticksPerSecond;
174
+ const lane = (name: string) =>
175
+ clamp(valueAt(track, values, name, tick), 0, 1);
176
+ return {
177
+ pressure: clamp(
178
+ press(lane("pressure")) + (bite && t < 0.08 ? 0.2 : 0),
179
+ 0,
180
+ 1,
181
+ ),
182
+ speed: lane("speed"),
183
+ sord: lane("sord"),
184
+ dyn: lane("dyn"),
185
+ };
186
+ };
187
+ return { spec, velocity, control };
188
+ }
189
+
190
+ /** The preset vibrato alone (a slur keeps it running). */
191
+ function vibratoCurve(
192
+ note: PerformedNote,
193
+ values: StringValues,
194
+ track: Track,
195
+ player: Readonly<{ rate: number; depth: number; phase: number }> = SOLO,
196
+ ): ((t: number) => number) | undefined {
197
+ if (note.performance?.replaceVibrato) return undefined;
198
+ const rate = valueAt(track, values, "vib", note.startTick) * player.rate;
199
+ const depth =
200
+ valueAt(track, values, "vibmod", note.startTick) * 100 * player.depth;
201
+ const delay = values.vibdelay as number;
202
+ if (!(rate > 0 && depth > 0)) return undefined;
203
+ const phase = player.phase;
204
+ return (t: number) => {
205
+ if (t <= delay) return 0;
206
+ const fade = Math.min(1, (t - delay) / 0.15);
207
+ return depth * fade * Math.sin(2 * Math.PI * (rate * (t - delay) + phase));
208
+ };
209
+ }
210
+
211
+ const SOLO = { rate: 1, depth: 1, phase: 0 } as const;
212
+
213
+ /** Longest seeded onset spread of a bowed section's players (spec 2). */
214
+ export const SECTION_SPREAD = 0.025;
215
+
216
+ /**
217
+ * One section player (spec 2, Ensemble): seeded vibrato rate x0.92-1.08,
218
+ * depth x0.8-1.2 and phase, and an onset up to 25 ms late (scaled down
219
+ * for notes under 0.25 s). The first player stays on the grid.
220
+ */
221
+ export function sectionPlayer(
222
+ seed: number,
223
+ index: number,
224
+ hold: number,
225
+ ): { rate: number; depth: number; phase: number; onset: number } {
226
+ const h = seedHash(`${seed}:${index}:player`);
227
+ return {
228
+ rate: 0.92 + 0.16 * unit(h, 0, 1),
229
+ depth: 0.8 + 0.4 * unit(h, 1, 1),
230
+ phase: unit(h, 2, 1),
231
+ onset:
232
+ index === 0
233
+ ? 0
234
+ : SECTION_SPREAD * unit(h, 3, 1) * Math.min(1, hold / 0.25),
235
+ };
236
+ }
237
+
238
+ type Voice = {
239
+ string: PluckString | BowedString;
240
+ /** The string's pitch over the note's base pitch (a slur keeps it). */
241
+ ratio: number;
242
+ start: number;
243
+ pitch: number;
244
+ group: number;
245
+ gainL: number;
246
+ gainR: number;
247
+ damp?: Readonly<{ from: number; tau: number }>;
248
+ };
249
+
250
+ /**
251
+ * Whether pending note `index` (starting at `start`) is a lone note: no
252
+ * other note starts with it, so a held single note may slur into it.
253
+ */
254
+ function slurs(
255
+ pending: readonly { start: number }[],
256
+ index: number,
257
+ start: number,
258
+ ): boolean {
259
+ return (
260
+ pending[index - 1]?.start !== start && pending[index + 1]?.start !== start
261
+ );
262
+ }
263
+
264
+ /** A 64th note in samples at the song's start tempo (the slur window). */
265
+ function slurWindow(context: EngineContext): number {
266
+ const ticks = (context.score.ticksPerBeat ?? 480) / 16;
267
+ return ticks * context.samplesPerTick;
268
+ }
269
+
270
+ /** Ring-out after the last note-off, in seconds. */
271
+ export function stringTailSeconds(track: Track): number {
272
+ const values = resolveString(track.string);
273
+ const release = values.release as number;
274
+ const sym = (values.sym as number) > 0 ? SYM_DECAY : 0;
275
+ return Math.min(MAX_RING, Math.max(release, sym) + 0.05);
276
+ }
277
+
278
+ function startSample(note: PerformedNote, context: EngineContext): number {
279
+ if (context.warp)
280
+ return Math.max(0, Math.floor(context.warp.sample(note.startTick)));
281
+ return Math.max(0, Math.floor(note.startTick * context.samplesPerTick));
282
+ }
283
+
284
+ function holdSamples(
285
+ note: PerformedNote,
286
+ start: number,
287
+ context: EngineContext,
288
+ ): number {
289
+ if (context.warp)
290
+ return Math.max(
291
+ 1,
292
+ Math.floor(
293
+ context.warp.sample(note.startTick + note.durationTicks) - start,
294
+ ),
295
+ );
296
+ return Math.max(1, Math.floor(note.durationTicks * context.samplesPerTick));
297
+ }
298
+
299
+ /** Combined pitch curve: the note's own, then the preset vibrato. */
300
+ function centsCurve(
301
+ note: PerformedNote,
302
+ values: StringValues,
303
+ track: Track,
304
+ ): ((t: number) => number) | undefined {
305
+ const own = note.performance?.cents;
306
+ const rate = valueAt(track, values, "vib", note.startTick);
307
+ const depth = valueAt(track, values, "vibmod", note.startTick) * 100;
308
+ const delay = values.vibdelay as number;
309
+ const vibrato =
310
+ rate > 0 && depth > 0 && !note.performance?.replaceVibrato
311
+ ? (t: number) => {
312
+ if (t <= delay) return 0;
313
+ const fade = Math.min(1, (t - delay) / 0.15);
314
+ return depth * fade * Math.sin(2 * Math.PI * rate * (t - delay));
315
+ }
316
+ : undefined;
317
+ if (own && vibrato) return (t) => own(t) + vibrato(t);
318
+ return own ?? vibrato;
319
+ }
320
+
321
+ /** Renders a string track's notes into `dry` (and `dryR` when stereo). */
322
+ export function renderStrings(
323
+ dry: Float64Array,
324
+ dryR: Float64Array | undefined,
325
+ notes: readonly PerformedNote[],
326
+ track: Track,
327
+ context: EngineContext,
328
+ ): void {
329
+ const sr = context.sampleRate;
330
+ const total = Math.min(dry.length, context.samples);
331
+ const values = resolveString(track.string);
332
+ const unison = Math.max(1, Math.round(values.unison as number));
333
+ const detune = values.detune as number;
334
+ const spread = dryR ? clamp(values.spread as number, 0, 1) : 0;
335
+ const oct = values.oct as number;
336
+ const octbelow = values.octbelow as number;
337
+ const cap = Math.max(1, Math.round(values.voices as number));
338
+ const tuning = context.tuning;
339
+ const bowed = isBowed(values);
340
+ const L = new Float64Array(total);
341
+ const R = dryR ? new Float64Array(total) : undefined;
342
+ const voices: Voice[] = [];
343
+ const pending = notes
344
+ .map((note) => ({ note, start: startSample(note, context) }))
345
+ .filter((item) => item.start < total)
346
+ .sort(
347
+ (a, b) =>
348
+ a.start - b.start ||
349
+ a.note.pitch - b.note.pitch ||
350
+ (a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0),
351
+ );
352
+ let next = 0;
353
+ for (let at = 0; at < total; at += BLOCK) {
354
+ const count = Math.min(BLOCK, total - at);
355
+ while (next < pending.length && pending[next]!.start < at + count) {
356
+ const { note, start } = pending[next]!;
357
+ next += 1;
358
+ const baseHz = noteHz(note.pitch, note.cents, tuning);
359
+ if (!(baseHz > 0) || baseHz >= 0.45 * sr) continue;
360
+ // Slur (bowed): a single note that starts while one earlier single
361
+ // note is still bowed moves that bow to the new pitch in 7 ms, with
362
+ // no new attack (legato articulation overlaps its notes, so it
363
+ // slurs too). Chords and double stops start new strokes.
364
+ if (bowed && slurs(pending, next - 1, start)) {
365
+ const held = voices.filter(
366
+ (v) => !v.string.done && v.string.offAt > start - v.start,
367
+ );
368
+ const groups = new Set(held.map((v) => v.group));
369
+ const pitches = new Set(held.map((v) => v.pitch));
370
+ // A true legato only: the held note lets go within a short window
371
+ // of the new one (a 64th note or 150 ms, legato's own overlap), or
372
+ // the new note is marked legato. A pedal or a note held under a
373
+ // moving line keeps sounding and the new note takes a new stroke.
374
+ const window = Math.max(0.15 * sr, slurWindow(context));
375
+ const letsGo =
376
+ note.articulation === "legato" ||
377
+ held.every((v) => v.start + v.string.offAt <= start + window);
378
+ const range = held.every((v) =>
379
+ (v.string as BowedString).canSlurTo(baseHz * v.ratio),
380
+ );
381
+ if (
382
+ letsGo &&
383
+ range &&
384
+ groups.size === 1 &&
385
+ pitches.size === 1 &&
386
+ held[0]!.start < start
387
+ ) {
388
+ const hold = holdSamples(note, start, context) / sr;
389
+ for (const v of held) {
390
+ (v.string as BowedString).slurTo(
391
+ baseHz * v.ratio,
392
+ start - v.start,
393
+ Math.max(0.01, hold),
394
+ note.performance?.cents,
395
+ );
396
+ v.pitch = note.pitch;
397
+ }
398
+ continue;
399
+ }
400
+ }
401
+ // Restrike: the same key damps its previous string.
402
+ for (const v of voices)
403
+ if (v.pitch === note.pitch) v.string.releaseAt(start - v.start);
404
+ // Voice cap: count only chords not yet released, so a releasing
405
+ // chord never shields the newer ringing ones from the cap.
406
+ const held = voices.filter(
407
+ (v) => !v.string.done && v.string.offAt > start - v.start,
408
+ );
409
+ const groups = new Set(held.map((v) => v.group));
410
+ if (groups.size >= cap) {
411
+ const oldest = Math.min(...groups);
412
+ for (const v of held)
413
+ if (v.group === oldest) v.string.releaseAt(start - v.start);
414
+ }
415
+ const seed = seedHash(
416
+ `${note.id}:${note.startTick + (context.seedTick ?? 0)}:string`,
417
+ );
418
+ const strings: { hz: number; gain: number; ratio: number }[] = [];
419
+ // The course's tuning error belongs to the key, not to the strike: it
420
+ // is seeded by track and key (so every velocity of a key beats the
421
+ // same way) and centred, so the course's mean pitch is the table's.
422
+ const course = seedHash(`${track.id}:${note.pitch}:course`);
423
+ const jitters = Array.from({ length: unison }, (_, i) =>
424
+ unison > 1 ? (unit(course, i, 1) - 0.5) * 2 : 0,
425
+ );
426
+ const meanJitter = jitters.reduce((a, b) => a + b, 0) / unison;
427
+ for (let i = 0; i < unison; i += 1) {
428
+ const d = unison > 1 ? (i / (unison - 1) - 0.5) * detune : 0;
429
+ const jitter = jitters[i]! - meanJitter;
430
+ const ratio = 2 ** ((d * 100 + jitter) / 1200);
431
+ strings.push({
432
+ hz: baseHz * ratio,
433
+ gain: 1 / Math.sqrt(unison),
434
+ ratio,
435
+ });
436
+ }
437
+ if (oct > 0 && note.pitch < octbelow) {
438
+ const up = noteHz(Math.min(127, note.pitch + 12), note.cents, tuning);
439
+ const hz = up > 0 ? up : baseHz * 2;
440
+ if (hz < 0.45 * sr) {
441
+ const tuned =
442
+ hz * 2 ** (((unit(course, unison, 1) - 0.5) * 4) / 1200);
443
+ strings.push({
444
+ hz: tuned,
445
+ gain: oct / Math.sqrt(unison),
446
+ ratio: tuned / baseHz,
447
+ });
448
+ }
449
+ }
450
+ const hold = holdSamples(note, start, context) / sr;
451
+ const pluck = bowed ? undefined : noteSpec(values, note, track);
452
+ const bow = bowed ? bowSpec(values, note, track, context) : undefined;
453
+ const cents = bowed
454
+ ? note.performance?.cents
455
+ : centsCurve(note, values, track);
456
+ const section = bowed && unison > 1;
457
+ strings.forEach((s, i) => {
458
+ // Section players (not the octave string) are seeded as players.
459
+ const player =
460
+ section && i < unison ? sectionPlayer(seed, i, hold) : undefined;
461
+ const onset = player ? Math.round(player.onset * sr) : 0;
462
+ const vibrato = bowed
463
+ ? vibratoCurve(note, values, track, player)
464
+ : undefined;
465
+ const pan =
466
+ strings.length > 1 ? (i / (strings.length - 1) - 0.5) * spread : 0;
467
+ const common = {
468
+ hz: s.hz,
469
+ hold: Math.max(0.01, hold - onset / sr),
470
+ seed: seedHash(`${seed}:${i}`),
471
+ ...(cents ? { cents } : {}),
472
+ };
473
+ const string = bow
474
+ ? new BowedString(
475
+ bow.spec,
476
+ {
477
+ ...common,
478
+ velocity: bow.velocity,
479
+ ...(vibrato ? { vibrato } : {}),
480
+ ...(bow.control ? { control: bow.control } : {}),
481
+ } satisfies BowNote,
482
+ sr,
483
+ // A bow sustains for as long as the note is held.
484
+ Math.max(MAX_RING, hold + bow.spec.release + 1),
485
+ )
486
+ : new PluckString(
487
+ pluck!.spec,
488
+ { ...common, velocity: pluck!.velocity },
489
+ sr,
490
+ MAX_RING,
491
+ );
492
+ const gl = R ? s.gain * Math.sqrt(0.5 - pan / 2) * Math.SQRT2 : s.gain;
493
+ const gr = s.gain * Math.sqrt(0.5 + pan / 2) * Math.SQRT2;
494
+ voices.push({
495
+ string,
496
+ ratio: s.ratio,
497
+ start: start + onset,
498
+ pitch: note.pitch,
499
+ group: start,
500
+ gainL: gl,
501
+ gainR: gr,
502
+ ...(note.performance?.damp ? { damp: note.performance.damp } : {}),
503
+ });
504
+ });
505
+ }
506
+ for (const v of voices) {
507
+ if (v.string.done) continue;
508
+ const from = Math.max(at, v.start);
509
+ const n = at + count - from;
510
+ if (n <= 0) continue;
511
+ let scale = 1;
512
+ if (v.damp) {
513
+ const t = (from - v.start) / sr;
514
+ if (t > v.damp.from) scale = Math.exp(-(t - v.damp.from) / v.damp.tau);
515
+ }
516
+ v.string.process(L, from, n, v.gainL * scale, R, v.gainR * scale);
517
+ }
518
+ for (let i = voices.length - 1; i >= 0; i -= 1)
519
+ if (voices[i]!.string.done) voices.splice(i, 1);
520
+ }
521
+ finishTrack(L, R, values, track, context);
522
+ for (let i = 0; i < total; i += 1) {
523
+ dry[i] = dry[i]! + L[i]!;
524
+ if (dryR && R) dryR[i] = dryR[i]! + R[i]!;
525
+ }
526
+ }
527
+
528
+ /** Body, sympathetic strings, preset trim and track volume. */
529
+ function finishTrack(
530
+ L: Float64Array,
531
+ R: Float64Array | undefined,
532
+ values: StringValues,
533
+ track: Track,
534
+ context: EngineContext,
535
+ ): void {
536
+ const sr = context.sampleRate;
537
+ const sym = values.sym as number;
538
+ let wet: Float64Array | undefined;
539
+ if (sym > 0) {
540
+ const mono = R ? new Float64Array(L.length) : L;
541
+ if (R)
542
+ for (let i = 0; i < L.length; i += 1) mono[i] = 0.5 * (L[i]! + R[i]!);
543
+ // The key's own scale (a raga's or maqam's, quarter tones included),
544
+ // not the chord mode it harmonizes with.
545
+ const key = parseKey(context.score.key);
546
+ const root = 48 + (key?.tonic ?? 0);
547
+ const steps = key ? scaleSteps(key) : [0, 2, 4, 5, 7, 9, 11];
548
+ const hzs = symKeys(values.symtune as string, root, steps).map((k) =>
549
+ noteHz(k.key, k.cents || undefined, context.tuning),
550
+ );
551
+ wet = sympathetic(mono, hzs, 0.02 * sym, SYM_DECAY, 0.3, sr);
552
+ }
553
+ const body = bodyFor(values.body as string, values.size as number);
554
+ applyBody(L, body, sr);
555
+ if (R) applyBody(R, body, sr);
556
+ if (wet)
557
+ for (let i = 0; i < L.length; i += 1) {
558
+ L[i] = L[i]! + wet[i]!;
559
+ if (R) R[i] = R[i]! + wet[i]!;
560
+ }
561
+ // Preset trim (and its lane) and the track volume, every 32 samples.
562
+ const volume = Math.max(0, Math.min(1, track.volume ?? 1));
563
+ const volumeLane = track.volumeAutomation ?? [];
564
+ const gainLane = track.fxAutomation?.["string-gain" as FxLane];
565
+ const warp = context.warp;
566
+ for (let at = 0; at < L.length; at += 32) {
567
+ const tick = warp ? warp.tick(at) : at / context.samplesPerTick;
568
+ const trim =
569
+ gainLane && gainLane.length > 0
570
+ ? interpolateAutomation(gainLane, tick, values.gain as number)
571
+ : (values.gain as number);
572
+ const g =
573
+ Math.max(0, trim) *
574
+ volume *
575
+ (volumeLane.length > 0 ? interpolateAutomation(volumeLane, tick, 1) : 1);
576
+ const end = Math.min(L.length, at + 32);
577
+ for (let i = at; i < end; i += 1) {
578
+ L[i] = L[i]! * g;
579
+ if (R) R[i] = R[i]! * g;
580
+ }
581
+ }
582
+ }
583
+
584
+ /** True when the track's strings are panned apart (spread with 2+ strings). */
585
+ export function stringStereo(track: Track): boolean {
586
+ const values = resolveString(track.string);
587
+ const strings =
588
+ Math.max(1, Math.round(values.unison as number)) +
589
+ ((values.oct as number) > 0 ? 1 : 0);
590
+ return (values.spread as number) > 0 && strings > 1;
591
+ }
592
+
593
+ /** Digest of the preset table text (FNV-1a, hex), computed once. */
594
+ const STRING_PRESET_DIGEST = `strings:${fnv1a(stringPresetTableText())}`;
595
+
596
+ function fnv1a(text: string): string {
597
+ let h = 0x811c9dc5;
598
+ for (let i = 0; i < text.length; i += 1) {
599
+ h ^= text.charCodeAt(i);
600
+ h = Math.imul(h, 0x01000193) >>> 0;
601
+ }
602
+ return h.toString(16).padStart(8, "0");
603
+ }
604
+
605
+ export const STRING_ENGINE: InstrumentEngine = Object.freeze({
606
+ id: "string",
607
+ field: "string" as keyof Track,
608
+ render: (dry, dryR, notes, track, context) =>
609
+ renderStrings(dry, dryR, notes, track, context),
610
+ tailSeconds: stringTailSeconds,
611
+ stereo: stringStereo,
612
+ // The preset table joins the stem and live keys: a code-side preset
613
+ // retune never serves a stem rendered from the old table.
614
+ assetDigests: () => [STRING_PRESET_DIGEST],
615
+ });