@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.
- package/CHANGELOG.md +86 -0
- package/DAWG.md +300 -35
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -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 +27 -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 +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -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/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -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/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- 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/space.ts +197 -50
- package/src/audio/engine.ts +175 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -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 +87 -16
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +448 -59
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +826 -47
- package/src/render.ts +109 -7
- 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 +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +79 -12
- 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
package/src/audio/live.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* The offline renderer is untouched, so loop renders and exports stay
|
|
9
9
|
* byte-identical. Live audio never reaches a render or an export: the engine
|
|
10
|
-
* mixes it into the stream only.
|
|
10
|
+
* mixes it into the stream only. Like auditions, live notes are pre-master
|
|
11
|
+
* (see `audition.ts`): the song master is a whole-mix stage.
|
|
11
12
|
*/
|
|
12
13
|
import {
|
|
13
14
|
SCORE_LIMITS,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
isSamplerInstrument,
|
|
16
17
|
type Track,
|
|
17
18
|
} from "../../core/score.ts";
|
|
19
|
+
import { bpmAtTick } from "../../core/tempo.ts";
|
|
18
20
|
import { sampleKey, type SampleBank } from "./samples.ts";
|
|
19
21
|
import { RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
|
|
20
22
|
|
|
@@ -31,6 +33,8 @@ export type LiveNoteRequest = Readonly<{
|
|
|
31
33
|
seconds: number;
|
|
32
34
|
/** Decoded sampler voices; without it sampler tracks are silent. */
|
|
33
35
|
samples?: SampleBank;
|
|
36
|
+
/** Song tick being played: the tempo there sets length and synced effects. */
|
|
37
|
+
tick?: number;
|
|
34
38
|
}>;
|
|
35
39
|
|
|
36
40
|
/** Longest a single live note is rendered for (a held sustain pedal). */
|
|
@@ -60,7 +64,11 @@ export class LiveSynth {
|
|
|
60
64
|
);
|
|
61
65
|
if (!track) return undefined;
|
|
62
66
|
const { score } = request;
|
|
63
|
-
const
|
|
67
|
+
const bpm =
|
|
68
|
+
request.tick === undefined
|
|
69
|
+
? score.tempoBpm
|
|
70
|
+
: bpmAtTick(score, request.tick);
|
|
71
|
+
const ticksPerSecond = (score.ticksPerBeat * bpm) / 60;
|
|
64
72
|
const seconds = Math.max(
|
|
65
73
|
0.01,
|
|
66
74
|
Math.min(MAX_LIVE_NOTE_SECONDS, request.seconds),
|
|
@@ -76,10 +84,14 @@ export class LiveSynth {
|
|
|
76
84
|
pitch,
|
|
77
85
|
velocity,
|
|
78
86
|
durationTicks,
|
|
79
|
-
|
|
87
|
+
bpm,
|
|
80
88
|
score.ticksPerBeat,
|
|
81
89
|
this.sampleRate,
|
|
82
90
|
samplerDigest(track, request.samples),
|
|
91
|
+
// The song tuning and key (the default tuning root) retune live notes.
|
|
92
|
+
...(score.tuning || track.tuning
|
|
93
|
+
? [score.tuning ?? null, score.key]
|
|
94
|
+
: []),
|
|
83
95
|
]);
|
|
84
96
|
const hit = this.cache.get(key);
|
|
85
97
|
if (hit) {
|
|
@@ -94,10 +106,13 @@ export class LiveSynth {
|
|
|
94
106
|
Math.min(SCORE_LIMITS.maxBars, Math.ceil(beats / score.beatsPerBar)),
|
|
95
107
|
);
|
|
96
108
|
const single = new TrackScore({
|
|
97
|
-
tempoBpm:
|
|
109
|
+
tempoBpm: bpm,
|
|
98
110
|
beatsPerBar: score.beatsPerBar,
|
|
99
111
|
bars,
|
|
100
112
|
ticksPerBeat: score.ticksPerBeat,
|
|
113
|
+
// The song tuning, and the key whose tonic is the default root.
|
|
114
|
+
...(score.tuning ? { tuning: score.tuning } : {}),
|
|
115
|
+
...(score.tuning || track.tuning ? { key: score.key } : {}),
|
|
101
116
|
tracks: [liveTrack(track)],
|
|
102
117
|
notes: [
|
|
103
118
|
{
|
|
@@ -130,9 +145,21 @@ export class LiveSynth {
|
|
|
130
145
|
}
|
|
131
146
|
}
|
|
132
147
|
|
|
133
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* The track as it sounds live: audible regardless of mute and solo, and
|
|
150
|
+
* without track `time`, so a key sounds once, as played, not re-placed.
|
|
151
|
+
*/
|
|
134
152
|
function liveTrack(track: Track): Track {
|
|
135
|
-
|
|
153
|
+
// A held key is the performance: the recorded pedal and the render-time
|
|
154
|
+
// humanize stay out of it. The velocity curve stays, so a key sounds like
|
|
155
|
+
// the note it records as.
|
|
156
|
+
const {
|
|
157
|
+
solo: _solo,
|
|
158
|
+
time: _time,
|
|
159
|
+
pedal: _pedal,
|
|
160
|
+
humanize: _humanize,
|
|
161
|
+
...rest
|
|
162
|
+
} = track;
|
|
136
163
|
return { ...rest, muted: false };
|
|
137
164
|
}
|
|
138
165
|
|
|
@@ -0,0 +1,551 @@
|
|
|
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 a 4x
|
|
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 4x 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: four Kaiser-windowed sinc phases of 32 taps,
|
|
243
|
+
* each in time order over x[n-15] .. x[n+16], estimating the signal at
|
|
244
|
+
* n + 1/8, 3/8, 5/8 and 7/8. With the samples themselves they give the 4x
|
|
245
|
+
* oversampled true peak. BS.1770-4 Annex 2's 12-tap example filter rolls
|
|
246
|
+
* off early and reads dense, bright material (supersaws, distortion) up to
|
|
247
|
+
* half a dB low; this one stays within a few hundredths of a 64x reference.
|
|
248
|
+
*/
|
|
249
|
+
export function truePeakPhases(): readonly Float64Array[] {
|
|
250
|
+
phases ??= [1, 3, 5, 7].map((eighth) => {
|
|
251
|
+
const frac = eighth / 8;
|
|
252
|
+
const taps = new Float64Array(TAPS_PER_PHASE);
|
|
253
|
+
const before = TAPS_PER_PHASE / 2 - 1;
|
|
254
|
+
const norm = besselI0(TRUE_PEAK_BETA);
|
|
255
|
+
for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1) {
|
|
256
|
+
const x = tap - before - frac;
|
|
257
|
+
const m = x / (TAPS_PER_PHASE / 2);
|
|
258
|
+
const window =
|
|
259
|
+
Math.abs(m) <= 1
|
|
260
|
+
? besselI0(TRUE_PEAK_BETA * Math.sqrt(1 - m * m)) / norm
|
|
261
|
+
: 0;
|
|
262
|
+
taps[tap] = (Math.sin(Math.PI * x) / (Math.PI * x)) * window;
|
|
263
|
+
}
|
|
264
|
+
return taps;
|
|
265
|
+
});
|
|
266
|
+
return phases;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Largest absolute value between each sample and the next (both samples
|
|
271
|
+
* and the four interpolated points), as `out[n]` for n .. n+1.
|
|
272
|
+
*/
|
|
273
|
+
export function interSamplePeaks(
|
|
274
|
+
channel: Float64Array,
|
|
275
|
+
loop: boolean,
|
|
276
|
+
out: Float64Array,
|
|
277
|
+
): void {
|
|
278
|
+
const n = channel.length;
|
|
279
|
+
const branches = truePeakPhases();
|
|
280
|
+
const before = TAPS_PER_PHASE / 2 - 1;
|
|
281
|
+
const at = (index: number): number => {
|
|
282
|
+
if (index >= 0 && index < n) return channel[index]!;
|
|
283
|
+
if (!loop || n === 0) return 0;
|
|
284
|
+
return channel[((index % n) + n) % n]!;
|
|
285
|
+
};
|
|
286
|
+
for (let index = 0; index < n; index += 1) {
|
|
287
|
+
let peak = Math.abs(channel[index]!);
|
|
288
|
+
const next = Math.abs(at(index + 1));
|
|
289
|
+
if (next > peak) peak = next;
|
|
290
|
+
const inside = index - before >= 0 && index + TAPS_PER_PHASE - before <= n;
|
|
291
|
+
for (const taps of branches) {
|
|
292
|
+
let sum = 0;
|
|
293
|
+
if (inside)
|
|
294
|
+
for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1)
|
|
295
|
+
sum += channel[index - before + tap]! * taps[tap]!;
|
|
296
|
+
else
|
|
297
|
+
for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1)
|
|
298
|
+
sum += at(index - before + tap) * taps[tap]!;
|
|
299
|
+
const magnitude = Math.abs(sum);
|
|
300
|
+
if (magnitude > peak) peak = magnitude;
|
|
301
|
+
}
|
|
302
|
+
if (peak > out[index]!) out[index] = peak;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The 4x oversampled true peak of a stereo buffer, linear. */
|
|
307
|
+
export function truePeakGain(
|
|
308
|
+
left: Float64Array,
|
|
309
|
+
right: Float64Array,
|
|
310
|
+
loop = false,
|
|
311
|
+
): number {
|
|
312
|
+
const peaks = new Float64Array(left.length);
|
|
313
|
+
interSamplePeaks(left, loop, peaks);
|
|
314
|
+
interSamplePeaks(right, loop, peaks);
|
|
315
|
+
let max = 0;
|
|
316
|
+
for (const peak of peaks) if (peak > max) max = peak;
|
|
317
|
+
return max;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Integrated loudness only (no series, no true peak), LUFS. */
|
|
321
|
+
export function integratedLoudness(
|
|
322
|
+
left: Float64Array,
|
|
323
|
+
right: Float64Array,
|
|
324
|
+
sampleRate: number,
|
|
325
|
+
loop = false,
|
|
326
|
+
): number {
|
|
327
|
+
const sums = prefixSums(weightedPower(left, right, sampleRate, loop));
|
|
328
|
+
return gatedLoudness(blockPowers(sums, sampleRate, loop, MOMENTARY_SECONDS));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Window ends every 100 ms: complete windows only for a one-shot. */
|
|
332
|
+
function stepEnds(n: number, sampleRate: number, loop: boolean): number[] {
|
|
333
|
+
const step = sampleRate * LOUDNESS_STEP_SECONDS;
|
|
334
|
+
const count = loop
|
|
335
|
+
? Math.max(1, Math.round(n / step))
|
|
336
|
+
: Math.floor(n / step + 1e-9);
|
|
337
|
+
const ends: number[] = [];
|
|
338
|
+
for (let k = 1; k <= count; k += 1)
|
|
339
|
+
ends.push(loop ? Math.round((k * n) / count) : Math.round(k * step));
|
|
340
|
+
return ends;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function blockPowers(
|
|
344
|
+
sums: Float64Array,
|
|
345
|
+
sampleRate: number,
|
|
346
|
+
loop: boolean,
|
|
347
|
+
seconds: number,
|
|
348
|
+
): number[] {
|
|
349
|
+
const n = sums.length - 1;
|
|
350
|
+
const width = Math.round(seconds * sampleRate);
|
|
351
|
+
const powers: number[] = [];
|
|
352
|
+
for (const end of stepEnds(n, sampleRate, loop))
|
|
353
|
+
if (loop || end >= width) powers.push(windowPower(sums, end, width, loop));
|
|
354
|
+
return powers;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Grid of the momentary and short-term maxima: 10 ms (EBU Tech 3341). */
|
|
358
|
+
export const MAX_STEP_SECONDS = 0.01;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The loudest `width`-sample window, with window ends on a 10 ms grid (the
|
|
362
|
+
* update rate EBU Tech 3341 asks of max momentary and short-term), so a
|
|
363
|
+
* burst between two 100 ms series steps still reads its full loudness.
|
|
364
|
+
*/
|
|
365
|
+
function maxWindowLufs(
|
|
366
|
+
sums: Float64Array,
|
|
367
|
+
sampleRate: number,
|
|
368
|
+
width: number,
|
|
369
|
+
loop: boolean,
|
|
370
|
+
): number {
|
|
371
|
+
const n = sums.length - 1;
|
|
372
|
+
const step = sampleRate * MAX_STEP_SECONDS;
|
|
373
|
+
let max = -Infinity;
|
|
374
|
+
const consider = (end: number) => {
|
|
375
|
+
const value = powerToLufs(windowPower(sums, end, width, loop));
|
|
376
|
+
if (value > max) max = value;
|
|
377
|
+
};
|
|
378
|
+
if (loop) {
|
|
379
|
+
const count = Math.max(1, Math.round(n / step));
|
|
380
|
+
for (let k = 1; k <= count; k += 1) consider(Math.round((k * n) / count));
|
|
381
|
+
return max;
|
|
382
|
+
}
|
|
383
|
+
if (n < width) return max;
|
|
384
|
+
for (let k = 0; ; k += 1) {
|
|
385
|
+
const end = width + Math.round(k * step);
|
|
386
|
+
if (end > n) break;
|
|
387
|
+
consider(end);
|
|
388
|
+
}
|
|
389
|
+
consider(n);
|
|
390
|
+
return max;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Everything a loudness meter shows, for a stereo buffer. */
|
|
394
|
+
export function measureLoudness(
|
|
395
|
+
left: Float64Array,
|
|
396
|
+
right: Float64Array,
|
|
397
|
+
sampleRate: number,
|
|
398
|
+
options: LoudnessOptions = {},
|
|
399
|
+
): Loudness {
|
|
400
|
+
const loop = options.loop ?? false;
|
|
401
|
+
const n = left.length;
|
|
402
|
+
const sums = prefixSums(weightedPower(left, right, sampleRate, loop));
|
|
403
|
+
const ends = stepEnds(n, sampleRate, loop);
|
|
404
|
+
const momentaryWidth = Math.round(MOMENTARY_SECONDS * sampleRate);
|
|
405
|
+
const shortWidth = Math.round(SHORT_TERM_SECONDS * sampleRate);
|
|
406
|
+
const momentary = new Float64Array(ends.length);
|
|
407
|
+
const shortTerm = new Float64Array(ends.length);
|
|
408
|
+
const blocks: number[] = [];
|
|
409
|
+
const shortBlocks: number[] = [];
|
|
410
|
+
ends.forEach((end, index) => {
|
|
411
|
+
const m = windowPower(sums, end, momentaryWidth, loop);
|
|
412
|
+
const s = windowPower(sums, end, shortWidth, loop);
|
|
413
|
+
momentary[index] = powerToLufs(m);
|
|
414
|
+
shortTerm[index] = powerToLufs(s);
|
|
415
|
+
if (loop || end >= momentaryWidth) blocks.push(m);
|
|
416
|
+
if (loop || end >= shortWidth) shortBlocks.push(s);
|
|
417
|
+
});
|
|
418
|
+
const momentaryMax = maxWindowLufs(sums, sampleRate, momentaryWidth, loop);
|
|
419
|
+
const shortTermMax = maxWindowLufs(sums, sampleRate, shortWidth, loop);
|
|
420
|
+
let samplePeak = 0;
|
|
421
|
+
for (let index = 0; index < n; index += 1) {
|
|
422
|
+
const peak = Math.max(Math.abs(left[index]!), Math.abs(right[index]!));
|
|
423
|
+
if (peak > samplePeak) samplePeak = peak;
|
|
424
|
+
}
|
|
425
|
+
const truePeak =
|
|
426
|
+
options.truePeak === false
|
|
427
|
+
? samplePeak
|
|
428
|
+
: Math.max(samplePeak, truePeakGain(left, right, loop));
|
|
429
|
+
return Object.freeze({
|
|
430
|
+
integrated: gatedLoudness(blocks),
|
|
431
|
+
momentaryMax,
|
|
432
|
+
shortTermMax,
|
|
433
|
+
range: loudnessRange(shortBlocks),
|
|
434
|
+
truePeak: gainToDb(truePeak),
|
|
435
|
+
samplePeak: gainToDb(samplePeak),
|
|
436
|
+
momentary,
|
|
437
|
+
shortTerm,
|
|
438
|
+
step: LOUDNESS_STEP_SECONDS,
|
|
439
|
+
seconds: n / sampleRate,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Interleaved 16-bit stereo PCM as two float channels. */
|
|
444
|
+
export function pcmChannels(
|
|
445
|
+
pcm: Int16Array,
|
|
446
|
+
): readonly [Float64Array, Float64Array] {
|
|
447
|
+
const frames = Math.floor(pcm.length / 2);
|
|
448
|
+
const left = new Float64Array(frames);
|
|
449
|
+
const right = new Float64Array(frames);
|
|
450
|
+
for (let index = 0; index < frames; index += 1) {
|
|
451
|
+
left[index] = pcm[index * 2]! / 32767;
|
|
452
|
+
right[index] = pcm[index * 2 + 1]! / 32767;
|
|
453
|
+
}
|
|
454
|
+
return [left, right];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Spectral bands `measureMix` reports, low edge to high edge in Hz. */
|
|
458
|
+
export const MIX_BANDS = Object.freeze([
|
|
459
|
+
{ name: "sub", low: 20, high: 60 },
|
|
460
|
+
{ name: "bass", low: 60, high: 250 },
|
|
461
|
+
{ name: "low-mid", low: 250, high: 2_000 },
|
|
462
|
+
{ name: "high-mid", low: 2_000, high: 6_000 },
|
|
463
|
+
{ name: "high", low: 6_000, high: 20_000 },
|
|
464
|
+
] as const);
|
|
465
|
+
|
|
466
|
+
export type MixMeasurement = Readonly<{
|
|
467
|
+
loudness: Loudness;
|
|
468
|
+
/** Share of energy per band in dB relative to the whole (0 = all of it). */
|
|
469
|
+
bands: Readonly<Record<(typeof MIX_BANDS)[number]["name"], number>>;
|
|
470
|
+
/** Pearson correlation of left and right: 1 mono, 0 wide, <0 out of phase. */
|
|
471
|
+
correlation: number;
|
|
472
|
+
/** Side energy relative to mid energy, dB (-Infinity for mono). */
|
|
473
|
+
sideDb: number;
|
|
474
|
+
/** Peak-to-loudness ratio (EBU PLR): true peak minus integrated, LU. */
|
|
475
|
+
plr: number;
|
|
476
|
+
}>;
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Loudness, spectral balance (Welch average of 4096-point Hann frames),
|
|
480
|
+
* stereo correlation and side level of a stereo buffer.
|
|
481
|
+
*/
|
|
482
|
+
export function measureMix(
|
|
483
|
+
left: Float64Array,
|
|
484
|
+
right: Float64Array,
|
|
485
|
+
sampleRate: number,
|
|
486
|
+
options: LoudnessOptions = {},
|
|
487
|
+
): MixMeasurement {
|
|
488
|
+
const loudness = measureLoudness(left, right, sampleRate, options);
|
|
489
|
+
const n = left.length;
|
|
490
|
+
let lr = 0;
|
|
491
|
+
let ll = 0;
|
|
492
|
+
let rr = 0;
|
|
493
|
+
let mid = 0;
|
|
494
|
+
let side = 0;
|
|
495
|
+
for (let index = 0; index < n; index += 1) {
|
|
496
|
+
const l = left[index]!;
|
|
497
|
+
const r = right[index]!;
|
|
498
|
+
lr += l * r;
|
|
499
|
+
ll += l * l;
|
|
500
|
+
rr += r * r;
|
|
501
|
+
mid += ((l + r) / 2) ** 2;
|
|
502
|
+
side += ((l - r) / 2) ** 2;
|
|
503
|
+
}
|
|
504
|
+
const correlation = ll > 0 && rr > 0 ? lr / Math.sqrt(ll * rr) : 1;
|
|
505
|
+
const size = 4096;
|
|
506
|
+
const window = new Float64Array(size);
|
|
507
|
+
for (let index = 0; index < size; index += 1)
|
|
508
|
+
window[index] = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / size);
|
|
509
|
+
const spectrum = new Float64Array(size / 2 + 1);
|
|
510
|
+
const re = new Float64Array(size);
|
|
511
|
+
const im = new Float64Array(size);
|
|
512
|
+
const hop = size / 2;
|
|
513
|
+
for (let start = 0; start === 0 || start + size <= n; start += hop) {
|
|
514
|
+
for (const channel of [left, right]) {
|
|
515
|
+
for (let index = 0; index < size; index += 1) {
|
|
516
|
+
const at = start + index;
|
|
517
|
+
re[index] = at < n ? channel[at]! * window[index]! : 0;
|
|
518
|
+
im[index] = 0;
|
|
519
|
+
}
|
|
520
|
+
fftInPlace(re, im);
|
|
521
|
+
for (let bin = 0; bin <= size / 2; bin += 1)
|
|
522
|
+
spectrum[bin]! += re[bin]! * re[bin]! + im[bin]! * im[bin]!;
|
|
523
|
+
}
|
|
524
|
+
if (start + size >= n) break;
|
|
525
|
+
}
|
|
526
|
+
let total = 0;
|
|
527
|
+
const energy = MIX_BANDS.map(() => 0);
|
|
528
|
+
for (let bin = 1; bin <= size / 2; bin += 1) {
|
|
529
|
+
const hz = (bin * sampleRate) / size;
|
|
530
|
+
total += spectrum[bin]!;
|
|
531
|
+
const band = MIX_BANDS.findIndex(
|
|
532
|
+
(entry) => hz >= entry.low && hz < entry.high,
|
|
533
|
+
);
|
|
534
|
+
if (band >= 0) energy[band]! += spectrum[bin]!;
|
|
535
|
+
}
|
|
536
|
+
const bands = Object.fromEntries(
|
|
537
|
+
MIX_BANDS.map((band, index) => [
|
|
538
|
+
band.name,
|
|
539
|
+
total > 0 && energy[index]! > 0
|
|
540
|
+
? 10 * Math.log10(energy[index]! / total)
|
|
541
|
+
: -Infinity,
|
|
542
|
+
]),
|
|
543
|
+
) as MixMeasurement["bands"];
|
|
544
|
+
return Object.freeze({
|
|
545
|
+
loudness,
|
|
546
|
+
bands,
|
|
547
|
+
correlation,
|
|
548
|
+
sideDb: mid > 0 && side > 0 ? 10 * Math.log10(side / mid) : -Infinity,
|
|
549
|
+
plr: loudness.truePeak - loudness.integrated,
|
|
550
|
+
});
|
|
551
|
+
}
|