@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,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loudness and mix measurement after ITU-R BS.1770-4 and EBU R 128:
|
|
3
|
+
* K-weighted mean square, momentary (400 ms) and short-term (3 s) loudness
|
|
4
|
+
* every 100 ms, gated integrated loudness (absolute -70 LUFS, relative
|
|
5
|
+
* -10 LU), loudness range per EBU Tech 3342 (short-term values gated at
|
|
6
|
+
* -70 LUFS and -20 LU, 10th to 95th percentile) and true peak from an 8x
|
|
7
|
+
* oversampled polyphase interpolator (BS.1770-4 Annex 2, with a longer
|
|
8
|
+
* windowed-sinc filter than its example). Validated against
|
|
9
|
+
* the synthetic test signals of EBU Tech 3341 and 3342 in loudness.test.ts.
|
|
10
|
+
*
|
|
11
|
+
* A `loop` buffer is measured as if it repeated forever: filters start in
|
|
12
|
+
* their steady state and windows wrap around the loop end.
|
|
13
|
+
*/
|
|
14
|
+
import { fftInPlace } from "./effects/convolution.ts";
|
|
15
|
+
|
|
16
|
+
export const ABSOLUTE_GATE_LUFS = -70;
|
|
17
|
+
export const RELATIVE_GATE_LU = -10;
|
|
18
|
+
export const RANGE_GATE_LU = -20;
|
|
19
|
+
/** Series step (and the gating-block hop): 100 ms. */
|
|
20
|
+
export const LOUDNESS_STEP_SECONDS = 0.1;
|
|
21
|
+
export const MOMENTARY_SECONDS = 0.4;
|
|
22
|
+
export const SHORT_TERM_SECONDS = 3;
|
|
23
|
+
/** True-peak oversampling factor and taps per polyphase branch. */
|
|
24
|
+
export const TRUE_PEAK_FACTOR = 4;
|
|
25
|
+
const TAPS_PER_PHASE = 32;
|
|
26
|
+
|
|
27
|
+
export type Loudness = Readonly<{
|
|
28
|
+
/** Gated integrated loudness, LUFS; -Infinity when everything is gated. */
|
|
29
|
+
integrated: number;
|
|
30
|
+
momentaryMax: number;
|
|
31
|
+
shortTermMax: number;
|
|
32
|
+
/** Loudness range (LRA), LU. */
|
|
33
|
+
range: number;
|
|
34
|
+
/** dBTP: the 8x oversampled peak. */
|
|
35
|
+
truePeak: number;
|
|
36
|
+
/** dBFS: the highest sample. */
|
|
37
|
+
samplePeak: number;
|
|
38
|
+
/** Momentary loudness of the 400 ms ending at each 100 ms step. */
|
|
39
|
+
momentary: Float64Array;
|
|
40
|
+
/** Short-term loudness of the 3 s ending at each 100 ms step. */
|
|
41
|
+
shortTerm: Float64Array;
|
|
42
|
+
/** Seconds between series values. */
|
|
43
|
+
step: number;
|
|
44
|
+
/** Seconds measured. */
|
|
45
|
+
seconds: number;
|
|
46
|
+
}>;
|
|
47
|
+
|
|
48
|
+
export type LoudnessOptions = Readonly<{
|
|
49
|
+
/** Measure the buffer as a loop playing forever (see the module doc). */
|
|
50
|
+
loop?: boolean;
|
|
51
|
+
/** Skip the oversampled true peak (reported as the sample peak). */
|
|
52
|
+
truePeak?: boolean;
|
|
53
|
+
}>;
|
|
54
|
+
|
|
55
|
+
/** -0.691 + 10·log10(power): BS.1770 loudness of a K-weighted power sum. */
|
|
56
|
+
export function powerToLufs(power: number): number {
|
|
57
|
+
return power > 0 ? -0.691 + 10 * Math.log10(power) : -Infinity;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function gainToDb(gain: number): number {
|
|
61
|
+
return gain > 0 ? 20 * Math.log10(gain) : -Infinity;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Coefficients = Readonly<{
|
|
65
|
+
b0: number;
|
|
66
|
+
b1: number;
|
|
67
|
+
b2: number;
|
|
68
|
+
a1: number;
|
|
69
|
+
a2: number;
|
|
70
|
+
}>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* K-weighting at any sample rate: the BS.1770 shelving pre-filter and RLB
|
|
74
|
+
* high-pass, from their analog prototypes by the bilinear transform (the
|
|
75
|
+
* parameters libebur128 derives; at 48 kHz they reproduce the standard's
|
|
76
|
+
* coefficient tables).
|
|
77
|
+
*/
|
|
78
|
+
export function kWeighting(
|
|
79
|
+
sampleRate: number,
|
|
80
|
+
): readonly [Coefficients, Coefficients] {
|
|
81
|
+
let f0 = 1681.974450955533;
|
|
82
|
+
const gain = 3.999843853973347;
|
|
83
|
+
let q = 0.7071752369554196;
|
|
84
|
+
let k = Math.tan((Math.PI * f0) / sampleRate);
|
|
85
|
+
const vh = 10 ** (gain / 20);
|
|
86
|
+
const vb = vh ** 0.4996667741545416;
|
|
87
|
+
let a0 = 1 + k / q + k * k;
|
|
88
|
+
const shelf = {
|
|
89
|
+
b0: (vh + (vb * k) / q + k * k) / a0,
|
|
90
|
+
b1: (2 * (k * k - vh)) / a0,
|
|
91
|
+
b2: (vh - (vb * k) / q + k * k) / a0,
|
|
92
|
+
a1: (2 * (k * k - 1)) / a0,
|
|
93
|
+
a2: (1 - k / q + k * k) / a0,
|
|
94
|
+
};
|
|
95
|
+
f0 = 38.13547087602444;
|
|
96
|
+
q = 0.5003270373238773;
|
|
97
|
+
k = Math.tan((Math.PI * f0) / sampleRate);
|
|
98
|
+
a0 = 1 + k / q + k * k;
|
|
99
|
+
const highPass = {
|
|
100
|
+
b0: 1,
|
|
101
|
+
b1: -2,
|
|
102
|
+
b2: 1,
|
|
103
|
+
a1: (2 * (k * k - 1)) / a0,
|
|
104
|
+
a2: (1 - k / q + k * k) / a0,
|
|
105
|
+
};
|
|
106
|
+
return [shelf, highPass];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** K-weighted `left² + right²` per sample (stereo weights are 1). */
|
|
110
|
+
function weightedPower(
|
|
111
|
+
left: Float64Array,
|
|
112
|
+
right: Float64Array,
|
|
113
|
+
sampleRate: number,
|
|
114
|
+
loop: boolean,
|
|
115
|
+
): Float64Array {
|
|
116
|
+
const n = left.length;
|
|
117
|
+
const power = new Float64Array(n);
|
|
118
|
+
const [shelf, highPass] = kWeighting(sampleRate);
|
|
119
|
+
for (const channel of [left, right]) {
|
|
120
|
+
let s1 = 0;
|
|
121
|
+
let s2 = 0;
|
|
122
|
+
let h1 = 0;
|
|
123
|
+
let h2 = 0;
|
|
124
|
+
// A loop runs one pass first so the filters start in their steady state.
|
|
125
|
+
for (let pass = loop ? 0 : 1; pass < 2; pass += 1)
|
|
126
|
+
for (let index = 0; index < n; index += 1) {
|
|
127
|
+
const x = channel[index]!;
|
|
128
|
+
const y = shelf.b0 * x + s1;
|
|
129
|
+
s1 = shelf.b1 * x - shelf.a1 * y + s2;
|
|
130
|
+
s2 = shelf.b2 * x - shelf.a2 * y;
|
|
131
|
+
const z = highPass.b0 * y + h1;
|
|
132
|
+
h1 = highPass.b1 * y - highPass.a1 * z + h2;
|
|
133
|
+
h2 = highPass.b2 * y - highPass.a2 * z;
|
|
134
|
+
if (pass === 1) power[index]! += z * z;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return power;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Prefix sums, so any window's power is a difference of two entries. */
|
|
141
|
+
function prefixSums(values: Float64Array): Float64Array {
|
|
142
|
+
const sums = new Float64Array(values.length + 1);
|
|
143
|
+
let total = 0;
|
|
144
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
145
|
+
total += values[index]!;
|
|
146
|
+
sums[index + 1] = total;
|
|
147
|
+
}
|
|
148
|
+
return sums;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Mean power of the `width` samples ending at `end`. A loop wraps around;
|
|
153
|
+
* a one-shot treats everything before the start as silence.
|
|
154
|
+
*/
|
|
155
|
+
function windowPower(
|
|
156
|
+
sums: Float64Array,
|
|
157
|
+
end: number,
|
|
158
|
+
width: number,
|
|
159
|
+
loop: boolean,
|
|
160
|
+
): number {
|
|
161
|
+
const n = sums.length - 1;
|
|
162
|
+
if (n === 0 || width <= 0) return 0;
|
|
163
|
+
if (!loop) {
|
|
164
|
+
const start = Math.max(0, end - width);
|
|
165
|
+
return (sums[end]! - sums[start]!) / width;
|
|
166
|
+
}
|
|
167
|
+
let total = 0;
|
|
168
|
+
let remaining = width;
|
|
169
|
+
let stop = end;
|
|
170
|
+
while (remaining > 0) {
|
|
171
|
+
const take = Math.min(remaining, stop);
|
|
172
|
+
total += sums[stop]! - sums[stop - take]!;
|
|
173
|
+
remaining -= take;
|
|
174
|
+
stop = stop - take === 0 ? n : stop - take;
|
|
175
|
+
}
|
|
176
|
+
return total / width;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** BS.1770-4 gating over block powers: -70 LUFS, then `relative` LU. */
|
|
180
|
+
export function gatedLoudness(
|
|
181
|
+
powers: readonly number[] | Float64Array,
|
|
182
|
+
relative = RELATIVE_GATE_LU,
|
|
183
|
+
): number {
|
|
184
|
+
let sum = 0;
|
|
185
|
+
let count = 0;
|
|
186
|
+
for (const power of powers)
|
|
187
|
+
if (powerToLufs(power) > ABSOLUTE_GATE_LUFS) {
|
|
188
|
+
sum += power;
|
|
189
|
+
count += 1;
|
|
190
|
+
}
|
|
191
|
+
if (count === 0) return -Infinity;
|
|
192
|
+
const gate = powerToLufs(sum / count) + relative;
|
|
193
|
+
sum = 0;
|
|
194
|
+
count = 0;
|
|
195
|
+
for (const power of powers)
|
|
196
|
+
if (powerToLufs(power) > ABSOLUTE_GATE_LUFS && powerToLufs(power) > gate) {
|
|
197
|
+
sum += power;
|
|
198
|
+
count += 1;
|
|
199
|
+
}
|
|
200
|
+
return count === 0 ? -Infinity : powerToLufs(sum / count);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** EBU Tech 3342 loudness range of short-term block powers, LU. */
|
|
204
|
+
export function loudnessRange(
|
|
205
|
+
powers: readonly number[] | Float64Array,
|
|
206
|
+
): number {
|
|
207
|
+
const kept: number[] = [];
|
|
208
|
+
let sum = 0;
|
|
209
|
+
for (const power of powers)
|
|
210
|
+
if (powerToLufs(power) > ABSOLUTE_GATE_LUFS) {
|
|
211
|
+
kept.push(power);
|
|
212
|
+
sum += power;
|
|
213
|
+
}
|
|
214
|
+
if (kept.length === 0) return 0;
|
|
215
|
+
const gate = powerToLufs(sum / kept.length) + RANGE_GATE_LU;
|
|
216
|
+
const values = kept
|
|
217
|
+
.map(powerToLufs)
|
|
218
|
+
.filter((value) => value > gate)
|
|
219
|
+
.sort((a, b) => a - b);
|
|
220
|
+
if (values.length === 0) return 0;
|
|
221
|
+
const low = values[Math.round((values.length - 1) * 0.1)]!;
|
|
222
|
+
const high = values[Math.round((values.length - 1) * 0.95)]!;
|
|
223
|
+
return high - low;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Kaiser window shape for the true-peak interpolator. */
|
|
227
|
+
const TRUE_PEAK_BETA = 8;
|
|
228
|
+
|
|
229
|
+
function besselI0(x: number): number {
|
|
230
|
+
let sum = 1;
|
|
231
|
+
let term = 1;
|
|
232
|
+
for (let k = 1; k < 40; k += 1) {
|
|
233
|
+
term *= (x / (2 * k)) ** 2;
|
|
234
|
+
sum += term;
|
|
235
|
+
}
|
|
236
|
+
return sum;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let phases: Float64Array[] | undefined;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The true-peak interpolator: Kaiser-windowed sinc phases of 32 taps, each
|
|
243
|
+
* in time order over x[n-15] .. x[n+16], estimating the signal at n + 1/8,
|
|
244
|
+
* 3/8, 5/8 and 7/8, then (0.6) n + 2/8, 4/8 and 6/8. With the samples
|
|
245
|
+
* themselves they give an evenly spaced 8x oversampled true peak; the odd
|
|
246
|
+
* eighths alone left 1/4-sample gaps that read dense material up to 0.2 dB
|
|
247
|
+
* low at a peak near n + 3/4. The odd phases come first and are unchanged,
|
|
248
|
+
* so a peak they already caught reads the same. BS.1770-4 Annex 2's 12-tap example filter rolls
|
|
249
|
+
* off early and reads dense, bright material (supersaws, distortion) up to
|
|
250
|
+
* half a dB low; this one stays within a few hundredths of a 64x reference.
|
|
251
|
+
*/
|
|
252
|
+
export function truePeakPhases(): readonly Float64Array[] {
|
|
253
|
+
phases ??= [1, 3, 5, 7, 2, 4, 6].map((eighth) => {
|
|
254
|
+
const frac = eighth / 8;
|
|
255
|
+
const taps = new Float64Array(TAPS_PER_PHASE);
|
|
256
|
+
const before = TAPS_PER_PHASE / 2 - 1;
|
|
257
|
+
const norm = besselI0(TRUE_PEAK_BETA);
|
|
258
|
+
for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1) {
|
|
259
|
+
const x = tap - before - frac;
|
|
260
|
+
const m = x / (TAPS_PER_PHASE / 2);
|
|
261
|
+
const window =
|
|
262
|
+
Math.abs(m) <= 1
|
|
263
|
+
? besselI0(TRUE_PEAK_BETA * Math.sqrt(1 - m * m)) / norm
|
|
264
|
+
: 0;
|
|
265
|
+
taps[tap] = (Math.sin(Math.PI * x) / (Math.PI * x)) * window;
|
|
266
|
+
}
|
|
267
|
+
return taps;
|
|
268
|
+
});
|
|
269
|
+
return phases;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let interleaved: Float64Array | undefined;
|
|
273
|
+
|
|
274
|
+
/** The seven phases interleaved by tap (`tap * 7 + phase`). */
|
|
275
|
+
function interleavedPhases(): Float64Array {
|
|
276
|
+
if (interleaved) return interleaved;
|
|
277
|
+
const branches = truePeakPhases();
|
|
278
|
+
const flat = new Float64Array(TAPS_PER_PHASE * branches.length);
|
|
279
|
+
branches.forEach((taps, phase) => {
|
|
280
|
+
for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1)
|
|
281
|
+
flat[tap * branches.length + phase] = taps[tap]!;
|
|
282
|
+
});
|
|
283
|
+
return (interleaved = flat);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Largest absolute value between each sample and the next (both samples
|
|
288
|
+
* and the seven interpolated points), as `out[n]` for n .. n+1.
|
|
289
|
+
*/
|
|
290
|
+
export function interSamplePeaks(
|
|
291
|
+
channel: Float64Array,
|
|
292
|
+
loop: boolean,
|
|
293
|
+
out: Float64Array,
|
|
294
|
+
): void {
|
|
295
|
+
const n = channel.length;
|
|
296
|
+
const branches = truePeakPhases();
|
|
297
|
+
const flat = interleavedPhases();
|
|
298
|
+
const before = TAPS_PER_PHASE / 2 - 1;
|
|
299
|
+
const at = (index: number): number => {
|
|
300
|
+
if (index >= 0 && index < n) return channel[index]!;
|
|
301
|
+
if (!loop || n === 0) return 0;
|
|
302
|
+
return channel[((index % n) + n) % n]!;
|
|
303
|
+
};
|
|
304
|
+
for (let index = 0; index < n; index += 1) {
|
|
305
|
+
let peak = Math.abs(channel[index]!);
|
|
306
|
+
const next = Math.abs(at(index + 1));
|
|
307
|
+
if (next > peak) peak = next;
|
|
308
|
+
const base = index - before;
|
|
309
|
+
if (base >= 0 && base + TAPS_PER_PHASE <= n) {
|
|
310
|
+
// Every phase in one pass over the window (the interior fast path).
|
|
311
|
+
let s0 = 0;
|
|
312
|
+
let s1 = 0;
|
|
313
|
+
let s2 = 0;
|
|
314
|
+
let s3 = 0;
|
|
315
|
+
let s4 = 0;
|
|
316
|
+
let s5 = 0;
|
|
317
|
+
let s6 = 0;
|
|
318
|
+
for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1) {
|
|
319
|
+
const x = channel[base + tap]!;
|
|
320
|
+
const k = tap * 7;
|
|
321
|
+
s0 += x * flat[k]!;
|
|
322
|
+
s1 += x * flat[k + 1]!;
|
|
323
|
+
s2 += x * flat[k + 2]!;
|
|
324
|
+
s3 += x * flat[k + 3]!;
|
|
325
|
+
s4 += x * flat[k + 4]!;
|
|
326
|
+
s5 += x * flat[k + 5]!;
|
|
327
|
+
s6 += x * flat[k + 6]!;
|
|
328
|
+
}
|
|
329
|
+
peak = Math.max(
|
|
330
|
+
peak,
|
|
331
|
+
Math.abs(s0),
|
|
332
|
+
Math.abs(s1),
|
|
333
|
+
Math.abs(s2),
|
|
334
|
+
Math.abs(s3),
|
|
335
|
+
Math.abs(s4),
|
|
336
|
+
Math.abs(s5),
|
|
337
|
+
Math.abs(s6),
|
|
338
|
+
);
|
|
339
|
+
} else
|
|
340
|
+
for (const taps of branches) {
|
|
341
|
+
let sum = 0;
|
|
342
|
+
for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1)
|
|
343
|
+
sum += at(base + tap) * taps[tap]!;
|
|
344
|
+
const magnitude = Math.abs(sum);
|
|
345
|
+
if (magnitude > peak) peak = magnitude;
|
|
346
|
+
}
|
|
347
|
+
if (peak > out[index]!) out[index] = peak;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The 8x oversampled true peak of a stereo buffer, linear. */
|
|
352
|
+
export function truePeakGain(
|
|
353
|
+
left: Float64Array,
|
|
354
|
+
right: Float64Array,
|
|
355
|
+
loop = false,
|
|
356
|
+
): number {
|
|
357
|
+
const peaks = new Float64Array(left.length);
|
|
358
|
+
interSamplePeaks(left, loop, peaks);
|
|
359
|
+
interSamplePeaks(right, loop, peaks);
|
|
360
|
+
let max = 0;
|
|
361
|
+
for (const peak of peaks) if (peak > max) max = peak;
|
|
362
|
+
return max;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Integrated loudness only (no series, no true peak), LUFS. */
|
|
366
|
+
export function integratedLoudness(
|
|
367
|
+
left: Float64Array,
|
|
368
|
+
right: Float64Array,
|
|
369
|
+
sampleRate: number,
|
|
370
|
+
loop = false,
|
|
371
|
+
): number {
|
|
372
|
+
const sums = prefixSums(weightedPower(left, right, sampleRate, loop));
|
|
373
|
+
return gatedLoudness(blockPowers(sums, sampleRate, loop, MOMENTARY_SECONDS));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Window ends every 100 ms: complete windows only for a one-shot. */
|
|
377
|
+
function stepEnds(n: number, sampleRate: number, loop: boolean): number[] {
|
|
378
|
+
const step = sampleRate * LOUDNESS_STEP_SECONDS;
|
|
379
|
+
const count = loop
|
|
380
|
+
? Math.max(1, Math.round(n / step))
|
|
381
|
+
: Math.floor(n / step + 1e-9);
|
|
382
|
+
const ends: number[] = [];
|
|
383
|
+
for (let k = 1; k <= count; k += 1)
|
|
384
|
+
ends.push(loop ? Math.round((k * n) / count) : Math.round(k * step));
|
|
385
|
+
return ends;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function blockPowers(
|
|
389
|
+
sums: Float64Array,
|
|
390
|
+
sampleRate: number,
|
|
391
|
+
loop: boolean,
|
|
392
|
+
seconds: number,
|
|
393
|
+
): number[] {
|
|
394
|
+
const n = sums.length - 1;
|
|
395
|
+
const width = Math.round(seconds * sampleRate);
|
|
396
|
+
const powers: number[] = [];
|
|
397
|
+
for (const end of stepEnds(n, sampleRate, loop))
|
|
398
|
+
if (loop || end >= width) powers.push(windowPower(sums, end, width, loop));
|
|
399
|
+
return powers;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Grid of the momentary and short-term maxima: 10 ms (EBU Tech 3341). */
|
|
403
|
+
export const MAX_STEP_SECONDS = 0.01;
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The loudest `width`-sample window, with window ends on a 10 ms grid (the
|
|
407
|
+
* update rate EBU Tech 3341 asks of max momentary and short-term), so a
|
|
408
|
+
* burst between two 100 ms series steps still reads its full loudness.
|
|
409
|
+
*/
|
|
410
|
+
function maxWindowLufs(
|
|
411
|
+
sums: Float64Array,
|
|
412
|
+
sampleRate: number,
|
|
413
|
+
width: number,
|
|
414
|
+
loop: boolean,
|
|
415
|
+
): number {
|
|
416
|
+
const n = sums.length - 1;
|
|
417
|
+
const step = sampleRate * MAX_STEP_SECONDS;
|
|
418
|
+
let max = -Infinity;
|
|
419
|
+
const consider = (end: number) => {
|
|
420
|
+
const value = powerToLufs(windowPower(sums, end, width, loop));
|
|
421
|
+
if (value > max) max = value;
|
|
422
|
+
};
|
|
423
|
+
if (loop) {
|
|
424
|
+
const count = Math.max(1, Math.round(n / step));
|
|
425
|
+
for (let k = 1; k <= count; k += 1) consider(Math.round((k * n) / count));
|
|
426
|
+
return max;
|
|
427
|
+
}
|
|
428
|
+
if (n < width) return max;
|
|
429
|
+
for (let k = 0; ; k += 1) {
|
|
430
|
+
const end = width + Math.round(k * step);
|
|
431
|
+
if (end > n) break;
|
|
432
|
+
consider(end);
|
|
433
|
+
}
|
|
434
|
+
consider(n);
|
|
435
|
+
return max;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Everything a loudness meter shows, for a stereo buffer. */
|
|
439
|
+
export function measureLoudness(
|
|
440
|
+
left: Float64Array,
|
|
441
|
+
right: Float64Array,
|
|
442
|
+
sampleRate: number,
|
|
443
|
+
options: LoudnessOptions = {},
|
|
444
|
+
): Loudness {
|
|
445
|
+
const loop = options.loop ?? false;
|
|
446
|
+
const n = left.length;
|
|
447
|
+
const sums = prefixSums(weightedPower(left, right, sampleRate, loop));
|
|
448
|
+
const ends = stepEnds(n, sampleRate, loop);
|
|
449
|
+
const momentaryWidth = Math.round(MOMENTARY_SECONDS * sampleRate);
|
|
450
|
+
const shortWidth = Math.round(SHORT_TERM_SECONDS * sampleRate);
|
|
451
|
+
const momentary = new Float64Array(ends.length);
|
|
452
|
+
const shortTerm = new Float64Array(ends.length);
|
|
453
|
+
const blocks: number[] = [];
|
|
454
|
+
const shortBlocks: number[] = [];
|
|
455
|
+
ends.forEach((end, index) => {
|
|
456
|
+
const m = windowPower(sums, end, momentaryWidth, loop);
|
|
457
|
+
const s = windowPower(sums, end, shortWidth, loop);
|
|
458
|
+
momentary[index] = powerToLufs(m);
|
|
459
|
+
shortTerm[index] = powerToLufs(s);
|
|
460
|
+
if (loop || end >= momentaryWidth) blocks.push(m);
|
|
461
|
+
if (loop || end >= shortWidth) shortBlocks.push(s);
|
|
462
|
+
});
|
|
463
|
+
const momentaryMax = maxWindowLufs(sums, sampleRate, momentaryWidth, loop);
|
|
464
|
+
const shortTermMax = maxWindowLufs(sums, sampleRate, shortWidth, loop);
|
|
465
|
+
let samplePeak = 0;
|
|
466
|
+
for (let index = 0; index < n; index += 1) {
|
|
467
|
+
const peak = Math.max(Math.abs(left[index]!), Math.abs(right[index]!));
|
|
468
|
+
if (peak > samplePeak) samplePeak = peak;
|
|
469
|
+
}
|
|
470
|
+
const truePeak =
|
|
471
|
+
options.truePeak === false
|
|
472
|
+
? samplePeak
|
|
473
|
+
: Math.max(samplePeak, truePeakGain(left, right, loop));
|
|
474
|
+
return Object.freeze({
|
|
475
|
+
integrated: gatedLoudness(blocks),
|
|
476
|
+
momentaryMax,
|
|
477
|
+
shortTermMax,
|
|
478
|
+
range: loudnessRange(shortBlocks),
|
|
479
|
+
truePeak: gainToDb(truePeak),
|
|
480
|
+
samplePeak: gainToDb(samplePeak),
|
|
481
|
+
momentary,
|
|
482
|
+
shortTerm,
|
|
483
|
+
step: LOUDNESS_STEP_SECONDS,
|
|
484
|
+
seconds: n / sampleRate,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Interleaved 16-bit stereo PCM as two float channels. */
|
|
489
|
+
export function pcmChannels(
|
|
490
|
+
pcm: Int16Array,
|
|
491
|
+
): readonly [Float64Array, Float64Array] {
|
|
492
|
+
const frames = Math.floor(pcm.length / 2);
|
|
493
|
+
const left = new Float64Array(frames);
|
|
494
|
+
const right = new Float64Array(frames);
|
|
495
|
+
for (let index = 0; index < frames; index += 1) {
|
|
496
|
+
left[index] = pcm[index * 2]! / 32767;
|
|
497
|
+
right[index] = pcm[index * 2 + 1]! / 32767;
|
|
498
|
+
}
|
|
499
|
+
return [left, right];
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Spectral bands `measureMix` reports, low edge to high edge in Hz. */
|
|
503
|
+
export const MIX_BANDS = Object.freeze([
|
|
504
|
+
{ name: "sub", low: 20, high: 60 },
|
|
505
|
+
{ name: "bass", low: 60, high: 250 },
|
|
506
|
+
{ name: "low-mid", low: 250, high: 2_000 },
|
|
507
|
+
{ name: "high-mid", low: 2_000, high: 6_000 },
|
|
508
|
+
{ name: "high", low: 6_000, high: 20_000 },
|
|
509
|
+
] as const);
|
|
510
|
+
|
|
511
|
+
export type MixMeasurement = Readonly<{
|
|
512
|
+
loudness: Loudness;
|
|
513
|
+
/** Share of energy per band in dB relative to the whole (0 = all of it). */
|
|
514
|
+
bands: Readonly<Record<(typeof MIX_BANDS)[number]["name"], number>>;
|
|
515
|
+
/** Pearson correlation of left and right: 1 mono, 0 wide, <0 out of phase. */
|
|
516
|
+
correlation: number;
|
|
517
|
+
/** Side energy relative to mid energy, dB (-Infinity for mono). */
|
|
518
|
+
sideDb: number;
|
|
519
|
+
/** Peak-to-loudness ratio (EBU PLR): true peak minus integrated, LU. */
|
|
520
|
+
plr: number;
|
|
521
|
+
}>;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Loudness, spectral balance (Welch average of 4096-point Hann frames),
|
|
525
|
+
* stereo correlation and side level of a stereo buffer.
|
|
526
|
+
*/
|
|
527
|
+
export function measureMix(
|
|
528
|
+
left: Float64Array,
|
|
529
|
+
right: Float64Array,
|
|
530
|
+
sampleRate: number,
|
|
531
|
+
options: LoudnessOptions = {},
|
|
532
|
+
): MixMeasurement {
|
|
533
|
+
const loudness = measureLoudness(left, right, sampleRate, options);
|
|
534
|
+
const n = left.length;
|
|
535
|
+
let lr = 0;
|
|
536
|
+
let ll = 0;
|
|
537
|
+
let rr = 0;
|
|
538
|
+
let mid = 0;
|
|
539
|
+
let side = 0;
|
|
540
|
+
for (let index = 0; index < n; index += 1) {
|
|
541
|
+
const l = left[index]!;
|
|
542
|
+
const r = right[index]!;
|
|
543
|
+
lr += l * r;
|
|
544
|
+
ll += l * l;
|
|
545
|
+
rr += r * r;
|
|
546
|
+
mid += ((l + r) / 2) ** 2;
|
|
547
|
+
side += ((l - r) / 2) ** 2;
|
|
548
|
+
}
|
|
549
|
+
const correlation = ll > 0 && rr > 0 ? lr / Math.sqrt(ll * rr) : 1;
|
|
550
|
+
const size = 4096;
|
|
551
|
+
const window = new Float64Array(size);
|
|
552
|
+
for (let index = 0; index < size; index += 1)
|
|
553
|
+
window[index] = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / size);
|
|
554
|
+
const spectrum = new Float64Array(size / 2 + 1);
|
|
555
|
+
const re = new Float64Array(size);
|
|
556
|
+
const im = new Float64Array(size);
|
|
557
|
+
const hop = size / 2;
|
|
558
|
+
for (let start = 0; start === 0 || start + size <= n; start += hop) {
|
|
559
|
+
for (const channel of [left, right]) {
|
|
560
|
+
for (let index = 0; index < size; index += 1) {
|
|
561
|
+
const at = start + index;
|
|
562
|
+
re[index] = at < n ? channel[at]! * window[index]! : 0;
|
|
563
|
+
im[index] = 0;
|
|
564
|
+
}
|
|
565
|
+
fftInPlace(re, im);
|
|
566
|
+
for (let bin = 0; bin <= size / 2; bin += 1)
|
|
567
|
+
spectrum[bin]! += re[bin]! * re[bin]! + im[bin]! * im[bin]!;
|
|
568
|
+
}
|
|
569
|
+
if (start + size >= n) break;
|
|
570
|
+
}
|
|
571
|
+
let total = 0;
|
|
572
|
+
const energy = MIX_BANDS.map(() => 0);
|
|
573
|
+
for (let bin = 1; bin <= size / 2; bin += 1) {
|
|
574
|
+
const hz = (bin * sampleRate) / size;
|
|
575
|
+
total += spectrum[bin]!;
|
|
576
|
+
const band = MIX_BANDS.findIndex(
|
|
577
|
+
(entry) => hz >= entry.low && hz < entry.high,
|
|
578
|
+
);
|
|
579
|
+
if (band >= 0) energy[band]! += spectrum[bin]!;
|
|
580
|
+
}
|
|
581
|
+
const bands = Object.fromEntries(
|
|
582
|
+
MIX_BANDS.map((band, index) => [
|
|
583
|
+
band.name,
|
|
584
|
+
total > 0 && energy[index]! > 0
|
|
585
|
+
? 10 * Math.log10(energy[index]! / total)
|
|
586
|
+
: -Infinity,
|
|
587
|
+
]),
|
|
588
|
+
) as MixMeasurement["bands"];
|
|
589
|
+
return Object.freeze({
|
|
590
|
+
loudness,
|
|
591
|
+
bands,
|
|
592
|
+
correlation,
|
|
593
|
+
sideDb: mid > 0 && side > 0 ? 10 * Math.log10(side / mid) : -Infinity,
|
|
594
|
+
plr: loudness.truePeak - loudness.integrated,
|
|
595
|
+
});
|
|
596
|
+
}
|