@hraness/dawg 0.4.1 → 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.
- package/CHANGELOG.md +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +29 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +459 -43
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- 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
|
+
};
|