@hraness/dawg 0.4.0 → 0.5.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 (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. package/tui/keys.ts +72 -0
@@ -0,0 +1,660 @@
1
+ /**
2
+ * The song master (core/master.ts) as DSP: EQ, glue compressor, tape,
3
+ * width and a true-peak limiter on the summed mix, then the loudness
4
+ * target. Everything is offline over whole buffers in float64 with a fixed
5
+ * order of operations, so masters render deterministically.
6
+ *
7
+ * Loops are processed as if they played forever: the chain runs over
8
+ * warm-up repeats and keeps one steady-state pass, so the loop seam is
9
+ * seamless. One-shot renders start from silence.
10
+ */
11
+ import {
12
+ MASTER_LIMITS,
13
+ masterActive,
14
+ type SongMaster,
15
+ } from "../../core/master.ts";
16
+ import type { FxValues } from "../../core/params.ts";
17
+ import {
18
+ gainToDb,
19
+ integratedLoudness,
20
+ interSamplePeaks,
21
+ truePeakGain,
22
+ } from "./loudness.ts";
23
+
24
+ export type MasterReport = Readonly<{
25
+ /** Gain the target applied (into the limiter when it is on), dB. */
26
+ gainDb: number;
27
+ /** Integrated loudness after the master, LUFS. */
28
+ integrated: number;
29
+ /** True peak after the master, dBTP. */
30
+ truePeak: number;
31
+ target?: number;
32
+ /** Whether the target was met within 0.5 LU. */
33
+ reached?: boolean;
34
+ /** Limiter passes the target search used (0 without one). */
35
+ runs?: number;
36
+ }>;
37
+
38
+ export type MasterOutput = Readonly<{
39
+ left: Float64Array;
40
+ right: Float64Array;
41
+ report: MasterReport;
42
+ }>;
43
+
44
+ /** Seconds of warm-up a loop gets before the pass that is kept. */
45
+ const LOOP_WARMUP_SECONDS = 2;
46
+ const TARGET_TOLERANCE_LU = 0.05;
47
+ const TARGET_ITERATIONS = 8;
48
+ /** LU per dB of drive below which the limiter is on its plateau. */
49
+ const PLATEAU_SLOPE = 0.05;
50
+ /** Largest single step the bracketing search takes, dB. */
51
+ const MAX_TARGET_STEP = 12;
52
+ /** Extra samples so true-peak interpolation past the lookahead is real. */
53
+ const TRUE_PEAK_PAD = 18;
54
+
55
+ /**
56
+ * Masters the first `frames` samples of a mix. Returns undefined for a
57
+ * master that changes nothing, so callers keep the plain mix (and dawg 0.4
58
+ * renders stay byte-identical).
59
+ */
60
+ export function applyMaster(
61
+ mixL: Float64Array,
62
+ mixR: Float64Array,
63
+ frames: number,
64
+ sampleRate: number,
65
+ master: SongMaster | undefined,
66
+ loop: boolean,
67
+ ): MasterOutput | undefined {
68
+ if (!masterActive(master) || frames <= 0) return undefined;
69
+ const song = master!;
70
+ const lookahead = song.limiter
71
+ ? Math.max(
72
+ 1,
73
+ Math.round((Number(song.limiter.lookahead) * sampleRate) / 1000),
74
+ )
75
+ : 0;
76
+ // A loop is laid out as warm-up repeats, the kept pass and enough of the
77
+ // next pass for the limiter to look ahead into; a one-shot is the mix
78
+ // followed by silence the limiter can look into.
79
+ const warm = loop
80
+ ? Math.ceil((LOOP_WARMUP_SECONDS * sampleRate) / frames) * frames
81
+ : 0;
82
+ const length = warm + frames + lookahead + TRUE_PEAK_PAD;
83
+ const left = new Float64Array(length);
84
+ const right = new Float64Array(length);
85
+ for (let index = 0; index < length; index += 1) {
86
+ const source = loop ? index % frames : index;
87
+ if (source < frames) {
88
+ left[index] = mixL[source]!;
89
+ right[index] = mixR[source]!;
90
+ }
91
+ }
92
+ if (song.eq) applyEq(left, right, song.eq, sampleRate);
93
+ if (song.glue) applyGlue(left, right, song.glue, sampleRate);
94
+ if (song.tape) applyTape(left, right, song.tape, sampleRate);
95
+ if (song.width) applyWidth(left, right, song.width, sampleRate);
96
+ const kept = (buffer: Float64Array) => buffer.subarray(warm, warm + frames);
97
+ let gainDb = 0;
98
+ let runs = 0;
99
+ let outL: Float64Array;
100
+ let outR: Float64Array;
101
+ if (song.limiter) {
102
+ const limiter = new Limiter(left, right, song.limiter, sampleRate);
103
+ const drive = Number(song.limiter.gain);
104
+ let lastRun: number | undefined;
105
+ const run = (gain: number) => {
106
+ if (gain === lastRun) return;
107
+ limiter.run(gain);
108
+ lastRun = gain;
109
+ runs += 1;
110
+ };
111
+ if (song.target === undefined) {
112
+ gainDb = drive;
113
+ } else {
114
+ // The linear guess is a pure function of the mix, so the same mix
115
+ // always solves to the same drive (and the same bytes).
116
+ const before = integratedLoudness(
117
+ kept(left),
118
+ kept(right),
119
+ sampleRate,
120
+ loop,
121
+ );
122
+ gainDb = Number.isFinite(before)
123
+ ? seekTarget(song.target, song.target - before, (gain) => {
124
+ run(gain);
125
+ return integratedLoudness(
126
+ kept(limiter.left),
127
+ kept(limiter.right),
128
+ sampleRate,
129
+ loop,
130
+ );
131
+ })
132
+ : 0;
133
+ }
134
+ run(gainDb);
135
+ outL = kept(limiter.left);
136
+ outR = kept(limiter.right);
137
+ trimToCeiling(
138
+ outL,
139
+ outR,
140
+ Number(song.limiter.ceiling),
141
+ loop,
142
+ song.limiter.truepeak !== false,
143
+ );
144
+ } else {
145
+ outL = kept(left);
146
+ outR = kept(right);
147
+ if (song.target !== undefined) {
148
+ const before = integratedLoudness(outL, outR, sampleRate, loop);
149
+ if (Number.isFinite(before)) {
150
+ const peak = gainToDb(truePeakGain(outL, outR, loop));
151
+ gainDb = clampGain(
152
+ Math.min(song.target - before, MASTER_LIMITS.safeCeiling - peak),
153
+ );
154
+ scale(outL, outR, 10 ** (gainDb / 20));
155
+ }
156
+ }
157
+ }
158
+ const integrated = integratedLoudness(outL, outR, sampleRate, loop);
159
+ const report: MasterReport = {
160
+ gainDb,
161
+ integrated,
162
+ truePeak: gainToDb(truePeakGain(outL, outR, loop)),
163
+ ...(song.target === undefined
164
+ ? {}
165
+ : {
166
+ target: song.target,
167
+ reached: Math.abs(integrated - song.target) <= 0.5,
168
+ }),
169
+ ...(song.limiter && song.target !== undefined ? { runs } : {}),
170
+ };
171
+ return Object.freeze({ left: outL, right: outR, report });
172
+ }
173
+
174
+ function clampGain(gainDb: number): number {
175
+ return Math.max(
176
+ -MASTER_LIMITS.maxTargetGain,
177
+ Math.min(MASTER_LIMITS.maxTargetGain, gainDb),
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Finds the limiter drive that meets a loudness target. Loudness rises
183
+ * monotonically (and ever more slowly) with drive, so the search first
184
+ * brackets the target from the linear guess, stepping outward, then closes
185
+ * the bracket with Illinois regula falsi. It stops early on the plateau
186
+ * where the limiter is past its ceiling and more drive buys almost no
187
+ * loudness. Every step depends only on the inputs, so the same mix always
188
+ * solves to the same drive; the closest run wins (ties go to less drive).
189
+ */
190
+ export function seekTarget(
191
+ target: number,
192
+ guess: number,
193
+ measure: (gainDb: number) => number,
194
+ ): number {
195
+ type Point = { gain: number; loudness: number };
196
+ const seen: Point[] = [];
197
+ const probe = (gain: number): Point => {
198
+ const point = { gain, loudness: measure(gain) };
199
+ seen.push(point);
200
+ return point;
201
+ };
202
+ const off = (point: Point) => Math.abs(point.loudness - target);
203
+ const best = () =>
204
+ seen.reduce((a, b) =>
205
+ off(b) < off(a) || (off(b) === off(a) && b.gain < a.gain) ? b : a,
206
+ );
207
+ let point = probe(clampGain(guess));
208
+ if (!Number.isFinite(point.loudness)) return 0;
209
+ let low = point.loudness < target ? point : undefined;
210
+ let high = point.loudness < target ? undefined : point;
211
+ // Bracket: step away from the target side, estimating the slope from the
212
+ // last two runs (1 dB per dB at first) and overshooting a little, since
213
+ // the curve only flattens further out.
214
+ while (
215
+ seen.length < TARGET_ITERATIONS &&
216
+ off(point) > TARGET_TOLERANCE_LU &&
217
+ (low === undefined || high === undefined)
218
+ ) {
219
+ const previous = seen.length >= 2 ? seen[seen.length - 2]! : undefined;
220
+ const slope =
221
+ previous === undefined
222
+ ? 1
223
+ : (point.loudness - previous.loudness) / (point.gain - previous.gain);
224
+ // Below the target on the plateau, more drive cannot reach it; above
225
+ // it, a flat stretch only means a bigger step down.
226
+ if (
227
+ high === undefined &&
228
+ previous !== undefined &&
229
+ !(slope >= PLATEAU_SLOPE)
230
+ )
231
+ break;
232
+ const step = Math.min(
233
+ MAX_TARGET_STEP,
234
+ Math.max(0.25, (1.25 * off(point)) / Math.max(slope, PLATEAU_SLOPE)),
235
+ );
236
+ const next = clampGain(point.gain + (high === undefined ? step : -step));
237
+ if (next === point.gain) break;
238
+ point = probe(next);
239
+ if (point.loudness < target) low = point;
240
+ else high = point;
241
+ }
242
+ if (
243
+ low !== undefined &&
244
+ high !== undefined &&
245
+ off(point) > TARGET_TOLERANCE_LU
246
+ ) {
247
+ let fLow = low.loudness - target;
248
+ let fHigh = high.loudness - target;
249
+ let side = 0;
250
+ while (seen.length < TARGET_ITERATIONS && high.gain - low.gain > 0.01) {
251
+ const gain = (low.gain * fHigh - high.gain * fLow) / (fHigh - fLow);
252
+ point = probe(gain);
253
+ const f = point.loudness - target;
254
+ if (Math.abs(f) <= TARGET_TOLERANCE_LU) break;
255
+ if (f < 0) {
256
+ low = point;
257
+ fLow = f;
258
+ if (side === -1) fHigh /= 2;
259
+ side = -1;
260
+ } else {
261
+ high = point;
262
+ fHigh = f;
263
+ if (side === 1) fLow /= 2;
264
+ side = 1;
265
+ }
266
+ }
267
+ }
268
+ return best().gain;
269
+ }
270
+
271
+ function scale(left: Float64Array, right: Float64Array, gain: number): void {
272
+ for (let index = 0; index < left.length; index += 1) {
273
+ left[index]! *= gain;
274
+ right[index]! *= gain;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * A final static trim so the measured peak (true peak, or sample peak when
280
+ * the limiter's true-peak detection is off) never passes the ceiling.
281
+ */
282
+ function trimToCeiling(
283
+ left: Float64Array,
284
+ right: Float64Array,
285
+ ceilingDb: number,
286
+ loop: boolean,
287
+ truePeak: boolean,
288
+ ): void {
289
+ const ceiling = 10 ** (ceilingDb / 20);
290
+ let peak = 0;
291
+ if (truePeak) peak = truePeakGain(left, right, loop);
292
+ else
293
+ for (let index = 0; index < left.length; index += 1)
294
+ peak = Math.max(peak, Math.abs(left[index]!), Math.abs(right[index]!));
295
+ if (peak > ceiling) scale(left, right, ceiling / peak);
296
+ }
297
+
298
+ /** A biquad section, transposed direct form II. */
299
+ class Section {
300
+ private z1 = 0;
301
+ private z2 = 0;
302
+
303
+ constructor(
304
+ private readonly b0: number,
305
+ private readonly b1: number,
306
+ private readonly b2: number,
307
+ private readonly a1: number,
308
+ private readonly a2: number,
309
+ ) {}
310
+
311
+ process(input: number): number {
312
+ const output = this.b0 * input + this.z1;
313
+ this.z1 = this.b1 * input - this.a1 * output + this.z2;
314
+ this.z2 = this.b2 * input - this.a2 * output;
315
+ return output;
316
+ }
317
+
318
+ run(buffer: Float64Array): void {
319
+ for (let index = 0; index < buffer.length; index += 1)
320
+ buffer[index] = this.process(buffer[index]!);
321
+ }
322
+ }
323
+
324
+ type SectionKind = "low" | "high" | "bell" | "lowpass" | "highpass";
325
+
326
+ /** RBJ Audio EQ Cookbook coefficients (shelves at slope S = 1). */
327
+ export function cookbook(
328
+ kind: SectionKind,
329
+ frequency: number,
330
+ sampleRate: number,
331
+ gainDb = 0,
332
+ q = Math.SQRT1_2,
333
+ ): Section {
334
+ const f = Math.max(10, Math.min(frequency, sampleRate * 0.45));
335
+ const w0 = (2 * Math.PI * f) / sampleRate;
336
+ const cos = Math.cos(w0);
337
+ const sin = Math.sin(w0);
338
+ const a = 10 ** (gainDb / 40);
339
+ let b0: number;
340
+ let b1: number;
341
+ let b2: number;
342
+ let a0: number;
343
+ let a1: number;
344
+ let a2: number;
345
+ if (kind === "bell") {
346
+ const alpha = sin / (2 * q);
347
+ b0 = 1 + alpha * a;
348
+ b1 = -2 * cos;
349
+ b2 = 1 - alpha * a;
350
+ a0 = 1 + alpha / a;
351
+ a1 = -2 * cos;
352
+ a2 = 1 - alpha / a;
353
+ } else if (kind === "low" || kind === "high") {
354
+ const alpha = sin / Math.SQRT2;
355
+ const root = 2 * Math.sqrt(a) * alpha;
356
+ const sign = kind === "low" ? 1 : -1;
357
+ b0 = a * (a + 1 - sign * (a - 1) * cos + root);
358
+ b1 = sign * 2 * a * (a - 1 - sign * (a + 1) * cos);
359
+ b2 = a * (a + 1 - sign * (a - 1) * cos - root);
360
+ a0 = a + 1 + sign * (a - 1) * cos + root;
361
+ a1 = -sign * 2 * (a - 1 + sign * (a + 1) * cos);
362
+ a2 = a + 1 + sign * (a - 1) * cos - root;
363
+ } else {
364
+ const alpha = sin / (2 * q);
365
+ const low = kind === "lowpass";
366
+ b0 = low ? (1 - cos) / 2 : (1 + cos) / 2;
367
+ b1 = low ? 1 - cos : -(1 + cos);
368
+ b2 = b0;
369
+ a0 = 1 + alpha;
370
+ a1 = -2 * cos;
371
+ a2 = 1 - alpha;
372
+ }
373
+ return new Section(b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0);
374
+ }
375
+
376
+ /** Low shelf, two bells and a high shelf; 0 dB bands are skipped. */
377
+ function applyEq(
378
+ left: Float64Array,
379
+ right: Float64Array,
380
+ values: FxValues,
381
+ sampleRate: number,
382
+ ): void {
383
+ const value = (key: string) => Number(values[key]);
384
+ const bands: [SectionKind, number, number, number][] = [
385
+ ["low", value("low"), value("lowfreq"), Math.SQRT1_2],
386
+ ["bell", value("bell1"), value("bell1freq"), value("bell1q")],
387
+ ["bell", value("bell2"), value("bell2freq"), value("bell2q")],
388
+ ["high", value("high"), value("highfreq"), Math.SQRT1_2],
389
+ ];
390
+ for (const [kind, gain, frequency, q] of bands) {
391
+ if (gain === 0) continue;
392
+ for (const channel of [left, right])
393
+ cookbook(kind, frequency, sampleRate, gain, q).run(channel);
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Stereo-linked feed-forward bus compressor (Giannoulis, Massberg & Reiss,
399
+ * JAES 2012): peak level of the louder channel (optionally high-passed so
400
+ * the bass does not pump the mix), a soft-knee gain computer in dB and
401
+ * attack/release smoothing of the gain reduction, then make-up and a
402
+ * parallel dry/wet mix.
403
+ */
404
+ function applyGlue(
405
+ left: Float64Array,
406
+ right: Float64Array,
407
+ values: FxValues,
408
+ sampleRate: number,
409
+ ): void {
410
+ const threshold = Number(values.threshold);
411
+ const ratio = Number(values.ratio);
412
+ const knee = Number(values.knee);
413
+ const mix = Number(values.mix);
414
+ const hpf = Number(values.hpf);
415
+ const slope = 1 - 1 / ratio;
416
+ // Auto make-up: half the static reduction a full-scale peak gets, a fixed
417
+ // estimate of the average, so glue on/off compares near level-matched and
418
+ // the result does not depend on the buffer (loops stay periodic).
419
+ const auto =
420
+ values.auto !== false ? (slope * Math.max(0, -threshold)) / 2 : 0;
421
+ const makeup = 10 ** ((Number(values.makeup) + auto) / 20);
422
+ const coefficient = (ms: number) =>
423
+ Math.exp(-1 / Math.max(1, (ms / 1000) * sampleRate));
424
+ const attack = coefficient(Number(values.attack));
425
+ const release = coefficient(Number(values.release));
426
+ const sideL = hpf > 0 ? cookbook("highpass", hpf, sampleRate) : undefined;
427
+ const sideR = hpf > 0 ? cookbook("highpass", hpf, sampleRate) : undefined;
428
+ let reduction = 0;
429
+ for (let index = 0; index < left.length; index += 1) {
430
+ const l = left[index]!;
431
+ const r = right[index]!;
432
+ const detectL = sideL ? sideL.process(l) : l;
433
+ const detectR = sideR ? sideR.process(r) : r;
434
+ const level =
435
+ 20 * Math.log10(Math.max(Math.abs(detectL), Math.abs(detectR)) + 1e-12);
436
+ const over = level - threshold;
437
+ let target = 0;
438
+ if (2 * over >= knee) target = slope * over;
439
+ else if (knee > 0 && 2 * over > -knee)
440
+ target = (slope * (over + knee / 2) ** 2) / (2 * knee);
441
+ reduction =
442
+ target > reduction
443
+ ? attack * reduction + (1 - attack) * target
444
+ : release * reduction + (1 - release) * target;
445
+ const gain = 10 ** (-reduction / 20) * makeup;
446
+ left[index] = l * (1 - mix) + l * gain * mix;
447
+ right[index] = r * (1 - mix) + r * gain * mix;
448
+ }
449
+ }
450
+
451
+ /** ln(cosh(u)), the antiderivative of tanh, without overflow. */
452
+ function logCosh(u: number): number {
453
+ const a = Math.abs(u);
454
+ return a + Math.log1p(Math.exp(-2 * a)) - Math.LN2;
455
+ }
456
+
457
+ /**
458
+ * Tape-style saturation: tanh(drive·x + bias), centred and scaled so full
459
+ * scale stays full scale (quiet parts come up, peaks stay put). First-order
460
+ * antiderivative anti-aliasing (Parker et al., DAFx 2016; Bilbao et al.,
461
+ * IEEE SPL 2017) keeps the harmonics from folding back, a 5 Hz DC blocker
462
+ * removes the offset the bias leaves, and `tone` rolls off the top
463
+ * (20 kHz is off: the stage is flat to 0.4 fs).
464
+ */
465
+ /** Pole of the filter that flattens the tape stage's ADAA top end. */
466
+ const TAPE_COMPENSATION = 0.85;
467
+
468
+ function applyTape(
469
+ left: Float64Array,
470
+ right: Float64Array,
471
+ values: FxValues,
472
+ sampleRate: number,
473
+ ): void {
474
+ const drive = 10 ** (Number(values.drive) / 20);
475
+ const bias = Number(values.bias);
476
+ const tone = Number(values.tone);
477
+ const mix = Number(values.mix);
478
+ const centre = Math.tanh(bias);
479
+ const norm = Math.max(
480
+ Math.tanh(drive + bias) - centre,
481
+ centre - Math.tanh(bias - drive),
482
+ );
483
+ const dcPole = Math.exp((-2 * Math.PI * 5) / sampleRate);
484
+ const toneOn = tone < Math.min(20_000, sampleRate * 0.45);
485
+ for (const channel of [left, right]) {
486
+ let previousU = bias;
487
+ let previousF = logCosh(bias);
488
+ let previousX = 0;
489
+ let compensated = 0;
490
+ let dcIn = 0;
491
+ let dcOut = 0;
492
+ const lowpass = toneOn ? cookbook("lowpass", tone, sampleRate) : undefined;
493
+ for (let index = 0; index < channel.length; index += 1) {
494
+ const x = channel[index]!;
495
+ const u = drive * x + bias;
496
+ const f = logCosh(u);
497
+ const delta = u - previousU;
498
+ const shaped =
499
+ Math.abs(delta) > 1e-9
500
+ ? (f - previousF) / delta
501
+ : Math.tanh((u + previousU) / 2);
502
+ previousU = u;
503
+ previousF = f;
504
+ let wet = (shaped - centre) / norm;
505
+ // DC blocker: y[n] = x[n] - x[n-1] + R·y[n-1].
506
+ const blocked = wet - dcIn + dcPole * dcOut;
507
+ dcIn = wet;
508
+ dcOut = blocked;
509
+ wet = lowpass ? lowpass.process(blocked) : blocked;
510
+ // The dry path gets the same half-sample delay the ADAA stage has.
511
+ const dry = (x + previousX) / 2;
512
+ previousX = x;
513
+ // Both paths carry the two-tap average ADAA amounts to in its linear
514
+ // region (|cos(pi f/fs)|: -6 dB at fs/3); a one-pole compensator
515
+ // (1 + r)/(1 + r z^-1) undoes it to within 0.3 dB up to 0.4 fs.
516
+ const mixed = dry * (1 - mix) + wet * mix;
517
+ compensated =
518
+ (1 + TAPE_COMPENSATION) * mixed - TAPE_COMPENSATION * compensated;
519
+ channel[index] = compensated;
520
+ }
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Mid/side width with mono bass: the side signal is scaled by `width` and
526
+ * high-passed at `mono` with a 4th-order Linkwitz-Riley filter; the mid
527
+ * runs through the matching all-pass so both keep the same phase.
528
+ */
529
+ function applyWidth(
530
+ left: Float64Array,
531
+ right: Float64Array,
532
+ values: FxValues,
533
+ sampleRate: number,
534
+ ): void {
535
+ const width = Number(values.width);
536
+ const mono = Number(values.mono);
537
+ if (width === 1 && mono === 0) return;
538
+ const crossover = mono > 0;
539
+ const lr4 = (kind: "lowpass" | "highpass") => [
540
+ cookbook(kind, mono, sampleRate),
541
+ cookbook(kind, mono, sampleRate),
542
+ ];
543
+ const midLow = crossover ? lr4("lowpass") : [];
544
+ const midHigh = crossover ? lr4("highpass") : [];
545
+ const sideHigh = crossover ? lr4("highpass") : [];
546
+ for (let index = 0; index < left.length; index += 1) {
547
+ let mid = (left[index]! + right[index]!) / 2;
548
+ let side = ((left[index]! - right[index]!) / 2) * width;
549
+ if (crossover) {
550
+ mid =
551
+ midLow[1]!.process(midLow[0]!.process(mid)) +
552
+ midHigh[1]!.process(midHigh[0]!.process(mid));
553
+ side = sideHigh[1]!.process(sideHigh[0]!.process(side));
554
+ }
555
+ left[index] = mid + side;
556
+ right[index] = mid - side;
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Brickwall limiter. Offline, so the lookahead reads the future directly:
562
+ * the gain each sample needs (ceiling over its peak, with true peaks from
563
+ * the 4x interpolator on both sides of the sample) is held as a minimum
564
+ * over the next `lookahead` samples, released exponentially and smoothed
565
+ * by a moving average of the same length. Every sample under the average
566
+ * window carries its own requirement, so the result never overshoots and
567
+ * the gain ramps down smoothly before each peak. Peaks are found once;
568
+ * `run` can then try different drives cheaply (the loudness target).
569
+ */
570
+ class Limiter {
571
+ readonly left: Float64Array;
572
+ readonly right: Float64Array;
573
+ private readonly peaks: Float64Array;
574
+ private readonly window: number;
575
+ private readonly release: number;
576
+ private readonly ceiling: number;
577
+
578
+ constructor(
579
+ private readonly inputL: Float64Array,
580
+ private readonly inputR: Float64Array,
581
+ values: FxValues,
582
+ sampleRate: number,
583
+ ) {
584
+ const n = inputL.length;
585
+ this.left = new Float64Array(n);
586
+ this.right = new Float64Array(n);
587
+ this.ceiling = 10 ** (Number(values.ceiling) / 20);
588
+ this.window = Math.max(
589
+ 1,
590
+ Math.round((Number(values.lookahead) * sampleRate) / 1000),
591
+ );
592
+ this.release = Math.exp(
593
+ -1 / Math.max(1, (Number(values.release) / 1000) * sampleRate),
594
+ );
595
+ this.peaks = new Float64Array(n);
596
+ if (values.truepeak !== false) {
597
+ interSamplePeaks(inputL, false, this.peaks);
598
+ interSamplePeaks(inputR, false, this.peaks);
599
+ // A sample's gain shapes the waveform on both sides of it.
600
+ for (let index = n - 1; index > 0; index -= 1)
601
+ if (this.peaks[index - 1]! > this.peaks[index]!)
602
+ this.peaks[index] = this.peaks[index - 1]!;
603
+ } else {
604
+ for (let index = 0; index < n; index += 1)
605
+ this.peaks[index] = Math.max(
606
+ Math.abs(inputL[index]!),
607
+ Math.abs(inputR[index]!),
608
+ );
609
+ }
610
+ }
611
+
612
+ /** Limits the input driven by `gainDb` into `left` and `right`. */
613
+ run(gainDb: number): void {
614
+ const drive = 10 ** (gainDb / 20);
615
+ const n = this.inputL.length;
616
+ const { peaks, window, ceiling } = this;
617
+ // Sliding minimum of the needed gain over [i, i + window - 1]: a
618
+ // monotonic deque in a ring buffer, `window` samples ahead of the output.
619
+ const capacity = window + 1;
620
+ const slots = new Int32Array(capacity);
621
+ const values = new Float64Array(capacity);
622
+ let head = 0;
623
+ let size = 0;
624
+ const history = new Float64Array(window);
625
+ let released = 1;
626
+ let sum = 0;
627
+ for (let ahead = 0; ahead < n + window - 1; ahead += 1) {
628
+ const index = ahead - window + 1;
629
+ while (size > 0 && slots[head]! < index) {
630
+ head = (head + 1) % capacity;
631
+ size -= 1;
632
+ }
633
+ const peak = ahead < n ? peaks[ahead]! * drive : 0;
634
+ const need = peak > ceiling ? ceiling / peak : 1;
635
+ while (size > 0 && values[(head + size - 1) % capacity]! >= need)
636
+ size -= 1;
637
+ const slot = (head + size) % capacity;
638
+ slots[slot] = ahead;
639
+ values[slot] = need;
640
+ size += 1;
641
+ if (index < 0) continue;
642
+ const target = values[head]!;
643
+ if (index === 0) {
644
+ released = target;
645
+ history.fill(target);
646
+ sum = window * target;
647
+ }
648
+ released =
649
+ target < released
650
+ ? target
651
+ : target - this.release * (target - released);
652
+ const at = index % window;
653
+ sum += released - history[at]!;
654
+ history[at] = released;
655
+ const gain = Math.min(1, sum / window) * drive;
656
+ this.left[index] = this.inputL[index]! * gain;
657
+ this.right[index] = this.inputR[index]! * gain;
658
+ }
659
+ }
660
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Off-thread measurement for `master measure` and the measure_mix agent
3
+ * tool: a full render (master chain and target search included) plus the
4
+ * BS.1770 meter can take seconds on a long loop, so the TUI hands it to a
5
+ * worker and keeps painting and scheduling audio meanwhile.
6
+ */
7
+ import { scoreFromJSON } from "../../core/score.ts";
8
+ import { measureScore, type ScoreMeasurement } from "./measure.ts";
9
+ import { SampleLibrary, hasSamplerTracks } from "./samples.ts";
10
+
11
+ export type MeasureRequest = Readonly<{
12
+ score: unknown;
13
+ sampleRate?: number;
14
+ loop?: boolean;
15
+ /** Project root sampler voices resolve under; omitted = samplers silent. */
16
+ projectRoot?: string;
17
+ }>;
18
+
19
+ export type MeasureReply =
20
+ | Readonly<{ ok: true; measured: ScoreMeasurement }>
21
+ | Readonly<{ ok: false; error: string }>;
22
+
23
+ declare const self: Worker;
24
+
25
+ self.onmessage = async (event: MessageEvent<MeasureRequest>) => {
26
+ const { score, sampleRate, loop, projectRoot } = event.data;
27
+ try {
28
+ const parsed = scoreFromJSON(score);
29
+ const samples =
30
+ projectRoot !== undefined && hasSamplerTracks(parsed)
31
+ ? await new SampleLibrary({ projectRoot }).load(parsed)
32
+ : undefined;
33
+ const measured = measureScore(parsed, {
34
+ ...(sampleRate === undefined ? {} : { sampleRate }),
35
+ ...(loop === undefined ? {} : { loop }),
36
+ ...(samples ? { samples } : {}),
37
+ });
38
+ self.postMessage({ ok: true, measured } satisfies MeasureReply);
39
+ } catch (error) {
40
+ self.postMessage({
41
+ ok: false,
42
+ error: error instanceof Error ? error.message : String(error),
43
+ } satisfies MeasureReply);
44
+ }
45
+ };