@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,119 @@
1
+ /**
2
+ * Loop filters for the string waveguides (ported from the 0.6 strings
3
+ * prototype, proto/strings/dsp.ts): the two-decay loss filter and the
4
+ * dispersion allpass fit. Pure functions; the delay interpolation comes from
5
+ * the shared contract (src/audio/dsp/interp.ts).
6
+ */
7
+ import { allpassDelay } from "../dsp/interp.ts";
8
+
9
+ export const clamp = (x: number, lo: number, hi: number): number =>
10
+ x < lo ? lo : x > hi ? hi : x;
11
+
12
+ /** Phase delay (samples) of the one-pole low-pass 1/(1 - p z^-1) at w. */
13
+ export function onePoleDelay(p: number, w: number): number {
14
+ if (w < 1e-9) return p / (1 - p);
15
+ return Math.atan2(p * Math.sin(w), 1 - p * Math.cos(w)) / w;
16
+ }
17
+
18
+ /**
19
+ * Loop-loss filter g(1-p)/(1 - p z^-1) from two decay times (Valimaki et
20
+ * al. 1996): the per-period gain at f0 gives T60 `t0`, at `fh` gives `th`.
21
+ * The pole is capped at 0.95 and the gain at 0.99995, so the loop's DC gain
22
+ * (g) stays below 1 and no setting can grow.
23
+ */
24
+ export function designLoss(
25
+ f0: number,
26
+ t0: number,
27
+ fh: number,
28
+ th: number,
29
+ sr: number,
30
+ ): { g: number; p: number } {
31
+ const w0 = (2 * Math.PI * f0) / sr;
32
+ const wh = (2 * Math.PI * Math.min(fh, 0.45 * sr)) / sr;
33
+ const g0 = 0.001 ** (1 / (t0 * f0));
34
+ const gh = 0.001 ** (1 / (th * f0));
35
+ const want = gh / g0;
36
+ const ratio = (p: number) =>
37
+ Math.sqrt(
38
+ (1 - 2 * p * Math.cos(w0) + p * p) / (1 - 2 * p * Math.cos(wh) + p * p),
39
+ );
40
+ let lo = 0;
41
+ let hi = 0.95;
42
+ if (wh <= w0 || want >= 1) hi = 0;
43
+ for (let i = 0; i < 50 && hi > 0; i += 1) {
44
+ const mid = (lo + hi) / 2;
45
+ if (ratio(mid) > want) lo = mid;
46
+ else hi = mid;
47
+ }
48
+ // DC gain g must stay below 1, so cap the pole where the f0 gain would
49
+ // need g > 0.99995: high keys keep their designed fundamental T60 and
50
+ // give up some of the requested treble damping (design review: high
51
+ // nylon and harp notes decayed several times too fast).
52
+ const gFor = (q: number) =>
53
+ (g0 * Math.sqrt(1 - 2 * q * Math.cos(w0) + q * q)) / (1 - q);
54
+ let p = (lo + hi) / 2;
55
+ if (gFor(p) > 0.99995) {
56
+ let a = 0;
57
+ let b = p;
58
+ for (let i = 0; i < 50; i += 1) {
59
+ const mid = (a + b) / 2;
60
+ if (gFor(mid) > 0.99995) b = mid;
61
+ else a = mid;
62
+ }
63
+ p = a;
64
+ }
65
+ const g = Math.min(0.99995, gFor(p));
66
+ return { g, p };
67
+ }
68
+
69
+ /** Inharmonicity B for the `stiff` knob: 1e-6 x 400^stiff, 0 at 0. */
70
+ export function stiffnessB(stiff: number): number {
71
+ return stiff > 0 ? 1e-6 * 400 ** stiff : 0;
72
+ }
73
+
74
+ /**
75
+ * Dispersion allpass cascade (Van Duyne and Smith 1994): one shared
76
+ * coefficient fitted by golden-section search so partial k lands at
77
+ * k f1 sqrt((1 + B k^2) / (1 + B)).
78
+ */
79
+ export function designDispersion(
80
+ f1: number,
81
+ B: number,
82
+ sections: number,
83
+ sr: number,
84
+ maxDelay: number,
85
+ ): number {
86
+ if (!(B > 0) || sections === 0) return 0;
87
+ const w1 = (2 * Math.PI * f1) / sr;
88
+ const ks: number[] = [];
89
+ for (let k = 2; k <= 16; k += 1) {
90
+ const fk = k * f1 * Math.sqrt((1 + B * k * k) / (1 + B));
91
+ if (fk > 0.42 * sr) break;
92
+ ks.push(k);
93
+ }
94
+ if (ks.length === 0) return 0;
95
+ const cost = (a: number): number => {
96
+ if (sections * allpassDelay(a, w1) > maxDelay) return 1e9;
97
+ let e = 0;
98
+ for (const k of ks) {
99
+ const fk = k * f1 * Math.sqrt((1 + B * k * k) / (1 + B));
100
+ const wk = (2 * Math.PI * fk) / sr;
101
+ const target = (2 * Math.PI * k) / wk - (2 * Math.PI) / w1;
102
+ const got = sections * (allpassDelay(a, wk) - allpassDelay(a, w1));
103
+ e += (got - target) ** 2;
104
+ }
105
+ return e;
106
+ };
107
+ let lo = -0.95;
108
+ let hi = 0;
109
+ const gr = (Math.sqrt(5) - 1) / 2;
110
+ let c = hi - gr * (hi - lo);
111
+ let d = lo + gr * (hi - lo);
112
+ for (let i = 0; i < 60; i += 1) {
113
+ if (cost(c) < cost(d)) hi = d;
114
+ else lo = c;
115
+ c = hi - gr * (hi - lo);
116
+ d = lo + gr * (hi - lo);
117
+ }
118
+ return (lo + hi) / 2;
119
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Measurement helpers for the string engine's tests (ported from the 0.6
3
+ * design prototype): FFT pitch with parabolic interpolation, a partial's
4
+ * T60 by regression and the spectral centroid.
5
+ */
6
+ import { createScore, type Track } from "../../../core/score.ts";
7
+ import type { PerformedNote } from "../../../core/expression.ts";
8
+ import type { TuningTable } from "../../../core/tuning.ts";
9
+ import { fft } from "../wavetable.ts";
10
+ import type { EngineContext } from "../instruments.ts";
11
+ import { renderStrings } from "./engine.ts";
12
+
13
+ /** 12-TET Hz of a MIDI key. */
14
+ export const tetHz = (pitch: number): number => 440 * 2 ** ((pitch - 69) / 12);
15
+
16
+ export const SR = 22050;
17
+ const TPB = 480;
18
+ const BPM = 120;
19
+ const SPT = (SR * 60) / (BPM * TPB);
20
+
21
+ export type TestNote = {
22
+ pitch: number;
23
+ start?: number;
24
+ seconds?: number;
25
+ velocity?: number;
26
+ performance?: PerformedNote["performance"];
27
+ };
28
+
29
+ /** Renders notes on a string track (mono, before effects). */
30
+ export function render(
31
+ string: Record<string, number | string>,
32
+ notes: readonly TestNote[],
33
+ seconds: number,
34
+ tuning?: TuningTable,
35
+ ): Float64Array {
36
+ const track = {
37
+ id: "s",
38
+ name: "s",
39
+ instrument: "string",
40
+ volume: 1,
41
+ string,
42
+ } as unknown as Track;
43
+ const samples = Math.round(seconds * SR);
44
+ const context: EngineContext = {
45
+ score: createScore({ bars: 1, tracks: [] }),
46
+ sampleRate: SR,
47
+ samples,
48
+ samplesPerTick: SPT,
49
+ tempoBpm: BPM,
50
+ ticksPerBeat: TPB,
51
+ ...(tuning ? { tuning } : {}),
52
+ };
53
+ const performed = notes.map((n, i) => ({
54
+ id: `n${i}`,
55
+ trackId: "s",
56
+ pitch: n.pitch,
57
+ velocity: n.velocity ?? 0.8,
58
+ startTick: Math.round(((n.start ?? 0) * SR) / SPT),
59
+ durationTicks: Math.round(((n.seconds ?? seconds) * SR) / SPT),
60
+ ...(n.performance ? { performance: n.performance } : {}),
61
+ })) as unknown as PerformedNote[];
62
+ const dry = new Float64Array(samples);
63
+ renderStrings(dry, undefined, performed, track, context);
64
+ return dry;
65
+ }
66
+
67
+ function spectrum(x: Float64Array, start: number, len: number, pad: number) {
68
+ let n = 1;
69
+ while (n < len * pad) n *= 2;
70
+ const re = new Float64Array(n);
71
+ const im = new Float64Array(n);
72
+ for (let i = 0; i < len && start + i < x.length; i += 1)
73
+ re[i] =
74
+ x[start + i]! * (0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (len - 1)));
75
+ fft(re, im);
76
+ const mag = new Float64Array(n / 2);
77
+ for (let i = 0; i < n / 2; i += 1) mag[i] = Math.hypot(re[i]!, im[i]!);
78
+ return mag;
79
+ }
80
+
81
+ function peakNear(mag: Float64Array, binHz: number, hz: number, cents = 60) {
82
+ const lo = Math.max(1, Math.floor((hz * 2 ** (-cents / 1200)) / binHz));
83
+ const hi = Math.min(
84
+ mag.length - 2,
85
+ Math.ceil((hz * 2 ** (cents / 1200)) / binHz),
86
+ );
87
+ let best = lo;
88
+ for (let i = lo; i <= hi; i += 1) if (mag[i]! > mag[best]!) best = i;
89
+ const a = Math.log(mag[best - 1]! + 1e-30);
90
+ const b = Math.log(mag[best]! + 1e-30);
91
+ const c = Math.log(mag[best + 1]! + 1e-30);
92
+ const off = (a - c) / (2 * (a - 2 * b + c));
93
+ return (best + (Number.isFinite(off) ? off : 0)) * binHz;
94
+ }
95
+
96
+ export const centsOff = (hz: number, ref: number): number =>
97
+ 1200 * Math.log2(hz / ref);
98
+
99
+ /**
100
+ * Measured frequency (Hz) of the partial near hz: Hann window over
101
+ * [t0, t0 + 1 s) as in the design spec, zero-padded at least 8x.
102
+ */
103
+ export function measurePitch(
104
+ x: Float64Array,
105
+ hz: number,
106
+ t0 = 0.3,
107
+ cents = 60,
108
+ ): number {
109
+ const start = Math.round(t0 * SR);
110
+ const len = Math.min(x.length - start, SR);
111
+ const mag = spectrum(x, start, len, 8);
112
+ let n = 1;
113
+ while (n < len * 8) n *= 2;
114
+ return peakNear(mag, SR / n, hz, cents);
115
+ }
116
+
117
+ function partialEnvelope(
118
+ x: Float64Array,
119
+ hz: number,
120
+ frame: number,
121
+ hop: number,
122
+ ) {
123
+ const frames = Math.max(0, Math.floor((x.length - frame) / hop));
124
+ const out = new Float64Array(frames);
125
+ const w = (2 * Math.PI * hz) / SR;
126
+ const cos = new Float64Array(frame);
127
+ const sin = new Float64Array(frame);
128
+ for (let i = 0; i < frame; i += 1) {
129
+ const win = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (frame - 1));
130
+ cos[i] = win * Math.cos(w * i);
131
+ sin[i] = win * Math.sin(w * i);
132
+ }
133
+ for (let f = 0; f < frames; f += 1) {
134
+ let re = 0;
135
+ let im = 0;
136
+ for (let i = 0; i < frame; i += 1) {
137
+ const v = x[f * hop + i]!;
138
+ re += v * cos[i]!;
139
+ im -= v * sin[i]!;
140
+ }
141
+ out[f] = Math.hypot(re, im);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** T60 of a partial: regression of dB from -5 to -40 below its peak. */
147
+ export function partialT60(x: Float64Array, hz: number): number {
148
+ const frame = Math.max(
149
+ 256,
150
+ Math.min(4096, 2 ** Math.ceil(Math.log2((SR / hz) * 6))),
151
+ );
152
+ const hop = frame >> 1;
153
+ const env = partialEnvelope(x, hz, frame, hop);
154
+ let peak = 0;
155
+ let pi = 0;
156
+ for (let i = 0; i < env.length; i += 1)
157
+ if (env[i]! > peak) {
158
+ peak = env[i]!;
159
+ pi = i;
160
+ }
161
+ const xs: number[] = [];
162
+ const ys: number[] = [];
163
+ for (let i = pi; i < env.length; i += 1) {
164
+ const db = 20 * Math.log10(env[i]! / peak + 1e-30);
165
+ if (db > -5) continue;
166
+ if (db < -40) break;
167
+ xs.push((i * hop) / SR);
168
+ ys.push(db);
169
+ }
170
+ if (xs.length < 3) return NaN;
171
+ const mx = xs.reduce((s, v) => s + v, 0) / xs.length;
172
+ const my = ys.reduce((s, v) => s + v, 0) / ys.length;
173
+ let sxy = 0;
174
+ let sxx = 0;
175
+ for (let i = 0; i < xs.length; i += 1) {
176
+ sxy += (xs[i]! - mx) * (ys[i]! - my);
177
+ sxx += (xs[i]! - mx) ** 2;
178
+ }
179
+ return -60 / (sxy / sxx);
180
+ }
181
+
182
+ /** Magnitude-weighted spectral centroid (Hz) of x[start, start+len). */
183
+ export function centroid(x: Float64Array, start: number, len: number): number {
184
+ const mag = spectrum(x, start, len, 1);
185
+ let num = 0;
186
+ let den = 0;
187
+ for (let i = 1; i < mag.length; i += 1) {
188
+ num += ((i * SR) / len) * mag[i]!;
189
+ den += mag[i]!;
190
+ }
191
+ return num / den;
192
+ }
193
+
194
+ export function peakOf(x: Float64Array): number {
195
+ let p = 0;
196
+ for (const v of x) p = Math.max(p, Math.abs(v));
197
+ return p;
198
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * A plucked or struck string: a single-delay-loop waveguide (Karplus-Strong
3
+ * as extended by Jaffe and Smith 1983), ported from the 0.6 strings
4
+ * prototype. The loop holds an integer delay, an exact first-order Thiran
5
+ * fraction, a two-decay loss filter, an optional dispersion cascade
6
+ * (stiffness) and an optional jawari bridge (sitar, tanpura buzz).
7
+ *
8
+ * Jawari (strings review, dsp blocker #2): the bridge is a lattice
9
+ * first-order allpass whose delay shortens with the string's one-sided
10
+ * displacement max(0, y - profile). An allpass has magnitude exactly 1 at
11
+ * every coefficient, so the buzz moves energy upward without adding DC; an
12
+ * in-loop DC blocker removes what the time-varying delay leaves. The pitch
13
+ * lock retunes the main fraction for the mean shortening, so a buzzing note
14
+ * stays on its target Hz.
15
+ */
16
+ import { allpassDelay, thiranFor } from "../dsp/interp.ts";
17
+ import { unit } from "../dsp/rng.ts";
18
+ import {
19
+ clamp,
20
+ designDispersion,
21
+ designLoss,
22
+ onePoleDelay,
23
+ stiffnessB,
24
+ } from "./loop.ts";
25
+
26
+ export const C4 = 261.6255653005986;
27
+ /** Control-rate step for pitch moves, the same as dawg's lanes. */
28
+ const CONTROL = 32;
29
+ /** Jawari bridge segment: rest length in samples and the shortest. */
30
+ const JAW_BASE = 1.5;
31
+ const JAW_SHORTEST = 0.5;
32
+ /**
33
+ * Pitch-lock gain on the mean bridge shortening: the buzz also pulls the
34
+ * fundamental flat by moving energy upward, so the lock over-compensates
35
+ * by this frozen constant (calibrated at 22.05 kHz over keys 55-81 so the
36
+ * fundamental holds within 1 cent from 0.3 s; design review asked for a
37
+ * constant, not runtime state).
38
+ */
39
+ const JAW_LOCK = 1.3;
40
+
41
+ /** Phase delay (samples) at w of the DC blocker (1 - z^-1)/(1 - R z^-1). */
42
+ function dcBlockDelay(R: number, w: number): number {
43
+ if (w < 1e-9) return 0;
44
+ const zero = Math.atan2(Math.sin(w), 1 - Math.cos(w));
45
+ const pole = Math.atan2(R * Math.sin(w), 1 - R * Math.cos(w));
46
+ return -(zero - pole) / w;
47
+ }
48
+
49
+ export type Exciter = "pick" | "finger" | "hammer" | "noise";
50
+
51
+ /** Resolved plucked-string parameters for one note. */
52
+ export type PluckSpec = Readonly<{
53
+ /** T60 (s) of the fundamental at C4. */
54
+ decay: number;
55
+ /** Decay key tracking: T60 x (f/C4)^-track. */
56
+ track: number;
57
+ /** 0..1 high-frequency loss. */
58
+ damp: number;
59
+ /** Stiffness knob; inharmonicity B = 1e-6 x 400^stiff. */
60
+ stiff: number;
61
+ /** Pluck position, fraction of the string from the bridge. */
62
+ pos: number;
63
+ exciter: Exciter;
64
+ /** 0..1 excitation brightness at full velocity. */
65
+ bright: number;
66
+ /** 0..1 noise share of the excitation. */
67
+ noise: number;
68
+ /** T60 (s) after note-off. */
69
+ release: number;
70
+ /** 0..1 bridge buzz. */
71
+ jawari: number;
72
+ /** 0 = bridge force; else magnetic pickup position (fraction). */
73
+ pickup: number;
74
+ }>;
75
+
76
+ export type PluckNote = Readonly<{
77
+ /** noteHz(pitch, cents, table) from the 0.5 tunings. */
78
+ hz: number;
79
+ /** 0..1 after the velocity curve and the preset's sensitivity. */
80
+ velocity: number;
81
+ /** Seconds to note-off (after articulation and pedal). */
82
+ hold: number;
83
+ /** Seed for the excitation noise (counter-based, src/audio/dsp/rng.ts). */
84
+ seed: number;
85
+ /** Pitch offset in cents at `t` seconds (bend, glide, vibrato). */
86
+ cents?: (t: number) => number;
87
+ }>;
88
+
89
+ function minHz(note: PluckNote, seconds: number): number {
90
+ let lo = note.hz;
91
+ if (note.cents)
92
+ for (let t = 0; t <= seconds; t += 0.01)
93
+ lo = Math.min(lo, note.hz * 2 ** (note.cents(t) / 1200));
94
+ return lo;
95
+ }
96
+
97
+ /** One plucked or struck string, streamed in blocks. */
98
+ export class PluckString {
99
+ private readonly buf: Float64Array;
100
+ private readonly mask: number;
101
+ private w = 0;
102
+ private N = 1;
103
+ private a = 0;
104
+ private tx1 = 0;
105
+ private ty1 = 0;
106
+ private readonly lg: number;
107
+ private readonly lp: number;
108
+ private ly = 0;
109
+ private readonly da: number;
110
+ private readonly dn: number;
111
+ private readonly dx: Float64Array;
112
+ private readonly dy: Float64Array;
113
+ private readonly jaw: number;
114
+ private jv = 0;
115
+ private jsum = 0;
116
+ private jcount = 0;
117
+ private jmean = 0;
118
+ private hz: number;
119
+ private readonly dcR: number;
120
+ private dcx = 0;
121
+ private dcy = 0;
122
+ private readonly exc: Float64Array;
123
+ private n = 0;
124
+ /** Sample index of note-off; a restrike or steal moves it earlier. */
125
+ offAt: number;
126
+ private readonly rel: number;
127
+ private readonly pickupTap: number;
128
+ private readonly base: number;
129
+ private quiet = 0;
130
+ done = false;
131
+
132
+ constructor(
133
+ readonly spec: PluckSpec,
134
+ readonly note: PluckNote,
135
+ readonly sr: number,
136
+ readonly maxSeconds = 12,
137
+ ) {
138
+ const f0 = note.hz;
139
+ const t0 = spec.decay * (f0 / C4) ** -spec.track;
140
+ const th = t0 / (1 + 99 * spec.damp * spec.damp);
141
+ // The high decay point sits at least 3 f0 up, so a high note's loss
142
+ // filter is not fitted between its own first partials (T60 accuracy).
143
+ const loss = designLoss(f0, t0, Math.max(3000, 3 * f0), th, sr);
144
+ this.lg = loss.g;
145
+ this.lp = loss.p;
146
+ const P = sr / f0;
147
+ this.jaw = spec.jawari;
148
+ this.base = spec.jawari > 0 ? JAW_BASE : 0;
149
+ this.dcR = spec.jawari > 0 ? Math.exp((-2 * Math.PI * 10) / sr) : 0;
150
+ const B = stiffnessB(spec.stiff);
151
+ const sections = B > 0 ? 4 : 0;
152
+ const w0 = (2 * Math.PI * f0) / sr;
153
+ this.dn = sections;
154
+ this.da = designDispersion(
155
+ f0,
156
+ B,
157
+ sections,
158
+ sr,
159
+ Math.max(0, P - 2.5 - onePoleDelay(this.lp, w0) - this.base),
160
+ );
161
+ this.dx = new Float64Array(sections);
162
+ this.dy = new Float64Array(sections);
163
+ const lowest = minHz(note, note.hold + spec.release);
164
+ let size = 16;
165
+ while (size < sr / lowest + 16) size <<= 1;
166
+ this.buf = new Float64Array(size);
167
+ this.mask = size - 1;
168
+ this.hz = f0;
169
+ this.retune(f0, true);
170
+ this.exc = excitation(spec, note, sr, P);
171
+ this.offAt = Math.round(note.hold * sr);
172
+ this.rel = 0.001 ** (1 / (Math.max(0.005, spec.release) * sr));
173
+ this.pickupTap =
174
+ spec.pickup > 0 ? Math.max(1, Math.round(spec.pickup * P)) : 0;
175
+ }
176
+
177
+ /** Loop delay for `hz`: integer N plus an exact Thiran fraction. */
178
+ private retune(hz: number, first = false): void {
179
+ const sr = this.sr;
180
+ this.hz = hz;
181
+ const w0 = (2 * Math.PI * hz) / sr;
182
+ let rest = sr / hz - onePoleDelay(this.lp, w0);
183
+ if (this.jaw > 0) {
184
+ // Pitch lock: the bridge allpass at its mean shortening and the
185
+ // in-loop DC blocker are both counted in the loop length.
186
+ const frac = this.base - JAW_LOCK * this.jmean;
187
+ rest -= allpassDelay((1 - frac) / (1 + frac), w0);
188
+ rest -= dcBlockDelay(this.dcR, w0);
189
+ }
190
+ if (this.dn > 0) rest -= this.dn * allpassDelay(this.da, w0);
191
+ const N = Math.max(1, Math.floor(rest - 0.5));
192
+ const D = clamp(rest - N, 0.5, 1.5);
193
+ const a = thiranFor(D, w0);
194
+ if (!first && N !== this.N) {
195
+ // Restate the allpass memory for the new read position.
196
+ const m = this.mask;
197
+ this.tx1 = this.buf[(this.w - 1 - N) & m]!;
198
+ const pos = this.w - 1 - N - D;
199
+ const i0 = Math.floor(pos);
200
+ const fr = pos - i0;
201
+ this.ty1 = this.buf[i0 & m]! * (1 - fr) + this.buf[(i0 + 1) & m]! * fr;
202
+ }
203
+ this.N = N;
204
+ this.a = a;
205
+ }
206
+
207
+ /** Moves note-off to `at` samples from the string's start (restrike, steal). */
208
+ releaseAt(at: number): void {
209
+ if (at < this.offAt) this.offAt = Math.max(this.n, at);
210
+ }
211
+
212
+ /**
213
+ * Adds `count` samples x `gain` into out[offset..] (and x `gainR` into
214
+ * `right` when given); returns false once silent.
215
+ */
216
+ process(
217
+ out: Float64Array,
218
+ offset: number,
219
+ count: number,
220
+ gain = 1,
221
+ right?: Float64Array,
222
+ gainR = 0,
223
+ ): boolean {
224
+ if (this.done) return false;
225
+ const { buf, mask, lp, exc, dx, dy, da, dn, jaw } = this;
226
+ const lgain = this.lg * (1 - lp);
227
+ const cents = this.note.cents;
228
+ const sr = this.sr;
229
+ let peak = 0;
230
+ for (let i = 0; i < count; i += 1) {
231
+ const n = this.n;
232
+ if (n % CONTROL === 0 && (cents || jaw > 0)) {
233
+ if (jaw > 0 && this.jcount > 0) {
234
+ // Mean bridge shortening over the last control block (pitch lock).
235
+ this.jmean += 0.25 * (this.jsum / this.jcount - this.jmean);
236
+ this.jsum = 0;
237
+ this.jcount = 0;
238
+ }
239
+ this.retune(
240
+ cents ? this.note.hz * 2 ** (cents(n / sr) / 1200) : this.hz,
241
+ );
242
+ }
243
+ const d = buf[(this.w - this.N) & mask]!;
244
+ const t = this.a * d + this.tx1 - this.a * this.ty1;
245
+ this.tx1 = d;
246
+ this.ty1 = t;
247
+ this.ly = lgain * t + lp * this.ly;
248
+ let s = this.ly;
249
+ for (let k = 0; k < dn; k += 1) {
250
+ const y = da * s + dx[k]! - da * dy[k]!;
251
+ dx[k] = s;
252
+ dy[k] = y;
253
+ s = y;
254
+ }
255
+ if (jaw > 0) {
256
+ // Bridge: a lattice allpass whose delay shortens with the
257
+ // one-sided displacement (bridge profile at rest = 0).
258
+ const short = Math.min(
259
+ JAW_BASE - JAW_SHORTEST,
260
+ jaw * 40 * Math.max(0, s),
261
+ );
262
+ this.jsum += short;
263
+ this.jcount += 1;
264
+ const frac = JAW_BASE - short;
265
+ const k = (1 - frac) / (1 + frac);
266
+ // Lattice form of (k + z^-1)/(1 + k z^-1): |H| = 1 for any k(n).
267
+ const y = k * s + this.jv;
268
+ this.jv = s - k * y;
269
+ // In-loop DC blocker (10 Hz).
270
+ const blocked = y - this.dcx + this.dcR * this.dcy;
271
+ this.dcx = y;
272
+ this.dcy = blocked;
273
+ s = blocked;
274
+ }
275
+ if (n >= this.offAt) {
276
+ const ramp = Math.min(1, (n - this.offAt) / (0.005 * sr));
277
+ s *= 1 - (1 - this.rel) * ramp;
278
+ }
279
+ const x = n < exc.length ? exc[n]! : 0;
280
+ const y = x + s;
281
+ buf[this.w & mask] = y;
282
+ const o = this.pickupTap ? y - buf[(this.w - this.pickupTap) & mask]! : y;
283
+ this.w += 1;
284
+ this.n += 1;
285
+ out[offset + i] = out[offset + i]! + gain * o;
286
+ if (right) right[offset + i] = right[offset + i]! + gainR * o;
287
+ const ay = Math.abs(y);
288
+ if (ay > peak) peak = ay;
289
+ }
290
+ // Tail limit: after note-off, stop once 2048 samples stay below -100 dBFS.
291
+ if (this.n > this.offAt && peak < 1e-5) {
292
+ this.quiet += count;
293
+ if (this.quiet >= 2048) this.done = true;
294
+ } else this.quiet = 0;
295
+ if (this.n >= this.maxSeconds * sr) this.done = true;
296
+ return !this.done;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Excitation: a raised-cosine pulse (pick, finger, hammer) or noise,
302
+ * shaped by the pluck-position comb (Jaffe and Smith 1983) and a velocity
303
+ * brightness low-pass, normalised to a loop RMS of 0.25 x velocity gain.
304
+ */
305
+ function excitation(
306
+ spec: PluckSpec,
307
+ note: PluckNote,
308
+ sr: number,
309
+ P: number,
310
+ ): Float64Array {
311
+ const vel = clamp(note.velocity, 0, 1);
312
+ const b = clamp(spec.bright * (0.3 + 0.7 * vel), 0, 1);
313
+ const lerp = (x: number, y: number) => x + (y - x) * b;
314
+ const width =
315
+ spec.exciter === "pick"
316
+ ? lerp(1.2e-3, 0.1e-3)
317
+ : spec.exciter === "finger"
318
+ ? lerp(5e-3, 0.9e-3)
319
+ : spec.exciter === "hammer"
320
+ ? lerp(4e-3, 0.3e-3)
321
+ : P / sr;
322
+ const L = Math.max(
323
+ 2,
324
+ Math.min(
325
+ Math.round(width * sr),
326
+ Math.round(spec.exciter === "noise" ? P : 0.6 * P),
327
+ ),
328
+ );
329
+ const M = Math.max(1, Math.round(spec.pos * P));
330
+ const len = L + M + 1;
331
+ const raw = new Float64Array(len);
332
+ const noisy = spec.exciter === "noise";
333
+ for (let i = 0; i < L; i += 1) {
334
+ const env = 0.5 - 0.5 * Math.cos((2 * Math.PI * (i + 0.5)) / L);
335
+ const pulse = noisy ? 0 : env;
336
+ const r = 2 * unit(note.seed, i, 0) - 1;
337
+ raw[i] = pulse * (1 - spec.noise) + (noisy ? 1 : spec.noise) * env * r;
338
+ }
339
+ const e = new Float64Array(len);
340
+ for (let i = 0; i < len; i += 1) e[i] = raw[i]! - (i >= M ? raw[i - M]! : 0);
341
+ // Velocity brightness: one-pole low-pass 300 Hz .. 13.6 kHz.
342
+ const fc = Math.min(0.45 * sr, 300 * 2 ** (5.5 * b));
343
+ const al = 1 - Math.exp((-2 * Math.PI * fc) / sr);
344
+ let y = 0;
345
+ let energy = 0;
346
+ for (let i = 0; i < len; i += 1) {
347
+ y += al * (e[i]! - y);
348
+ e[i] = y;
349
+ energy += y * y;
350
+ }
351
+ const gain = (0.25 * (0.15 + 0.85 * vel)) / Math.sqrt(energy / P + 1e-30);
352
+ for (let i = 0; i < len; i += 1) e[i] = e[i]! * gain;
353
+ return e;
354
+ }