@hraness/dawg 0.5.0 → 0.6.0

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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/DAWG.md +269 -13
  3. package/README.md +4 -4
  4. package/core/diff.ts +4 -0
  5. package/core/fx.ts +437 -2
  6. package/core/granular.ts +528 -0
  7. package/core/instruments.ts +281 -0
  8. package/core/keys.ts +386 -0
  9. package/core/resonators.ts +574 -0
  10. package/core/score.ts +181 -6
  11. package/core/sdk/eval-child.ts +2 -0
  12. package/core/sdk/print.ts +169 -13
  13. package/core/sdk/sync-instruments.ts +58 -0
  14. package/core/sdk/v1.ts +1114 -21
  15. package/core/sections.ts +34 -8
  16. package/core/strings.ts +845 -0
  17. package/guides/effects.md +3 -1
  18. package/guides/sounds.md +4 -0
  19. package/guides/tempo.md +1 -0
  20. package/package.json +1 -1
  21. package/src/agent/agent.ts +7 -0
  22. package/src/agent/granular-tools.ts +138 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/ops.ts +39 -2
  25. package/src/agent/preview-tool.ts +6 -1
  26. package/src/agent/tools.ts +538 -14
  27. package/src/agent/xcb-agent.ts +7 -0
  28. package/src/audio/arrange.ts +21 -3
  29. package/src/audio/dsp/bank.ts +233 -0
  30. package/src/audio/dsp/fft.ts +6 -0
  31. package/src/audio/dsp/filters.ts +57 -0
  32. package/src/audio/dsp/interp.ts +112 -0
  33. package/src/audio/dsp/modal.ts +684 -0
  34. package/src/audio/dsp/onset.ts +197 -0
  35. package/src/audio/dsp/oversample.ts +202 -0
  36. package/src/audio/dsp/rng.ts +37 -0
  37. package/src/audio/dsp/shape.ts +81 -0
  38. package/src/audio/dsp/stft.ts +73 -0
  39. package/src/audio/dsp/window.ts +77 -0
  40. package/src/audio/effects/chain.ts +6 -1
  41. package/src/audio/effects/rig/cab.ts +99 -0
  42. package/src/audio/effects/rig/filters.ts +152 -0
  43. package/src/audio/effects/rig/gate.ts +39 -0
  44. package/src/audio/effects/rig/head.ts +487 -0
  45. package/src/audio/effects/rig/index.ts +170 -0
  46. package/src/audio/effects/rig/section.ts +78 -0
  47. package/src/audio/effects/rig/stomp.ts +238 -0
  48. package/src/audio/engine.ts +10 -1
  49. package/src/audio/fit.ts +402 -0
  50. package/src/audio/granular.ts +664 -0
  51. package/src/audio/instrument-check.ts +133 -0
  52. package/src/audio/instruments.ts +111 -0
  53. package/src/audio/keys/dsp.ts +323 -0
  54. package/src/audio/keys/engine.ts +361 -0
  55. package/src/audio/keys/piano.ts +432 -0
  56. package/src/audio/live-worker.ts +54 -0
  57. package/src/audio/live.ts +231 -12
  58. package/src/audio/loudness.ts +65 -20
  59. package/src/audio/preview.ts +2 -0
  60. package/src/audio/resonators.ts +287 -0
  61. package/src/audio/sampler.ts +163 -17
  62. package/src/audio/samples.ts +30 -7
  63. package/src/audio/strings/body.ts +250 -0
  64. package/src/audio/strings/engine.ts +369 -0
  65. package/src/audio/strings/loop.ts +119 -0
  66. package/src/audio/strings/measure.test-helpers.ts +198 -0
  67. package/src/audio/strings/pluck.ts +354 -0
  68. package/src/audio/warp.ts +61 -0
  69. package/src/audio/wav.ts +78 -6
  70. package/src/commands/expression.ts +9 -2
  71. package/src/commands/fit.ts +135 -0
  72. package/src/commands/fx.ts +31 -0
  73. package/src/commands/granular.ts +427 -0
  74. package/src/commands/help.ts +74 -3
  75. package/src/commands/keys.ts +353 -0
  76. package/src/commands/modal.ts +247 -0
  77. package/src/commands/rig.ts +260 -0
  78. package/src/commands/sample.ts +3 -0
  79. package/src/commands/string.ts +175 -0
  80. package/src/commands/time.ts +4 -1
  81. package/src/main.ts +210 -8
  82. package/src/project/check.ts +13 -0
  83. package/src/render.ts +33 -4
  84. package/src/tui/audition.ts +2 -2
  85. package/src/tui/granular-menu.ts +278 -0
  86. package/src/tui/menu.ts +332 -9
  87. package/src/tui/modal-menu.ts +118 -0
  88. package/src/tui/play-session.ts +45 -2
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Per-track string bodies and sympathetic strings (0.6 strings prototype,
3
+ * proto/strings/strings.ts). A body is a direct path plus a bank of 0 dB
4
+ * band-pass modes, run once per track over the summed strings; `size`
5
+ * scales the body (mode frequencies x 1/size). Sympathetic strings are
6
+ * one-way coupled loops tuned to the song key, open strings or a drone.
7
+ */
8
+ import { thiranFor } from "../dsp/interp.ts";
9
+ import { C4 } from "./pluck.ts";
10
+ import { designLoss, onePoleDelay } from "./loop.ts";
11
+
12
+ /** [frequency Hz, Q, gain] */
13
+ export type BodyMode = readonly [number, number, number];
14
+ export type Body = Readonly<{ direct: number; modes: readonly BodyMode[] }>;
15
+
16
+ export const BODIES: Readonly<Record<string, Body>> = Object.freeze({
17
+ none: { direct: 1, modes: [] },
18
+ guitar: {
19
+ direct: 0.55,
20
+ modes: [
21
+ [98, 10, 1.2],
22
+ [204, 14, 0.9],
23
+ [390, 18, 0.5],
24
+ [560, 20, 0.35],
25
+ [960, 12, 0.3],
26
+ [2300, 5, 0.35],
27
+ ],
28
+ },
29
+ steel: {
30
+ direct: 0.6,
31
+ modes: [
32
+ [110, 10, 1.0],
33
+ [220, 14, 0.9],
34
+ [420, 16, 0.6],
35
+ [700, 14, 0.4],
36
+ [1600, 6, 0.4],
37
+ [3200, 4, 0.3],
38
+ ],
39
+ },
40
+ small: {
41
+ direct: 0.6,
42
+ modes: [
43
+ [140, 10, 1.0],
44
+ [270, 14, 0.8],
45
+ [520, 16, 0.5],
46
+ [900, 12, 0.4],
47
+ [2200, 5, 0.35],
48
+ [3600, 4, 0.3],
49
+ ],
50
+ },
51
+ bowl: {
52
+ direct: 0.55,
53
+ modes: [
54
+ [120, 10, 1.0],
55
+ [250, 12, 0.8],
56
+ [480, 14, 0.5],
57
+ [900, 10, 0.35],
58
+ [1800, 6, 0.3],
59
+ ],
60
+ },
61
+ gourd: {
62
+ direct: 0.5,
63
+ modes: [
64
+ [180, 8, 0.9],
65
+ [360, 10, 0.5],
66
+ [720, 8, 0.4],
67
+ [1500, 5, 0.35],
68
+ [3000, 4, 0.3],
69
+ ],
70
+ },
71
+ skin: {
72
+ direct: 0.45,
73
+ modes: [
74
+ [300, 6, 1.2],
75
+ [650, 6, 0.8],
76
+ [1300, 5, 0.6],
77
+ [2600, 4, 0.5],
78
+ ],
79
+ },
80
+ board: {
81
+ direct: 0.6,
82
+ modes: [
83
+ [95, 6, 0.8],
84
+ [180, 8, 0.7],
85
+ [350, 8, 0.6],
86
+ [700, 8, 0.45],
87
+ [1400, 6, 0.35],
88
+ [2800, 5, 0.25],
89
+ ],
90
+ },
91
+ violin: {
92
+ direct: 0.3,
93
+ modes: [
94
+ [275, 14, 1.0],
95
+ [460, 18, 0.9],
96
+ [540, 18, 1.1],
97
+ [700, 12, 0.5],
98
+ [1100, 8, 0.4],
99
+ [2500, 3, 0.9],
100
+ [3400, 4, 0.4],
101
+ ],
102
+ },
103
+ });
104
+
105
+ /**
106
+ * The body for a name and size: modes x 1/size. `bass` is the violin body
107
+ * at 0.3 (a double bass is about 3.3 times the size).
108
+ */
109
+ export function bodyFor(name: string, size: number): Body {
110
+ const base =
111
+ name === "bass" ? BODIES.violin! : (BODIES[name] ?? BODIES.none!);
112
+ const k = (name === "bass" ? 0.3 : 1) / Math.max(0.05, size);
113
+ if (k === 1) return base;
114
+ return {
115
+ direct: base.direct,
116
+ modes: base.modes.map(([f, q, g]) => [f * k, q, g] as const),
117
+ };
118
+ }
119
+
120
+ /** Body over `x` in place: direct + modes, then a 10 Hz DC blocker. */
121
+ export function applyBody(x: Float64Array, body: Body, sr: number): void {
122
+ if (body.modes.length === 0 && body.direct === 1) return;
123
+ const input = Float64Array.from(x);
124
+ for (let i = 0; i < x.length; i += 1) x[i] = body.direct * input[i]!;
125
+ for (const [f, q, g] of body.modes) {
126
+ if (f >= 0.45 * sr) continue;
127
+ const w = (2 * Math.PI * f) / sr;
128
+ const alpha = Math.sin(w) / (2 * q);
129
+ const a0 = 1 + alpha;
130
+ const b0 = alpha / a0;
131
+ const b2 = -alpha / a0;
132
+ const a1 = (-2 * Math.cos(w)) / a0;
133
+ const a2 = (1 - alpha) / a0;
134
+ let z1 = 0;
135
+ let z2 = 0;
136
+ for (let i = 0; i < x.length; i += 1) {
137
+ const xi = input[i]!;
138
+ const y = b0 * xi + z1;
139
+ z1 = -a1 * y + z2;
140
+ z2 = b2 * xi - a2 * y;
141
+ x[i] = x[i]! + g * y;
142
+ }
143
+ }
144
+ const R = Math.exp((-2 * Math.PI * 10) / sr);
145
+ let x1 = 0;
146
+ let y1 = 0;
147
+ for (let i = 0; i < x.length; i += 1) {
148
+ const xi = x[i]!;
149
+ const y = xi - x1 + R * y1;
150
+ x1 = xi;
151
+ y1 = y;
152
+ x[i] = y;
153
+ }
154
+ }
155
+
156
+ export type SymTune = "scale" | "open" | "drone";
157
+
158
+ /** A sympathetic string: a MIDI key plus cents (quarter tones). */
159
+ export interface SymKey {
160
+ key: number;
161
+ cents: number;
162
+ }
163
+
164
+ /**
165
+ * Sympathetic string keys per tuning mode. `steps` is the song key's scale
166
+ * in semitones above the tonic (fractional for quarter tones). `scale`
167
+ * tunes up to 13 strings to it; `drone` is Sa-Pa-Sa, or Sa-Ma-Sa (else
168
+ * Sa-Ni-Sa) when the scale has no Pa; `open` is guitar EADGBE.
169
+ */
170
+ export function symKeys(
171
+ mode: string,
172
+ root: number,
173
+ steps: readonly number[],
174
+ ): SymKey[] {
175
+ const at = (step: number): SymKey => {
176
+ const whole = Math.round(step);
177
+ return { key: root + whole, cents: Math.round((step - whole) * 1000) / 10 };
178
+ };
179
+ if (mode === "open")
180
+ return [40, 45, 50, 55, 59, 64].map((key) => ({ key, cents: 0 }));
181
+ if (mode === "drone") {
182
+ const has = (n: number) => steps.some((s) => Math.abs(s - n) < 0.01);
183
+ const second = has(7)
184
+ ? at(-5)
185
+ : has(5)
186
+ ? at(-7)
187
+ : has(6)
188
+ ? at(-6)
189
+ : has(11)
190
+ ? at(-1)
191
+ : at(-5);
192
+ return [at(-12), second, at(0)];
193
+ }
194
+ const sorted = [...steps]
195
+ .map((s) => ((s % 12) + 12) % 12)
196
+ .sort((a, b) => a - b);
197
+ const keys: SymKey[] = [];
198
+ for (let octave = 0; octave <= 12 && keys.length < 13; octave += 12)
199
+ for (const step of sorted)
200
+ if (step + octave <= 19 && keys.length < 13) keys.push(at(step + octave));
201
+ return keys;
202
+ }
203
+
204
+ /**
205
+ * Sympathetic strings: one-way coupled loops excited by `x`; returns only
206
+ * what they add (the coupled input is subtracted back out).
207
+ */
208
+ export function sympathetic(
209
+ x: Float64Array,
210
+ hzs: readonly number[],
211
+ couple: number,
212
+ decay: number,
213
+ damp: number,
214
+ sr: number,
215
+ ): Float64Array {
216
+ const out = new Float64Array(x.length);
217
+ for (const hz of hzs) {
218
+ if (!(hz > 20) || hz > 0.4 * sr) continue;
219
+ const t0 = decay * (hz / C4) ** -0.3;
220
+ const { g, p } = designLoss(
221
+ hz,
222
+ t0,
223
+ Math.max(3000, 3 * hz),
224
+ t0 / (1 + 99 * damp * damp),
225
+ sr,
226
+ );
227
+ const w0 = (2 * Math.PI * hz) / sr;
228
+ const rest = sr / hz - onePoleDelay(p, w0);
229
+ const N = Math.max(1, Math.floor(rest - 0.5));
230
+ const a = thiranFor(rest - N, w0);
231
+ let size = 16;
232
+ while (size < N + 4) size <<= 1;
233
+ const buf = new Float64Array(size);
234
+ const m = size - 1;
235
+ let tx = 0;
236
+ let ty = 0;
237
+ let ly = 0;
238
+ const lgain = g * (1 - p);
239
+ for (let i = 0; i < x.length; i += 1) {
240
+ const d = buf[(i - N) & m]!;
241
+ const t = a * d + tx - a * ty;
242
+ tx = d;
243
+ ty = t;
244
+ ly = lgain * t + p * ly;
245
+ buf[i & m] = couple * x[i]! + ly;
246
+ out[i] = out[i]! + ly;
247
+ }
248
+ }
249
+ return out;
250
+ }
@@ -0,0 +1,369 @@
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
+
26
+ const BLOCK = 128;
27
+ /** Longest a single string may ring (seconds). */
28
+ const MAX_RING = 12;
29
+ /** Sympathetic strings' own T60 at C4 (prototype). */
30
+ const SYM_DECAY = 4;
31
+
32
+ /** Value of `name` at `tick`: the `string-<name>` lane over the stored value. */
33
+ function valueAt(
34
+ track: Track,
35
+ values: StringValues,
36
+ name: string,
37
+ tick: number,
38
+ ): number {
39
+ const base = values[name] as number;
40
+ const points = track.fxAutomation?.[`string-${name}` as FxLane];
41
+ return points && points.length > 0
42
+ ? interpolateAutomation(points, tick, base)
43
+ : base;
44
+ }
45
+
46
+ /** The plucked-string spec for one note (articulations map to timbre). */
47
+ export function noteSpec(
48
+ values: StringValues,
49
+ note: PerformedNote,
50
+ track: Track,
51
+ ): { spec: PluckSpec; velocity: number } {
52
+ const at = (name: string) => valueAt(track, values, name, note.startTick);
53
+ let mute = clamp(at("mute"), 0, 1);
54
+ let bright = clamp(values.bright as number, 0, 1);
55
+ let velocity = clamp(note.velocity, 0, 1);
56
+ const articulation = note.articulation;
57
+ if (articulation === "staccato") mute = Math.max(mute, 0.7);
58
+ if (articulation === "ghost") {
59
+ mute = 1;
60
+ bright *= 0.5;
61
+ }
62
+ if (articulation === "accent" || articulation === "marcato")
63
+ velocity = Math.min(1, velocity + 0.15);
64
+ // Velocity sensitivity: 0 plays every note at full velocity.
65
+ const sens = clamp(values.vel as number, 0, 1);
66
+ velocity = 1 - sens * (1 - velocity);
67
+ const damp = clamp(values.damp as number, 0, 1);
68
+ return {
69
+ spec: {
70
+ // Palm mute: up to 12x shorter ring and a darker loop.
71
+ decay: Math.max(0.02, at("ring") * (1 - 0.92 * mute)),
72
+ track: values.track as number,
73
+ damp: damp + (1 - damp) * 0.6 * mute,
74
+ stiff: values.stiff as number,
75
+ pos: clamp(values.pos as number, 0.01, 0.5),
76
+ exciter: values.exciter as Exciter,
77
+ bright,
78
+ noise: values.noise as number,
79
+ release: values.release as number,
80
+ jawari: clamp(at("buzz"), 0, 1),
81
+ pickup: values.pickup as number,
82
+ },
83
+ velocity,
84
+ };
85
+ }
86
+
87
+ type Voice = {
88
+ string: PluckString;
89
+ start: number;
90
+ pitch: number;
91
+ group: number;
92
+ gainL: number;
93
+ gainR: number;
94
+ damp?: Readonly<{ from: number; tau: number }>;
95
+ };
96
+
97
+ /** Ring-out after the last note-off, in seconds. */
98
+ export function stringTailSeconds(track: Track): number {
99
+ const values = resolveString(track.string);
100
+ const release = values.release as number;
101
+ const sym = (values.sym as number) > 0 ? SYM_DECAY : 0;
102
+ return Math.min(MAX_RING, Math.max(release, sym) + 0.05);
103
+ }
104
+
105
+ function startSample(note: PerformedNote, context: EngineContext): number {
106
+ if (context.warp)
107
+ return Math.max(0, Math.floor(context.warp.sample(note.startTick)));
108
+ return Math.max(0, Math.floor(note.startTick * context.samplesPerTick));
109
+ }
110
+
111
+ function holdSamples(
112
+ note: PerformedNote,
113
+ start: number,
114
+ context: EngineContext,
115
+ ): number {
116
+ if (context.warp)
117
+ return Math.max(
118
+ 1,
119
+ Math.floor(
120
+ context.warp.sample(note.startTick + note.durationTicks) - start,
121
+ ),
122
+ );
123
+ return Math.max(1, Math.floor(note.durationTicks * context.samplesPerTick));
124
+ }
125
+
126
+ /** Combined pitch curve: the note's own, then the preset vibrato. */
127
+ function centsCurve(
128
+ note: PerformedNote,
129
+ values: StringValues,
130
+ track: Track,
131
+ ): ((t: number) => number) | undefined {
132
+ const own = note.performance?.cents;
133
+ const rate = valueAt(track, values, "vib", note.startTick);
134
+ const depth = valueAt(track, values, "vibmod", note.startTick) * 100;
135
+ const delay = values.vibdelay as number;
136
+ const vibrato =
137
+ rate > 0 && depth > 0 && !note.performance?.replaceVibrato
138
+ ? (t: number) => {
139
+ if (t <= delay) return 0;
140
+ const fade = Math.min(1, (t - delay) / 0.15);
141
+ return depth * fade * Math.sin(2 * Math.PI * rate * (t - delay));
142
+ }
143
+ : undefined;
144
+ if (own && vibrato) return (t) => own(t) + vibrato(t);
145
+ return own ?? vibrato;
146
+ }
147
+
148
+ /** Renders a string track's notes into `dry` (and `dryR` when stereo). */
149
+ export function renderStrings(
150
+ dry: Float64Array,
151
+ dryR: Float64Array | undefined,
152
+ notes: readonly PerformedNote[],
153
+ track: Track,
154
+ context: EngineContext,
155
+ ): void {
156
+ const sr = context.sampleRate;
157
+ const total = Math.min(dry.length, context.samples);
158
+ const values = resolveString(track.string);
159
+ const unison = Math.max(1, Math.round(values.unison as number));
160
+ const detune = values.detune as number;
161
+ const spread = dryR ? clamp(values.spread as number, 0, 1) : 0;
162
+ const oct = values.oct as number;
163
+ const octbelow = values.octbelow as number;
164
+ const cap = Math.max(1, Math.round(values.voices as number));
165
+ const tuning = context.tuning;
166
+ const L = new Float64Array(total);
167
+ const R = dryR ? new Float64Array(total) : undefined;
168
+ const voices: Voice[] = [];
169
+ const pending = notes
170
+ .map((note) => ({ note, start: startSample(note, context) }))
171
+ .filter((item) => item.start < total)
172
+ .sort(
173
+ (a, b) =>
174
+ a.start - b.start ||
175
+ a.note.pitch - b.note.pitch ||
176
+ (a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0),
177
+ );
178
+ let next = 0;
179
+ for (let at = 0; at < total; at += BLOCK) {
180
+ const count = Math.min(BLOCK, total - at);
181
+ while (next < pending.length && pending[next]!.start < at + count) {
182
+ const { note, start } = pending[next]!;
183
+ next += 1;
184
+ const baseHz = noteHz(note.pitch, note.cents, tuning);
185
+ if (!(baseHz > 0) || baseHz >= 0.45 * sr) continue;
186
+ // Restrike: the same key damps its previous string.
187
+ for (const v of voices)
188
+ if (v.pitch === note.pitch) v.string.releaseAt(start - v.start);
189
+ // Voice cap: count only chords not yet released, so a releasing
190
+ // chord never shields the newer ringing ones from the cap.
191
+ const held = voices.filter(
192
+ (v) => !v.string.done && v.string.offAt > start - v.start,
193
+ );
194
+ const groups = new Set(held.map((v) => v.group));
195
+ if (groups.size >= cap) {
196
+ const oldest = Math.min(...groups);
197
+ for (const v of held)
198
+ if (v.group === oldest) v.string.releaseAt(start - v.start);
199
+ }
200
+ const { spec, velocity } = noteSpec(values, note, track);
201
+ const seed = seedHash(
202
+ `${note.id}:${note.startTick + (context.seedTick ?? 0)}:string`,
203
+ );
204
+ const strings: { hz: number; gain: number }[] = [];
205
+ // The course's tuning error belongs to the key, not to the strike: it
206
+ // is seeded by track and key (so every velocity of a key beats the
207
+ // same way) and centred, so the course's mean pitch is the table's.
208
+ const course = seedHash(`${track.id}:${note.pitch}:course`);
209
+ const jitters = Array.from({ length: unison }, (_, i) =>
210
+ unison > 1 ? (unit(course, i, 1) - 0.5) * 2 : 0,
211
+ );
212
+ const meanJitter = jitters.reduce((a, b) => a + b, 0) / unison;
213
+ for (let i = 0; i < unison; i += 1) {
214
+ const d = unison > 1 ? (i / (unison - 1) - 0.5) * detune : 0;
215
+ const jitter = jitters[i]! - meanJitter;
216
+ strings.push({
217
+ hz: baseHz * 2 ** ((d * 100 + jitter) / 1200),
218
+ gain: 1 / Math.sqrt(unison),
219
+ });
220
+ }
221
+ if (oct > 0 && note.pitch < octbelow) {
222
+ const up = noteHz(Math.min(127, note.pitch + 12), note.cents, tuning);
223
+ const hz = up > 0 ? up : baseHz * 2;
224
+ if (hz < 0.45 * sr)
225
+ strings.push({
226
+ hz: hz * 2 ** (((unit(course, unison, 1) - 0.5) * 4) / 1200),
227
+ gain: oct / Math.sqrt(unison),
228
+ });
229
+ }
230
+ const hold = holdSamples(note, start, context) / sr;
231
+ const cents = centsCurve(note, values, track);
232
+ strings.forEach((s, i) => {
233
+ const pan =
234
+ strings.length > 1 ? (i / (strings.length - 1) - 0.5) * spread : 0;
235
+ const string = new PluckString(
236
+ spec,
237
+ {
238
+ hz: s.hz,
239
+ velocity,
240
+ hold: Math.max(0.01, hold),
241
+ seed: seedHash(`${seed}:${i}`),
242
+ ...(cents ? { cents } : {}),
243
+ },
244
+ sr,
245
+ MAX_RING,
246
+ );
247
+ const gl = R ? s.gain * Math.sqrt(0.5 - pan / 2) * Math.SQRT2 : s.gain;
248
+ const gr = s.gain * Math.sqrt(0.5 + pan / 2) * Math.SQRT2;
249
+ voices.push({
250
+ string,
251
+ start,
252
+ pitch: note.pitch,
253
+ group: start,
254
+ gainL: gl,
255
+ gainR: gr,
256
+ ...(note.performance?.damp ? { damp: note.performance.damp } : {}),
257
+ });
258
+ });
259
+ }
260
+ for (const v of voices) {
261
+ if (v.string.done) continue;
262
+ const from = Math.max(at, v.start);
263
+ const n = at + count - from;
264
+ if (n <= 0) continue;
265
+ let scale = 1;
266
+ if (v.damp) {
267
+ const t = (from - v.start) / sr;
268
+ if (t > v.damp.from) scale = Math.exp(-(t - v.damp.from) / v.damp.tau);
269
+ }
270
+ v.string.process(L, from, n, v.gainL * scale, R, v.gainR * scale);
271
+ }
272
+ for (let i = voices.length - 1; i >= 0; i -= 1)
273
+ if (voices[i]!.string.done) voices.splice(i, 1);
274
+ }
275
+ finishTrack(L, R, values, track, context);
276
+ for (let i = 0; i < total; i += 1) {
277
+ dry[i] = dry[i]! + L[i]!;
278
+ if (dryR && R) dryR[i] = dryR[i]! + R[i]!;
279
+ }
280
+ }
281
+
282
+ /** Body, sympathetic strings, preset trim and track volume. */
283
+ function finishTrack(
284
+ L: Float64Array,
285
+ R: Float64Array | undefined,
286
+ values: StringValues,
287
+ track: Track,
288
+ context: EngineContext,
289
+ ): void {
290
+ const sr = context.sampleRate;
291
+ const sym = values.sym as number;
292
+ let wet: Float64Array | undefined;
293
+ if (sym > 0) {
294
+ const mono = R ? new Float64Array(L.length) : L;
295
+ if (R)
296
+ for (let i = 0; i < L.length; i += 1) mono[i] = 0.5 * (L[i]! + R[i]!);
297
+ // The key's own scale (a raga's or maqam's, quarter tones included),
298
+ // not the chord mode it harmonizes with.
299
+ const key = parseKey(context.score.key);
300
+ const root = 48 + (key?.tonic ?? 0);
301
+ const steps = key ? scaleSteps(key) : [0, 2, 4, 5, 7, 9, 11];
302
+ const hzs = symKeys(values.symtune as string, root, steps).map((k) =>
303
+ noteHz(k.key, k.cents || undefined, context.tuning),
304
+ );
305
+ wet = sympathetic(mono, hzs, 0.02 * sym, SYM_DECAY, 0.3, sr);
306
+ }
307
+ const body = bodyFor(values.body as string, values.size as number);
308
+ applyBody(L, body, sr);
309
+ if (R) applyBody(R, body, sr);
310
+ if (wet)
311
+ for (let i = 0; i < L.length; i += 1) {
312
+ L[i] = L[i]! + wet[i]!;
313
+ if (R) R[i] = R[i]! + wet[i]!;
314
+ }
315
+ // Preset trim (and its lane) and the track volume, every 32 samples.
316
+ const volume = Math.max(0, Math.min(1, track.volume ?? 1));
317
+ const volumeLane = track.volumeAutomation ?? [];
318
+ const gainLane = track.fxAutomation?.["string-gain" as FxLane];
319
+ const warp = context.warp;
320
+ for (let at = 0; at < L.length; at += 32) {
321
+ const tick = warp ? warp.tick(at) : at / context.samplesPerTick;
322
+ const trim =
323
+ gainLane && gainLane.length > 0
324
+ ? interpolateAutomation(gainLane, tick, values.gain as number)
325
+ : (values.gain as number);
326
+ const g =
327
+ Math.max(0, trim) *
328
+ volume *
329
+ (volumeLane.length > 0 ? interpolateAutomation(volumeLane, tick, 1) : 1);
330
+ const end = Math.min(L.length, at + 32);
331
+ for (let i = at; i < end; i += 1) {
332
+ L[i] = L[i]! * g;
333
+ if (R) R[i] = R[i]! * g;
334
+ }
335
+ }
336
+ }
337
+
338
+ /** True when the track's strings are panned apart (spread with 2+ strings). */
339
+ export function stringStereo(track: Track): boolean {
340
+ const values = resolveString(track.string);
341
+ const strings =
342
+ Math.max(1, Math.round(values.unison as number)) +
343
+ ((values.oct as number) > 0 ? 1 : 0);
344
+ return (values.spread as number) > 0 && strings > 1;
345
+ }
346
+
347
+ /** Digest of the preset table text (FNV-1a, hex), computed once. */
348
+ const STRING_PRESET_DIGEST = `strings:${fnv1a(stringPresetTableText())}`;
349
+
350
+ function fnv1a(text: string): string {
351
+ let h = 0x811c9dc5;
352
+ for (let i = 0; i < text.length; i += 1) {
353
+ h ^= text.charCodeAt(i);
354
+ h = Math.imul(h, 0x01000193) >>> 0;
355
+ }
356
+ return h.toString(16).padStart(8, "0");
357
+ }
358
+
359
+ export const STRING_ENGINE: InstrumentEngine = Object.freeze({
360
+ id: "string",
361
+ field: "string" as keyof Track,
362
+ render: (dry, dryR, notes, track, context) =>
363
+ renderStrings(dry, dryR, notes, track, context),
364
+ tailSeconds: stringTailSeconds,
365
+ stereo: stringStereo,
366
+ // The preset table joins the stem and live keys: a code-side preset
367
+ // retune never serves a stem rendered from the old table.
368
+ assetDigests: () => [STRING_PRESET_DIGEST],
369
+ });