@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
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The wind engine (0.6.1): renders a `wind` track's performed notes through
3
+ * `WindVoice` waveguides.
4
+ *
5
+ * - Lines: with no track `glide`, a note that starts while a single
6
+ * earlier note is still held, and that starts alone, slurs on from it
7
+ * (one breath, no re-attack) as a wind player tongues only at a new
8
+ * phrase; notes that start together stay separate voices, so one track
9
+ * can carry a horn-section chord. A track `glide` keeps its own meaning
10
+ * (`performNotes` has already chained or split the line).
11
+ * - Players: `players` > 1 is a section. The players are dealt over the
12
+ * tones that start together, top voice first, and each extra player on a
13
+ * tone is a seeded few cents off, a few milliseconds late and a little
14
+ * softer, as real section doublings are; a tone's players share its level
15
+ * (1/sqrt(n)).
16
+ * - At most `MAX_WIND_VOICES` sound at once; the oldest onset is stolen
17
+ * with an 80 ms raised-cosine fade (largest gain step -61 dB at 22.05 kHz).
18
+ *
19
+ * Deterministic: every random draw comes from `seededRandom`.
20
+ */
21
+ import type { PerformedNote } from "../../../core/expression.ts";
22
+ import {
23
+ MAX_WIND_VOICES,
24
+ WIND_LANE_PARAMS,
25
+ windPresetOf,
26
+ windSettings,
27
+ windTailSeconds,
28
+ type WindSettings,
29
+ } from "../../../core/resonators.ts";
30
+ import type { AutomationPoint, Track } from "../../../core/score.ts";
31
+ import { noteHz } from "../../../core/tuning.ts";
32
+ import { seededRandom } from "../dsp/rng.ts";
33
+ import { interpolateAutomation } from "../effects/common.ts";
34
+ import type { EngineContext, InstrumentEngine } from "../instruments.ts";
35
+ import { applyModalKnee } from "../resonators.ts";
36
+ import { warpedSpan } from "../warp.ts";
37
+ import { windTrim } from "./trim.ts";
38
+ import { WIND_BLOCK, WindVoice, type WindSegment } from "./voice.ts";
39
+
40
+ /** A gap this short between a line's end and the next onset still slurs. */
41
+ const SLUR_GAP_SECONDS = 0.01;
42
+
43
+ /** Fade applied to a stolen voice, seconds. */
44
+ export const WIND_STEAL_FADE = 0.08;
45
+ /** Section doublings: the widest detune (cents) and latest entry (s). */
46
+ const SECTION_CENTS = 9;
47
+ const SECTION_LATE = 0.018;
48
+
49
+ /** Breath push per articulation (accent and marcato blow harder). */
50
+ const PUSH: Readonly<Record<string, number>> = Object.freeze({
51
+ accent: 0.15,
52
+ marcato: 0.25,
53
+ ghost: -0.4,
54
+ });
55
+
56
+ type Line = {
57
+ start: number;
58
+ length: number;
59
+ notes: PerformedNote[];
60
+ segments: WindSegment[];
61
+ };
62
+
63
+ function span(
64
+ note: PerformedNote,
65
+ context: EngineContext,
66
+ ): { start: number; length: number } {
67
+ if (context.warp)
68
+ return warpedSpan(context.warp, note.startTick, note.durationTicks);
69
+ const perTick =
70
+ (60 * context.sampleRate) / (context.tempoBpm * context.ticksPerBeat);
71
+ return {
72
+ start: Math.max(0, Math.floor(note.startTick * perTick)),
73
+ length: Math.max(1, Math.floor(note.durationTicks * perTick)),
74
+ };
75
+ }
76
+
77
+ function laneOf(
78
+ track: Track,
79
+ param: string,
80
+ ): readonly AutomationPoint[] | undefined {
81
+ const lanes = track.fxAutomation as
82
+ Readonly<Record<string, readonly AutomationPoint[]>> | undefined;
83
+ const points = lanes?.[`wind-${param}`];
84
+ return points && points.length > 0 ? points : undefined;
85
+ }
86
+
87
+ /**
88
+ * Groups performed notes into breaths: chords stay separate voices, a lone
89
+ * note entering under a lone held note slurs on (when `legato`).
90
+ */
91
+ export function windLines(
92
+ notes: readonly PerformedNote[],
93
+ context: EngineContext,
94
+ legato: boolean,
95
+ ): Line[] {
96
+ const planned = notes
97
+ .map((note) => ({ note, ...span(note, context) }))
98
+ .filter((entry) => entry.start < context.samples)
99
+ .sort(
100
+ (a, b) =>
101
+ a.start - b.start ||
102
+ b.note.pitch - a.note.pitch ||
103
+ (a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0),
104
+ );
105
+ const lines: Line[] = [];
106
+ const slurGap = Math.round(SLUR_GAP_SECONDS * context.sampleRate);
107
+ let index = 0;
108
+ let previous: Line[] = [];
109
+ while (index < planned.length) {
110
+ const at = planned[index]!.start;
111
+ const group: typeof planned = [];
112
+ while (index < planned.length && planned[index]!.start === at)
113
+ group.push(planned[index++]!);
114
+ // A note that starts where the line ends (to within a few ms, so the
115
+ // tick-to-sample rounding of abutting notes counts) is held through:
116
+ // a typed line of back-to-back quarters slurs, a real rest re-tongues.
117
+ const held = previous.filter(
118
+ (line) => line.start + line.length + slurGap > at,
119
+ );
120
+ const only = group[0]!;
121
+ if (
122
+ legato &&
123
+ group.length === 1 &&
124
+ held.length === 1 &&
125
+ previous.length === 1 &&
126
+ !only.note.performance?.cents &&
127
+ only.note.articulation !== "staccato"
128
+ ) {
129
+ const line = held[0]!;
130
+ line.notes.push(only.note);
131
+ line.segments.push({
132
+ at: at - line.start,
133
+ hz: noteHz(only.note.pitch, only.note.cents, context.tuning),
134
+ });
135
+ line.length = only.start + only.length - line.start;
136
+ continue;
137
+ }
138
+ previous = group.map(({ note, start, length }) => ({
139
+ start,
140
+ length,
141
+ notes: [note],
142
+ segments: [{ at: 0, hz: noteHz(note.pitch, note.cents, context.tuning) }],
143
+ }));
144
+ lines.push(...previous);
145
+ }
146
+ return lines;
147
+ }
148
+
149
+ type Planned = {
150
+ voice: WindVoice;
151
+ start: number;
152
+ end: number;
153
+ steal: number;
154
+ level: number;
155
+ };
156
+
157
+ /** Renders a wind track's performed notes into `dry` (mono). */
158
+ export function renderWindTrack(
159
+ dry: Float64Array,
160
+ notes: readonly PerformedNote[],
161
+ track: Track,
162
+ context: EngineContext,
163
+ ): void {
164
+ const { sampleRate, samples } = context;
165
+ const tickAt = (index: number): number =>
166
+ context.warp ? context.warp.tick(index) : index / context.samplesPerTick;
167
+ const lanes = new Map<string, readonly AutomationPoint[]>();
168
+ for (const { param } of WIND_LANE_PARAMS) {
169
+ const points = laneOf(track, param);
170
+ if (points) lanes.set(param, points);
171
+ }
172
+ const base = windSettings(track.wind);
173
+ const trim = windTrim(windPresetOf(track.wind), sampleRate);
174
+ const players = Math.max(1, Math.round(base.players));
175
+ const seedTick = context.seedTick ?? 0;
176
+ const volumeLane = track.volumeAutomation ?? [];
177
+ const volume = Math.max(0, Math.min(1, track.volume ?? 1));
178
+ const gainAt = (index: number): number =>
179
+ volume *
180
+ (volumeLane.length > 0
181
+ ? interpolateAutomation(volumeLane, tickAt(index), 1)
182
+ : 1);
183
+ const lines = windLines(notes, context, track.glide === undefined);
184
+ const planned: Planned[] = [];
185
+ // Deal the section over each onset's tones, top voice first.
186
+ let i = 0;
187
+ while (i < lines.length) {
188
+ let j = i;
189
+ while (j < lines.length && lines[j]!.start === lines[i]!.start) j += 1;
190
+ const tones = lines.slice(i, j);
191
+ const counts = tones.map(() => 1);
192
+ for (let k = tones.length; k < players; k += 1)
193
+ counts[k % tones.length]! += 1;
194
+ tones.forEach((line, t) => {
195
+ const head = line.notes[0]!;
196
+ const seed = `${head.id}:${head.startTick + seedTick}`;
197
+ const push = PUSH[head.articulation ?? ""] ?? 0;
198
+ const count = counts[t]!;
199
+ const random = seededRandom(`${seed}:section`);
200
+ for (let player = 0; player < count; player += 1) {
201
+ const cents =
202
+ player === 0 ? 0 : SECTION_CENTS * (2 * random() - 1) || 0.5;
203
+ const late =
204
+ player === 0 ? 0 : Math.round(SECTION_LATE * random() * sampleRate);
205
+ const soft = player === 0 ? 1 : 0.8 + 0.15 * random();
206
+ const start = line.start + late;
207
+ if (start >= samples) continue;
208
+ const ratio = 2 ** (cents / 1200);
209
+ const bend = head.performance?.cents;
210
+ const voice = new WindVoice(
211
+ base,
212
+ {
213
+ segments: line.segments.map((s) => ({
214
+ at: s.at,
215
+ hz: s.hz * ratio,
216
+ })),
217
+ length: Math.max(1, line.length - late),
218
+ velocity: head.velocity,
219
+ seed: player === 0 ? seed : `${seed}:${player}`,
220
+ ...(push ? { push } : {}),
221
+ ...(bend ? { cents: bend } : {}),
222
+ ...(lanes.size > 0
223
+ ? {
224
+ lane: (param: string, t: number) => {
225
+ const points = lanes.get(param);
226
+ return points
227
+ ? interpolateAutomation(
228
+ points,
229
+ tickAt(start + t * sampleRate),
230
+ base[param as keyof WindSettings] as number,
231
+ )
232
+ : undefined;
233
+ },
234
+ }
235
+ : {}),
236
+ },
237
+ sampleRate,
238
+ trim,
239
+ );
240
+ planned.push({
241
+ voice,
242
+ start,
243
+ end: start + voice.endAt,
244
+ steal: Infinity,
245
+ level: soft / Math.sqrt(count),
246
+ });
247
+ }
248
+ });
249
+ i = j;
250
+ }
251
+ planned.sort((a, b) => a.start - b.start);
252
+ const fade = Math.max(1, Math.round(WIND_STEAL_FADE * sampleRate));
253
+ const sounding: Planned[] = [];
254
+ for (const entry of planned) {
255
+ for (let k = sounding.length - 1; k >= 0; k -= 1)
256
+ if (sounding[k]!.end <= entry.start) sounding.splice(k, 1);
257
+ while (sounding.length >= MAX_WIND_VOICES) {
258
+ const oldest = sounding.shift()!;
259
+ oldest.steal = entry.start;
260
+ oldest.end = Math.min(oldest.end, entry.start + fade);
261
+ }
262
+ sounding.push(entry);
263
+ }
264
+ const scratch = new Float64Array(WIND_BLOCK);
265
+ for (const { voice, start, end, steal, level } of planned) {
266
+ const stop = Math.min(samples, end);
267
+ let at = start;
268
+ while (at < stop) {
269
+ const count = Math.min(WIND_BLOCK, stop - at);
270
+ scratch.fill(0, 0, count);
271
+ const alive = voice.process(scratch, 0, count);
272
+ const gain = gainAt(at) * level;
273
+ for (let k = 0; k < count; k += 1) {
274
+ const index = at + k;
275
+ let g = gain;
276
+ if (index >= steal)
277
+ g *=
278
+ index - steal >= fade
279
+ ? 0
280
+ : 0.5 + 0.5 * Math.cos((Math.PI * (index - steal)) / fade);
281
+ dry[index]! += scratch[k]! * g;
282
+ }
283
+ if (!alive) break;
284
+ at += count;
285
+ }
286
+ }
287
+ // The modal engine's soft knee: transparent below -3 dBFS, so a chord or
288
+ // section does not clip; causal and stateless, so renders stay prefix-stable.
289
+ applyModalKnee(dry, samples);
290
+ }
291
+
292
+ /** The wind engine as registered in `src/audio/instruments.ts`. */
293
+ export const WIND_ENGINE: InstrumentEngine = Object.freeze({
294
+ id: "wind",
295
+ field: "wind",
296
+ render(dry, _dryR, notes, track, context) {
297
+ renderWindTrack(dry, notes, track, context);
298
+ },
299
+ tailSeconds: (track: Track) => windTailSeconds(track.wind),
300
+ stereo: () => false,
301
+ assetDigests: (track: Track) => [`winds:1:${windPresetOf(track.wind)}`],
302
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Filters the wind voice needs beyond dawg's `Biquad` (lpf, hpf, bpf):
3
+ * the RBJ cookbook peak and high shelf for mutes and the dynamics shelf
4
+ * (Robert Bristow-Johnson, "Cookbook formulae for audio EQ biquad filter
5
+ * coefficients"). A new class so the shared `Biquad` stays untouched.
6
+ */
7
+ import { thiranFor } from "../dsp/interp.ts";
8
+
9
+ export type WindFilterType = "lpf" | "hpf" | "bpf" | "peak" | "highshelf";
10
+
11
+ export class WindBiquad {
12
+ private b0 = 1;
13
+ private b1 = 0;
14
+ private b2 = 0;
15
+ private a1 = 0;
16
+ private a2 = 0;
17
+ private z1 = 0;
18
+ private z2 = 0;
19
+
20
+ set(
21
+ type: WindFilterType,
22
+ hz: number,
23
+ q: number,
24
+ gainDb: number,
25
+ sampleRate: number,
26
+ ): this {
27
+ const f = Math.min(Math.max(hz, 10), 0.45 * sampleRate);
28
+ const w = (2 * Math.PI * f) / sampleRate;
29
+ const cos = Math.cos(w);
30
+ const alpha = Math.sin(w) / (2 * Math.max(0.05, q));
31
+ const A = 10 ** (gainDb / 40);
32
+ let b0 = 1;
33
+ let b1 = 0;
34
+ let b2 = 0;
35
+ let a0 = 1;
36
+ let a1 = 0;
37
+ let a2 = 0;
38
+ switch (type) {
39
+ case "lpf":
40
+ b0 = (1 - cos) / 2;
41
+ b1 = 1 - cos;
42
+ b2 = b0;
43
+ a0 = 1 + alpha;
44
+ a1 = -2 * cos;
45
+ a2 = 1 - alpha;
46
+ break;
47
+ case "hpf":
48
+ b0 = (1 + cos) / 2;
49
+ b1 = -(1 + cos);
50
+ b2 = b0;
51
+ a0 = 1 + alpha;
52
+ a1 = -2 * cos;
53
+ a2 = 1 - alpha;
54
+ break;
55
+ case "bpf":
56
+ b0 = alpha;
57
+ b2 = -alpha;
58
+ a0 = 1 + alpha;
59
+ a1 = -2 * cos;
60
+ a2 = 1 - alpha;
61
+ break;
62
+ case "peak":
63
+ b0 = 1 + alpha * A;
64
+ b1 = -2 * cos;
65
+ b2 = 1 - alpha * A;
66
+ a0 = 1 + alpha / A;
67
+ a1 = -2 * cos;
68
+ a2 = 1 - alpha / A;
69
+ break;
70
+ case "highshelf": {
71
+ const sq = 2 * Math.sqrt(A) * alpha;
72
+ b0 = A * (A + 1 + (A - 1) * cos + sq);
73
+ b1 = -2 * A * (A - 1 + (A + 1) * cos);
74
+ b2 = A * (A + 1 + (A - 1) * cos - sq);
75
+ a0 = A + 1 - (A - 1) * cos + sq;
76
+ a1 = 2 * (A - 1 - (A + 1) * cos);
77
+ a2 = A + 1 - (A - 1) * cos - sq;
78
+ break;
79
+ }
80
+ }
81
+ this.b0 = b0 / a0;
82
+ this.b1 = b1 / a0;
83
+ this.b2 = b2 / a0;
84
+ this.a1 = a1 / a0;
85
+ this.a2 = a2 / a0;
86
+ return this;
87
+ }
88
+
89
+ process(x: number): number {
90
+ const y = this.b0 * x + this.z1;
91
+ this.z1 = this.b1 * x - this.a1 * y + this.z2;
92
+ this.z2 = this.b2 * x - this.a2 * y;
93
+ return y;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * A delay line read before it is written (waveguide loops read the bore
99
+ * output, then push the new input): an integer ring plus a first-order
100
+ * Thiran allpass for the fraction in [0.5, 1.5), its phase delay exact at
101
+ * the loop's fundamental (`thiranFor` in src/audio/dsp/interp.ts).
102
+ */
103
+ export class LoopDelay {
104
+ private readonly buffer: Float64Array;
105
+ private readonly mask: number;
106
+ private write = 0;
107
+ private whole = 1;
108
+ private a = 0;
109
+ private u1 = 0;
110
+ private y1 = 0;
111
+
112
+ constructor(maxDelay: number) {
113
+ let size = 8;
114
+ while (size < Math.ceil(maxDelay) + 8) size *= 2;
115
+ this.buffer = new Float64Array(size);
116
+ this.mask = size - 1;
117
+ }
118
+
119
+ /** Total delay in samples (at least 1.5); `w` rad/sample where it is exact. */
120
+ set(delay: number, w = 0): void {
121
+ const total = Math.min(Math.max(delay, 1.5), this.mask - 3);
122
+ const whole = Math.floor(total - 0.5);
123
+ this.whole = whole;
124
+ this.a = thiranFor(total - whole, w);
125
+ }
126
+
127
+ read(): number {
128
+ const u = this.buffer[(this.write - this.whole) & this.mask]!;
129
+ const y = this.a * u + this.u1 - this.a * this.y1;
130
+ this.u1 = u;
131
+ this.y1 = y;
132
+ return y;
133
+ }
134
+
135
+ push(value: number): void {
136
+ this.buffer[this.write] = value;
137
+ this.write = (this.write + 1) & this.mask;
138
+ }
139
+ }
140
+
141
+ /** Phase delay in samples of the one-pole lowpass y += (1 - p)(x - y). */
142
+ export function onePoleDelay(p: number, w: number): number {
143
+ if (w < 1e-9) return p / (1 - p);
144
+ return Math.atan2(p * Math.sin(w), 1 - p * Math.cos(w)) / w;
145
+ }
146
+
147
+ /** Phase delay of the DC blocker y = x - x1 + R y1 at `w` (negative: it leads). */
148
+ export function dcBlockDelay(r: number, w: number): number {
149
+ // H = (1 - z^-1) / (1 - R z^-1)
150
+ const num = Math.atan2(Math.sin(w), 1 - Math.cos(w));
151
+ const den = Math.atan2(r * Math.sin(w), 1 - r * Math.cos(w));
152
+ return -(num - den) / w;
153
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pitch measurement for wind calibration and tests (not on the render
3
+ * path): Hann-windowed, zero-padded FFT, the largest bin within a window
4
+ * around the expected fundamental, refined by a parabola on the log
5
+ * magnitude (J. O. Smith, "Quadratic Interpolation of Spectral Peaks").
6
+ */
7
+ import { fftInPlace } from "../dsp/fft.ts";
8
+
9
+ export type PitchReading = Readonly<{
10
+ /** Measured fundamental, Hz. */
11
+ hz: number;
12
+ /** Fundamental level over the strongest partial, dB (<= 0). */
13
+ fundamentalDb: number;
14
+ }>;
15
+
16
+ /** Magnitude spectrum of x[from..from+length), zero-padded to `size`. */
17
+ function spectrum(
18
+ x: Float64Array,
19
+ from: number,
20
+ length: number,
21
+ size: number,
22
+ ): Float64Array {
23
+ const re = new Float64Array(size);
24
+ const im = new Float64Array(size);
25
+ for (let i = 0; i < length; i += 1) {
26
+ const win = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (length - 1));
27
+ re[i] = (x[from + i] ?? 0) * win;
28
+ }
29
+ fftInPlace(re, im);
30
+ const mag = new Float64Array(size / 2);
31
+ for (let k = 0; k < size / 2; k += 1)
32
+ mag[k] = Math.hypot(re[k]!, im[k]!) + 1e-300;
33
+ return mag;
34
+ }
35
+
36
+ /**
37
+ * The fundamental near `expected` Hz in x between `fromSec` and `toSec`,
38
+ * searched within +-`spanCents`.
39
+ */
40
+ export function measurePitch(
41
+ x: Float64Array,
42
+ sampleRate: number,
43
+ expected: number,
44
+ fromSec = 0.2,
45
+ toSec = 0.9,
46
+ spanCents = 150,
47
+ ): PitchReading {
48
+ const from = Math.floor(fromSec * sampleRate);
49
+ const length = Math.max(
50
+ 256,
51
+ Math.min(x.length - from, Math.floor((toSec - fromSec) * sampleRate)),
52
+ );
53
+ let size = 1;
54
+ while (size < length * 8) size *= 2;
55
+ const mag = spectrum(x, from, length, size);
56
+ const binHz = sampleRate / size;
57
+ const lo = Math.max(
58
+ 1,
59
+ Math.floor((expected * 2 ** (-spanCents / 1200)) / binHz),
60
+ );
61
+ const hi = Math.min(
62
+ mag.length - 2,
63
+ Math.ceil((expected * 2 ** (spanCents / 1200)) / binHz),
64
+ );
65
+ let best = lo;
66
+ for (let k = lo; k <= hi; k += 1) if (mag[k]! > mag[best]!) best = k;
67
+ const a = Math.log(mag[best - 1]!);
68
+ const b = Math.log(mag[best]!);
69
+ const c = Math.log(mag[best + 1]!);
70
+ const denom = a - 2 * b + c;
71
+ const delta = denom === 0 ? 0 : (0.5 * (a - c)) / denom;
72
+ let peak = 0;
73
+ for (let k = 1; k < mag.length; k += 1) peak = Math.max(peak, mag[k]!);
74
+ return {
75
+ hz: (best + clampHalf(delta)) * binHz,
76
+ fundamentalDb: 20 * Math.log10(mag[best]! / peak),
77
+ };
78
+ }
79
+
80
+ function clampHalf(d: number): number {
81
+ return d < -0.5 ? -0.5 : d > 0.5 ? 0.5 : d;
82
+ }
83
+
84
+ /** Spectral centroid of x over a window, Hz. */
85
+ export function spectralCentroid(
86
+ x: Float64Array,
87
+ sampleRate: number,
88
+ fromSec: number,
89
+ toSec: number,
90
+ ): number {
91
+ const from = Math.floor(fromSec * sampleRate);
92
+ const length = Math.floor((toSec - fromSec) * sampleRate);
93
+ let size = 1;
94
+ while (size < length) size *= 2;
95
+ const mag = spectrum(x, from, length, size);
96
+ let num = 0;
97
+ let den = 0;
98
+ for (let k = 1; k < mag.length; k += 1) {
99
+ const hz = (k * sampleRate) / size;
100
+ num += hz * mag[k]!;
101
+ den += mag[k]!;
102
+ }
103
+ return den > 0 ? num / den : 0;
104
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pitch and level trims for the wind voice, looked up from the generated
3
+ * table (`trims.ts`, written by `bun scripts/calibrate-winds.ts`). The
4
+ * waveguides' residual pitch error (loop-filter group delay the closed
5
+ * forms miss, the lip resonance pull) is deterministic, so each preset
6
+ * carries a correction in cents per semitone of its range at each render
7
+ * rate, interpolated linearly in pitch, plus an output level and a per-semitone
8
+ * loudness curve that put the sustain RMS at WIND_HOUSE_RMS_DB at velocity
9
+ * 0.8 (the engine's soft knee guards the peaks). Rates without a table use the nearest
10
+ * one (pitch then within a few cents).
11
+ */
12
+ import type { WindPresetName } from "../../../core/winds.ts";
13
+ import { WIND_TRIM_TABLE } from "./trims.ts";
14
+ import type { WindTrim } from "./voice.ts";
15
+
16
+ /** House sustain level of every preset at velocity 0.8, dBFS RMS. */
17
+ export const WIND_HOUSE_RMS_DB = -14;
18
+
19
+ /** Rates the calibration writes tables for. */
20
+ export const WIND_TRIM_RATES = Object.freeze([22_050, 44_100, 48_000]);
21
+
22
+ export type WindTrimRow = Readonly<{
23
+ /** MIDI note of `cents[0]`; one entry per semitone. */
24
+ lo: number;
25
+ /** Correction in hundredths of a cent. */
26
+ cents: readonly number[];
27
+ level: number;
28
+ /**
29
+ * Loudness curve in hundredths of a dB per semitone on the `cents` grid
30
+ * (absent: flat), so the sustain RMS stays on the house level across the
31
+ * range.
32
+ */
33
+ gains?: readonly number[];
34
+ }>;
35
+
36
+ function nearestRate(sampleRate: number): number {
37
+ let best = WIND_TRIM_RATES[0]!;
38
+ for (const rate of WIND_TRIM_RATES)
39
+ if (Math.abs(rate - sampleRate) < Math.abs(best - sampleRate)) best = rate;
40
+ return best;
41
+ }
42
+
43
+ /** The trim row of `preset` at `sampleRate`, undefined before calibration. */
44
+ export function windTrimRow(
45
+ preset: WindPresetName,
46
+ sampleRate: number,
47
+ ): WindTrimRow | undefined {
48
+ return WIND_TRIM_TABLE[nearestRate(sampleRate)]?.[preset];
49
+ }
50
+
51
+ function interpolate(lo: number, grid: readonly number[], hz: number): number {
52
+ const midi = 69 + 12 * Math.log2(hz / 440) - lo;
53
+ const last = grid.length - 1;
54
+ if (last < 0) return 0;
55
+ if (midi <= 0) return grid[0]! / 100;
56
+ if (midi >= last) return grid[last]! / 100;
57
+ const i = Math.floor(midi);
58
+ const u = midi - i;
59
+ return ((1 - u) * grid[i]! + u * grid[i + 1]!) / 100;
60
+ }
61
+
62
+ /** Interpolated correction of a row at `hz`, in cents. */
63
+ export function rowCents(row: WindTrimRow, hz: number): number {
64
+ return interpolate(row.lo, row.cents, hz);
65
+ }
66
+
67
+ /** Interpolated loudness gain of a row at `hz`, as a factor. */
68
+ export function rowGain(row: WindTrimRow, hz: number): number {
69
+ return row.gains ? 10 ** (interpolate(row.lo, row.gains, hz) / 20) : 1;
70
+ }
71
+
72
+ export function windTrim(preset: WindPresetName, sampleRate: number): WindTrim {
73
+ const row = windTrimRow(preset, sampleRate);
74
+ if (!row) return { cents: () => 0, level: 1 };
75
+ return {
76
+ cents: (hz) => rowCents(row, hz),
77
+ level: row.level,
78
+ gain: (hz) => rowGain(row, hz),
79
+ };
80
+ }