@map-audio/pam-mcp-server 1.12.0 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -5
- package/dist/bridge.d.ts +17 -3
- package/dist/bridge.js +81 -20
- package/dist/bridge.js.map +1 -1
- package/dist/fgemma-format.d.ts +119 -0
- package/dist/fgemma-format.js +292 -0
- package/dist/fgemma-format.js.map +1 -0
- package/dist/fgemma-runner.d.ts +27 -0
- package/dist/fgemma-runner.js +475 -0
- package/dist/fgemma-runner.js.map +1 -0
- package/dist/manifest-cell1.json +7950 -0
- package/dist/manifest-mfx.json +13381 -0
- package/dist/manifest.d.ts +2 -1
- package/dist/manifest.js +79 -29
- package/dist/manifest.js.map +1 -1
- package/dist/manifest.json +821 -130
- package/dist/mixAssist.d.ts +155 -0
- package/dist/mixAssist.js +647 -0
- package/dist/mixAssist.js.map +1 -0
- package/dist/presetSave.d.ts +10 -0
- package/dist/presetSave.js +22 -0
- package/dist/presetSave.js.map +1 -0
- package/dist/server.js +2204 -140
- package/dist/server.js.map +1 -1
- package/package.json +5 -2
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Vendored from shared/mixAssist/ (server rootDir cannot import outside src/;
|
|
3
|
+
// the fgemma-format.ts precedent). Regenerate with:
|
|
4
|
+
// node scripts/sync-mixassist.mjs
|
|
5
|
+
// Parity is pinned by interface/test/mixAssistServerParity.test.ts.
|
|
6
|
+
/* eslint-disable */
|
|
7
|
+
// ===== shared/mixAssist/targets.ts =====
|
|
8
|
+
// Mix Assist rule constants. Every clamp/threshold the engine applies lives
|
|
9
|
+
// here so calibration against the factory corpus is a constants-only change.
|
|
10
|
+
// The -lite rules descend from the QMUL cross-adaptive school (Ward partial
|
|
11
|
+
// loudness, Ma DRC regressions, Hafezi masking cuts) — see
|
|
12
|
+
// docs/plans/AUTOMIX_RESEARCH.md §3.1.
|
|
13
|
+
// --- balance (fader) stage ---
|
|
14
|
+
export const MAX_FADER_MOVE_DB = 12; // AUTOMIX_RESEARCH §5 clamp
|
|
15
|
+
export const FADER_EPSILON_DB = 0.3; // no balance pass when every move sits inside this dead-zone
|
|
16
|
+
export const FADER_COMPENSATION_EPSILON_DB = 0.05; // retain 0.1 dB companion moves that preserve bus power
|
|
17
|
+
export const MIN_STEM_LUFS = -50; // quieter stems read as "not sounding" — skipped
|
|
18
|
+
// Ward-lite: dB of effective loudness a fully-masked cell loses (its measured
|
|
19
|
+
// LUFS overstates how loud it reads inside the mix).
|
|
20
|
+
export const MASK_PENALTY_DB_PER_UNIT = 3;
|
|
21
|
+
// Offsets are RELATIVE (the bus normalization cancels any uniform shift).
|
|
22
|
+
// kick sits ABOVE the stem power mean: PAM's genre corpus (techno/house/dnb/
|
|
23
|
+
// trap/boom_bap/drill) mixes kick-forward, and an equal-loudness target that
|
|
24
|
+
// pulls a deliberately hot kick down to the mean reads as the assistant
|
|
25
|
+
// "killing the kick" — especially live, where AUTO feeds this solve into the
|
|
26
|
+
// signed rider every second.
|
|
27
|
+
export const ROLE_OFFSETS_DB = { kick: 2, drums: 0, bass: 0, melodic: -2, ambient: -5 };
|
|
28
|
+
// Fader floor: -60 is the true-mute sentinel (dsp/modules/sampler.ts) — never
|
|
29
|
+
// propose at or below it.
|
|
30
|
+
export const FADER_FLOOR_DB = -59;
|
|
31
|
+
export const FADER_CEIL_DB = 12;
|
|
32
|
+
// --- role classification ---
|
|
33
|
+
export const BASS_LOW_FRACTION = 0.5; // lowFraction() above this = low-dominant
|
|
34
|
+
// Low-dominant cells need a lower onset gate than the generic drum rule: a
|
|
35
|
+
// four-on-the-floor kick is 1.5-2 onsets/sec, and half-time/boom-bap kick
|
|
36
|
+
// patterns sit at 0.5-0.9 (onsetDensity divides by the WHOLE capture, silence
|
|
37
|
+
// included). The crest gate (plr > CREST_ENGAGE_DB) is what separates a kick
|
|
38
|
+
// from a sustained bass; this floor only rejects one-shot rumbles.
|
|
39
|
+
export const LOW_TRANSIENT_ONSET_DENSITY = 0.5;
|
|
40
|
+
// Classifier crest gate for the kick path — deliberately LOWER than the DYN
|
|
41
|
+
// stage's CREST_ENGAGE_DB: a heavily compressed/clipped kick reads PLR 10-13
|
|
42
|
+
// and must still classify as a kick (offset + EQ guard) while staying
|
|
43
|
+
// comp-ineligible (DYN keeps its own 14 dB gate). Sustained basslines read
|
|
44
|
+
// PLR ~6-9 and stay bass.
|
|
45
|
+
export const DRUMS_CREST_MIN_DB = 10;
|
|
46
|
+
export const DRUMS_ONSET_DENSITY = 3; // onsets/sec at or above = drums
|
|
47
|
+
export const AMBIENT_ONSET_DENSITY = 0.3; // at or below (with low crest) = ambient
|
|
48
|
+
export const AMBIENT_MAX_PLR = 8;
|
|
49
|
+
// --- pan stage ---
|
|
50
|
+
export const PAN_EPSILON = 0.05;
|
|
51
|
+
export const MAX_PAN_SPREAD = 0.35; // symmetric spread target
|
|
52
|
+
export const PAN_CENTER_GUARD = 0.15; // cells already panned wider are left alone
|
|
53
|
+
export const BASS_RECENTER_CORR_LOW = 0.7; // master corrLow below this recenters bass
|
|
54
|
+
// --- masking / EQ stage ---
|
|
55
|
+
export const MASKING_PAIR_THRESHOLD = 0.5; // overlap below this never triggers moves
|
|
56
|
+
export const MAX_EQ_CUT_DB = 3; // Hafezi bound
|
|
57
|
+
export const MAX_EQ_LOW_CUT_DB = 1.5; // preserve low-end weight on non-bass maskers
|
|
58
|
+
export const MAX_EQ_CUTS = 2;
|
|
59
|
+
export const EQ_EPSILON_DB = 0.3;
|
|
60
|
+
// --- dynamics (comp) stage ---
|
|
61
|
+
export const CREST_ENGAGE_DB = 14; // plr above this makes a cell comp-eligible
|
|
62
|
+
export const COMP_RATIO_MIN = 1.5;
|
|
63
|
+
export const COMP_RATIO_MAX = 4;
|
|
64
|
+
export const COMP_EPSILON = 0.05;
|
|
65
|
+
// onset density that saturates the percussivity weighting
|
|
66
|
+
export const PERCUSSIVITY_FULL_ONSET_DENSITY = 8;
|
|
67
|
+
// Param defaults for the propose() epsilon gate when `current` lacks a key
|
|
68
|
+
// (values from build.js's manifest defaults).
|
|
69
|
+
export const MIX_PARAM_DEFAULTS = {
|
|
70
|
+
volume: 0,
|
|
71
|
+
pan: 0,
|
|
72
|
+
eqLowGain: 0,
|
|
73
|
+
eqMidGain: 0,
|
|
74
|
+
eqHighGain: 0,
|
|
75
|
+
compressorThreshold: 0,
|
|
76
|
+
compressorRatioAmount: 2,
|
|
77
|
+
compressorAttack: 0.5,
|
|
78
|
+
compressorRelease: 200,
|
|
79
|
+
compressorRatio: 0, // legacy-mislabelled MAKEUP gain, dB (see build.js)
|
|
80
|
+
};
|
|
81
|
+
export function mixParamDefault(key) {
|
|
82
|
+
const suffix = key.replace(/^cell\d+_/, '');
|
|
83
|
+
return MIX_PARAM_DEFAULTS[suffix] ?? 0;
|
|
84
|
+
}
|
|
85
|
+
// ===== shared/mixAssist/bark.ts =====
|
|
86
|
+
// Bark band model — the TS mirror of modules/analysis/BarkScale.h. The 26
|
|
87
|
+
// edge literals MUST match the C++ table verbatim; parity is pinned by
|
|
88
|
+
// interface/test/mixAssistBark.test.ts (and the C++ [mixassist] suite pins
|
|
89
|
+
// the same table's behavior). Change both together or neither.
|
|
90
|
+
export const BARK_BAND_COUNT = 25;
|
|
91
|
+
export const BARK_BAND_EDGES_HZ = [
|
|
92
|
+
20.0, 100.0, 200.0, 300.0, 400.0, 510.0, 630.0, 770.0, 920.0, 1080.0, 1270.0,
|
|
93
|
+
1480.0, 1720.0, 2000.0, 2320.0, 2700.0, 3150.0, 3700.0, 4400.0, 5300.0,
|
|
94
|
+
6400.0, 7700.0, 9500.0, 12000.0, 15500.0, 20000.0,
|
|
95
|
+
];
|
|
96
|
+
export function bandForFrequency(hz) {
|
|
97
|
+
if (hz <= BARK_BAND_EDGES_HZ[0])
|
|
98
|
+
return 0;
|
|
99
|
+
for (let b = 0; b < BARK_BAND_COUNT; b++) {
|
|
100
|
+
if (hz < BARK_BAND_EDGES_HZ[b + 1])
|
|
101
|
+
return b;
|
|
102
|
+
}
|
|
103
|
+
return BARK_BAND_COUNT - 1;
|
|
104
|
+
}
|
|
105
|
+
// Geometric mean of the band edges — the log-domain center.
|
|
106
|
+
export function barkBandCenterHz(band) {
|
|
107
|
+
const b = Math.max(0, Math.min(BARK_BAND_COUNT - 1, band));
|
|
108
|
+
return Math.sqrt(BARK_BAND_EDGES_HZ[b] * BARK_BAND_EDGES_HZ[b + 1]);
|
|
109
|
+
}
|
|
110
|
+
export const dbToPower = (db) => Math.pow(10, db / 10);
|
|
111
|
+
// Power-weighted spectral centroid over band centers, Hz. 0 when silent.
|
|
112
|
+
export function barkCentroidHz(barkDb) {
|
|
113
|
+
let power = 0;
|
|
114
|
+
let weighted = 0;
|
|
115
|
+
for (let b = 0; b < Math.min(barkDb.length, BARK_BAND_COUNT); b++) {
|
|
116
|
+
const p = dbToPower(barkDb[b]);
|
|
117
|
+
power += p;
|
|
118
|
+
weighted += p * barkBandCenterHz(b);
|
|
119
|
+
}
|
|
120
|
+
return power > 0 ? weighted / power : 0;
|
|
121
|
+
}
|
|
122
|
+
// Fraction of LINEAR power in bands wholly below `belowHz`. 0 when silent.
|
|
123
|
+
export function lowFraction(barkDb, belowHz = 200) {
|
|
124
|
+
let total = 0;
|
|
125
|
+
let low = 0;
|
|
126
|
+
for (let b = 0; b < Math.min(barkDb.length, BARK_BAND_COUNT); b++) {
|
|
127
|
+
const p = dbToPower(barkDb[b]);
|
|
128
|
+
total += p;
|
|
129
|
+
if (BARK_BAND_EDGES_HZ[b + 1] <= belowHz)
|
|
130
|
+
low += p;
|
|
131
|
+
}
|
|
132
|
+
return total > 0 ? low / total : 0;
|
|
133
|
+
}
|
|
134
|
+
// ===== shared/mixAssist/masking.ts =====
|
|
135
|
+
// Cross-cell Bark-band masking overlap (AutoMix M1). Deliberately simple:
|
|
136
|
+
// textbook objective masking models track perceived masking poorly (Hafezi &
|
|
137
|
+
// Reiss, JAES 2015) — a bounded band-overlap fraction works better as a rule
|
|
138
|
+
// input than a psychoacoustic model.
|
|
139
|
+
// Fraction of A's energy that B overlaps: sum_b min(Pa, Pb) / sum_b Pa.
|
|
140
|
+
// Asymmetric (a quiet cell under a loud one reads ~1; the loud one reads
|
|
141
|
+
// less). 0 when A is silent.
|
|
142
|
+
export function maskingOverlap(aDb, bDb) {
|
|
143
|
+
let sumA = 0;
|
|
144
|
+
let shared = 0;
|
|
145
|
+
const n = Math.min(aDb.length, bDb.length, BARK_BAND_COUNT);
|
|
146
|
+
for (let b = 0; b < n; b++) {
|
|
147
|
+
const pa = dbToPower(aDb[b]);
|
|
148
|
+
const pb = dbToPower(bDb[b]);
|
|
149
|
+
sumA += pa;
|
|
150
|
+
shared += Math.min(pa, pb);
|
|
151
|
+
}
|
|
152
|
+
return sumA > 0 ? shared / sumA : 0;
|
|
153
|
+
}
|
|
154
|
+
// M[i][j] = fraction of cell i's energy overlapped by cell j. Diagonal 0.
|
|
155
|
+
export function maskingMatrix(barkDbByCell) {
|
|
156
|
+
const n = barkDbByCell.length;
|
|
157
|
+
const m = [];
|
|
158
|
+
for (let i = 0; i < n; i++) {
|
|
159
|
+
m.push(new Array(n).fill(0));
|
|
160
|
+
for (let j = 0; j < n; j++) {
|
|
161
|
+
if (i !== j)
|
|
162
|
+
m[i][j] = maskingOverlap(barkDbByCell[i], barkDbByCell[j]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return m;
|
|
166
|
+
}
|
|
167
|
+
// Region carrying the largest shared power between two stems, split at
|
|
168
|
+
// 300 Hz / 5 kHz — the mixer 3-band EQ's split, so a cut lands on the right
|
|
169
|
+
// knob. Band membership by log-center.
|
|
170
|
+
export function dominantOverlapRegion(aDb, bDb) {
|
|
171
|
+
const shared = { low: 0, mid: 0, high: 0 };
|
|
172
|
+
const n = Math.min(aDb.length, bDb.length, BARK_BAND_COUNT);
|
|
173
|
+
for (let b = 0; b < n; b++) {
|
|
174
|
+
const center = barkBandCenterHz(b);
|
|
175
|
+
const region = center < 300 ? 'low' : center >= 5000 ? 'high' : 'mid';
|
|
176
|
+
shared[region] += Math.min(dbToPower(aDb[b]), dbToPower(bDb[b]));
|
|
177
|
+
}
|
|
178
|
+
if (shared.low >= shared.mid && shared.low >= shared.high)
|
|
179
|
+
return 'low';
|
|
180
|
+
if (shared.high >= shared.mid)
|
|
181
|
+
return 'high';
|
|
182
|
+
return 'mid';
|
|
183
|
+
}
|
|
184
|
+
// ===== shared/mixAssist/parse.ts =====
|
|
185
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
186
|
+
function parseStem(raw) {
|
|
187
|
+
// Number.isFinite per band: one NaN/Infinity entry would silently poison the
|
|
188
|
+
// masking matrix (dbToPower propagates it through every overlap sum).
|
|
189
|
+
const bark = Array.isArray(raw.bark) && raw.bark.every((b) => typeof b === 'number' && Number.isFinite(b))
|
|
190
|
+
? raw.bark
|
|
191
|
+
: null;
|
|
192
|
+
if (!bark)
|
|
193
|
+
return null;
|
|
194
|
+
return {
|
|
195
|
+
lufsI: num(raw.i),
|
|
196
|
+
lufsS: num(raw.s),
|
|
197
|
+
lufsRoll: num(raw.roll),
|
|
198
|
+
lra: num(raw.lra),
|
|
199
|
+
truePeakDb: num(raw.tp),
|
|
200
|
+
samplePeakDb: num(raw.sp),
|
|
201
|
+
psr: num(raw.psr),
|
|
202
|
+
plr: num(raw.plr),
|
|
203
|
+
corr: num(raw.corr),
|
|
204
|
+
corrLow: num(raw.corrLow),
|
|
205
|
+
onsets: num(raw.onsets) ?? 0,
|
|
206
|
+
onsetDensity: num(raw.onsetDensity) ?? 0,
|
|
207
|
+
seconds: num(raw.secs) ?? 0,
|
|
208
|
+
barkDb: bark,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// Maps the native analyzeMixStems payload (short keys, sentinel fields
|
|
212
|
+
// dropped) into the shared MixAnalysis shape. Returns null on anything
|
|
213
|
+
// malformed — callers treat that as "no analysis".
|
|
214
|
+
export function parseMixAnalysisPayload(raw) {
|
|
215
|
+
if (typeof raw !== 'object' || raw === null)
|
|
216
|
+
return null;
|
|
217
|
+
const o = raw;
|
|
218
|
+
if (!Array.isArray(o.cells) || typeof o.master !== 'object' || o.master === null)
|
|
219
|
+
return null;
|
|
220
|
+
const cells = [];
|
|
221
|
+
for (const c of o.cells) {
|
|
222
|
+
if (typeof c !== 'object' || c === null)
|
|
223
|
+
return null;
|
|
224
|
+
const stem = parseStem(c);
|
|
225
|
+
const cellId = num(c.cellId);
|
|
226
|
+
if (!stem || cellId === undefined)
|
|
227
|
+
return null;
|
|
228
|
+
cells.push({ cellId, ...stem });
|
|
229
|
+
}
|
|
230
|
+
const master = parseStem(o.master);
|
|
231
|
+
if (!master)
|
|
232
|
+
return null;
|
|
233
|
+
return {
|
|
234
|
+
epoch: num(o.epoch) ?? 0,
|
|
235
|
+
live: o.live === true,
|
|
236
|
+
sampleRate: num(o.sampleRate) ?? 48000,
|
|
237
|
+
durationSeconds: num(o.durationSeconds) ?? 0,
|
|
238
|
+
bpm: num(o.bpm) ?? 120,
|
|
239
|
+
cells,
|
|
240
|
+
master,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// The Knob.tsx mod-badge idiom: envelopes/vary/paramSeqs match by paramId;
|
|
244
|
+
// LFO and macro links count only when enabled. Macro exclusions are per cell.
|
|
245
|
+
export function paramIsModulated(lists, paramId) {
|
|
246
|
+
if (lists.envelopes?.some((e) => e?.paramId === paramId))
|
|
247
|
+
return true;
|
|
248
|
+
if (lists.lfos?.some((l) => l?.paramId === paramId && (l.enabled === 1 || l.enabled === true)))
|
|
249
|
+
return true;
|
|
250
|
+
if (lists.vary?.some((v) => v?.paramId === paramId))
|
|
251
|
+
return true;
|
|
252
|
+
if (lists.paramSeqs?.some((p) => p?.paramId === paramId))
|
|
253
|
+
return true;
|
|
254
|
+
const match = paramId.match(/^cell(\d+)_(.+)$/);
|
|
255
|
+
if (!match)
|
|
256
|
+
return false;
|
|
257
|
+
const cellId = Number(match[1]);
|
|
258
|
+
const macroId = `macro_${match[2]}`;
|
|
259
|
+
const macro = lists.macros?.find((m) => m.paramId === macroId && (m.enabled === 1 || m.enabled === true));
|
|
260
|
+
return macro !== undefined && Number(macro[`exclude_cell${cellId}`] ?? 0) <= 0.5;
|
|
261
|
+
}
|
|
262
|
+
const numOr = (v, d) => typeof v === 'number' && Number.isFinite(v) ? v : d;
|
|
263
|
+
const EQ_SUFFIXES = ['eqLowGain', 'eqMidGain', 'eqHighGain'];
|
|
264
|
+
const COMP_SUFFIXES = [
|
|
265
|
+
'compressorThreshold',
|
|
266
|
+
'compressorRatioAmount',
|
|
267
|
+
'compressorAttack',
|
|
268
|
+
'compressorRelease',
|
|
269
|
+
'compressorRatio', // legacy-mislabelled MAKEUP gain (see build.js)
|
|
270
|
+
];
|
|
271
|
+
export const MIX_CURRENT_SUFFIXES = [
|
|
272
|
+
'volume',
|
|
273
|
+
'pan',
|
|
274
|
+
...EQ_SUFFIXES,
|
|
275
|
+
...COMP_SUFFIXES,
|
|
276
|
+
];
|
|
277
|
+
export function buildMixMeasurementFromState(analysis, flatState, mods = {}, options = {}) {
|
|
278
|
+
const cells = analysis.cells.map((a) => {
|
|
279
|
+
const id = a.cellId;
|
|
280
|
+
const k = (suffix) => flatState[`cell${id}_${suffix}`];
|
|
281
|
+
const context = {
|
|
282
|
+
cellId: id,
|
|
283
|
+
// Cached analysis may outlive its source; callers with live state supply
|
|
284
|
+
// the current presence check, while headless callers retain legacy true.
|
|
285
|
+
hasSource: options.hasSource?.(id) ?? true,
|
|
286
|
+
muted: numOr(k('mute'), 0) > 0.5,
|
|
287
|
+
soloed: numOr(k('solo'), 0) > 0.5,
|
|
288
|
+
volume: numOr(k('volume'), 0),
|
|
289
|
+
pan: numOr(k('pan'), 0),
|
|
290
|
+
eqLowGain: numOr(k('eqLowGain'), 0),
|
|
291
|
+
eqMidGain: numOr(k('eqMidGain'), 0),
|
|
292
|
+
eqHighGain: numOr(k('eqHighGain'), 0),
|
|
293
|
+
eqEnabled: numOr(k('eqEnabled'), 0) > 0.5,
|
|
294
|
+
compressorEnabled: numOr(k('compressorEnabled'), 0) > 0.5,
|
|
295
|
+
compressorThreshold: numOr(k('compressorThreshold'), 0),
|
|
296
|
+
compressorRatioAmount: numOr(k('compressorRatioAmount'), 2),
|
|
297
|
+
compressorAttack: numOr(k('compressorAttack'), 0.5),
|
|
298
|
+
compressorRelease: numOr(k('compressorRelease'), 200),
|
|
299
|
+
volumeModulated: paramIsModulated(mods, `cell${id}_volume`),
|
|
300
|
+
panModulated: paramIsModulated(mods, `cell${id}_pan`),
|
|
301
|
+
eqModulated: EQ_SUFFIXES.some((s) => paramIsModulated(mods, `cell${id}_${s}`)),
|
|
302
|
+
compModulated: COMP_SUFFIXES.some((s) => paramIsModulated(mods, `cell${id}_${s}`)),
|
|
303
|
+
};
|
|
304
|
+
return { analysis: a, context };
|
|
305
|
+
});
|
|
306
|
+
return {
|
|
307
|
+
cells,
|
|
308
|
+
masterLufsI: analysis.master.lufsI,
|
|
309
|
+
masterCorrLow: analysis.master.corrLow,
|
|
310
|
+
epoch: analysis.epoch,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
// The `current` record suggestMix diffs proposals against — every actuator key
|
|
314
|
+
// of every analyzed cell, read from the same flat state.
|
|
315
|
+
export function currentFlatFromState(analysis, flatState) {
|
|
316
|
+
const current = {};
|
|
317
|
+
for (const c of analysis.cells) {
|
|
318
|
+
for (const suffix of MIX_CURRENT_SUFFIXES) {
|
|
319
|
+
const key = `cell${c.cellId}_${suffix}`;
|
|
320
|
+
const v = flatState[key];
|
|
321
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
322
|
+
current[key] = v;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return current;
|
|
326
|
+
}
|
|
327
|
+
// ===== shared/mixAssist/engine.ts =====
|
|
328
|
+
// Mix Assist rule engine (AutoMix M2). Pure and deterministic — no stores, no
|
|
329
|
+
// clock, no RNG. Mirrors shared/masterAssistant/engine.ts's contract: moves
|
|
330
|
+
// carry ABSOLUTE target values, only keys that differ from `current` beyond an
|
|
331
|
+
// epsilon are emitted (the idempotence contract), and every guard leaves a
|
|
332
|
+
// guardsHit note instead of failing silently.
|
|
333
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
334
|
+
const round1 = (v) => Math.round(v * 10) / 10;
|
|
335
|
+
const round2 = (v) => Math.round(v * 100) / 100;
|
|
336
|
+
export function isLowDominant(a) {
|
|
337
|
+
return lowFraction(a.barkDb) > BASS_LOW_FRACTION;
|
|
338
|
+
}
|
|
339
|
+
export function classifyCellRole(a) {
|
|
340
|
+
const lowDominant = isLowDominant(a);
|
|
341
|
+
// DRUMS_CREST_MIN_DB, not CREST_ENGAGE_DB: a compressed/clipped kick reads
|
|
342
|
+
// PLR 10-13 and must still take the kick path (offset + EQ guard) while
|
|
343
|
+
// staying out of the DYN stage, which keeps its own 14 dB gate.
|
|
344
|
+
const kickCrest = (a.plr ?? 0) > DRUMS_CREST_MIN_DB;
|
|
345
|
+
if (lowDominant && kickCrest && a.onsetDensity >= LOW_TRANSIENT_ONSET_DENSITY)
|
|
346
|
+
return 'kick';
|
|
347
|
+
if (lowDominant)
|
|
348
|
+
return 'bass';
|
|
349
|
+
if (a.onsetDensity >= DRUMS_ONSET_DENSITY)
|
|
350
|
+
return 'drums';
|
|
351
|
+
if (a.onsetDensity <= AMBIENT_ONSET_DENSITY && (a.plr ?? 0) < AMBIENT_MAX_PLR)
|
|
352
|
+
return 'ambient';
|
|
353
|
+
return 'melodic';
|
|
354
|
+
}
|
|
355
|
+
// Shift all deltas by one common offset so the summed bus power is preserved:
|
|
356
|
+
// offset = 10log10(sum 10^((L+d)/10) / sum 10^(L/10)); subtracting it from
|
|
357
|
+
// every delta lands the post-move sum exactly on the pre-move sum. Uniform
|
|
358
|
+
// role offsets therefore cancel — only RELATIVE differences move faders.
|
|
359
|
+
export function normalizeMovesToBus(deltasDb, lufs) {
|
|
360
|
+
let before = 0;
|
|
361
|
+
let after = 0;
|
|
362
|
+
for (let i = 0; i < deltasDb.length; i++) {
|
|
363
|
+
before += Math.pow(10, lufs[i] / 10);
|
|
364
|
+
after += Math.pow(10, (lufs[i] + deltasDb[i]) / 10);
|
|
365
|
+
}
|
|
366
|
+
if (before <= 0 || after <= 0)
|
|
367
|
+
return deltasDb.slice();
|
|
368
|
+
const offset = 10 * Math.log10(after / before);
|
|
369
|
+
return deltasDb.map((d) => d - offset);
|
|
370
|
+
}
|
|
371
|
+
export function normalizeMovesToBusBounded(rawDeltasDb, lufs, currentVolumes) {
|
|
372
|
+
if (rawDeltasDb.length === 0)
|
|
373
|
+
return [];
|
|
374
|
+
const powers = lufs.map((l) => Math.pow(10, l / 10));
|
|
375
|
+
const before = powers.reduce((sum, power) => sum + power, 0);
|
|
376
|
+
if (before <= 0)
|
|
377
|
+
return rawDeltasDb.slice();
|
|
378
|
+
const lower = currentVolumes.map((v) => Math.max(-MAX_FADER_MOVE_DB, FADER_FLOOR_DB - v));
|
|
379
|
+
const upper = currentVolumes.map((v) => Math.min(MAX_FADER_MOVE_DB, FADER_CEIL_DB - v));
|
|
380
|
+
const deltasAt = (offset) => rawDeltasDb.map((d, i) => clamp(d - offset, lower[i], upper[i]));
|
|
381
|
+
const errorAt = (offset) => {
|
|
382
|
+
const deltas = deltasAt(offset);
|
|
383
|
+
let after = 0;
|
|
384
|
+
for (let i = 0; i < deltas.length; i++)
|
|
385
|
+
after += powers[i] * Math.pow(10, deltas[i] / 10);
|
|
386
|
+
return after - before;
|
|
387
|
+
};
|
|
388
|
+
let lo = -120;
|
|
389
|
+
let hi = 120;
|
|
390
|
+
for (let iteration = 0; iteration < 80; iteration++) {
|
|
391
|
+
const mid = 0.5 * (lo + hi);
|
|
392
|
+
if (errorAt(mid) > 0)
|
|
393
|
+
lo = mid;
|
|
394
|
+
else
|
|
395
|
+
hi = mid;
|
|
396
|
+
}
|
|
397
|
+
return deltasAt(0.5 * (lo + hi));
|
|
398
|
+
}
|
|
399
|
+
export function suggestMix(m, options, current = {}) {
|
|
400
|
+
const moves = [];
|
|
401
|
+
const guardsHit = [];
|
|
402
|
+
const flat = {};
|
|
403
|
+
const scope = new Set(options.scopes);
|
|
404
|
+
const cur = (key) => current[key] ?? mixParamDefault(key);
|
|
405
|
+
const propose = (cellId, key, value, why, epsilon) => {
|
|
406
|
+
if (Math.abs(value - cur(key)) > epsilon) {
|
|
407
|
+
moves.push({ key, value, why, cellId });
|
|
408
|
+
flat[key] = value;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
// ---- eligibility -------------------------------------------------------
|
|
412
|
+
const anySolo = m.cells.some((c) => c.context.soloed);
|
|
413
|
+
const sorted = [...m.cells].sort((a, b) => a.context.cellId - b.context.cellId);
|
|
414
|
+
const participants = [];
|
|
415
|
+
for (const cell of sorted) {
|
|
416
|
+
const ctx = cell.context;
|
|
417
|
+
if (!ctx.hasSource || ctx.muted)
|
|
418
|
+
continue;
|
|
419
|
+
if (anySolo && !ctx.soloed)
|
|
420
|
+
continue;
|
|
421
|
+
const lufs = cell.analysis.lufsI ?? cell.analysis.lufsS;
|
|
422
|
+
if (lufs === undefined || lufs <= MIN_STEM_LUFS)
|
|
423
|
+
continue;
|
|
424
|
+
participants.push({
|
|
425
|
+
cell,
|
|
426
|
+
lufs,
|
|
427
|
+
effectiveLufs: lufs,
|
|
428
|
+
maskedFraction: 0,
|
|
429
|
+
role: classifyCellRole(cell.analysis),
|
|
430
|
+
lowDominant: isLowDominant(cell.analysis),
|
|
431
|
+
index: participants.length,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (participants.length < 2) {
|
|
435
|
+
return {
|
|
436
|
+
moves: [],
|
|
437
|
+
guardsHit: [],
|
|
438
|
+
flat: {},
|
|
439
|
+
summary: 'Nothing to balance — fewer than two sounding cells',
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
// ---- masking matrix (Ward-lite partial-loudness correction) ------------
|
|
443
|
+
const matrix = maskingMatrix(participants.map((p) => p.cell.analysis.barkDb));
|
|
444
|
+
for (const p of participants) {
|
|
445
|
+
let masked = 0;
|
|
446
|
+
for (const q of participants) {
|
|
447
|
+
if (q.index !== p.index)
|
|
448
|
+
masked = Math.max(masked, matrix[p.index][q.index]);
|
|
449
|
+
}
|
|
450
|
+
p.maskedFraction = masked;
|
|
451
|
+
p.effectiveLufs = p.lufs - MASK_PENALTY_DB_PER_UNIT * masked;
|
|
452
|
+
}
|
|
453
|
+
// ---- BAL: partial-loudness fader balance --------------------------------
|
|
454
|
+
if (scope.has('balance')) {
|
|
455
|
+
// Loudness-power mean of the masking-corrected levels.
|
|
456
|
+
let meanPower = 0;
|
|
457
|
+
for (const p of participants)
|
|
458
|
+
meanPower += Math.pow(10, p.effectiveLufs / 10);
|
|
459
|
+
const ref = 10 * Math.log10(meanPower / participants.length);
|
|
460
|
+
const rawDeltas = participants.map((p) => ref + ROLE_OFFSETS_DB[p.role] - p.effectiveLufs);
|
|
461
|
+
// Guarded cells hold still. The bounded solve preserves the movable subset's
|
|
462
|
+
// power while respecting both the per-pass move cap and remaining fader travel.
|
|
463
|
+
const movable = participants.map((p) => !p.cell.context.volumeModulated);
|
|
464
|
+
const movableParticipants = participants.filter((_, i) => movable[i]);
|
|
465
|
+
const movableRawDeltas = rawDeltas.filter((_, i) => movable[i]);
|
|
466
|
+
const movableLufs = movableParticipants.map((p) => p.lufs);
|
|
467
|
+
const unconstrainedDeltas = normalizeMovesToBus(movableRawDeltas, movableLufs);
|
|
468
|
+
const movableDeltas = normalizeMovesToBusBounded(movableRawDeltas, movableLufs, movableParticipants.map((p) => cur(`cell${p.cell.context.cellId}_volume`)));
|
|
469
|
+
const hasPrimaryMove = movableDeltas.some((d) => Math.abs(d) > FADER_EPSILON_DB);
|
|
470
|
+
const balanceEpsilon = hasPrimaryMove ? FADER_COMPENSATION_EPSILON_DB : FADER_EPSILON_DB;
|
|
471
|
+
let mi = 0;
|
|
472
|
+
for (let i = 0; i < participants.length; i++) {
|
|
473
|
+
const p = participants[i];
|
|
474
|
+
const id = p.cell.context.cellId;
|
|
475
|
+
if (!movable[i]) {
|
|
476
|
+
guardsHit.push(`cell ${id} volume is modulated — left alone`);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const moveIndex = mi++;
|
|
480
|
+
const d = movableDeltas[moveIndex];
|
|
481
|
+
if (Math.abs(d - unconstrainedDeltas[moveIndex]) > FADER_EPSILON_DB)
|
|
482
|
+
guardsHit.push(`cell ${id} balance move limited by fader range`);
|
|
483
|
+
const key = `cell${id}_volume`;
|
|
484
|
+
const value = round1(cur(key) + d);
|
|
485
|
+
const direction = d >= 0 ? 'up' : 'down';
|
|
486
|
+
const excess = rawDeltas[i];
|
|
487
|
+
propose(cellId(p), key, value, `cell ${id} sits ${round1(Math.abs(excess))} dB ${excess < 0 ? 'above' : 'below'} the balance — ${direction} ${round1(Math.abs(d))} dB`, balanceEpsilon);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// ---- PAN: spread the worst-masked non-bass pair -------------------------
|
|
491
|
+
if (scope.has('pan')) {
|
|
492
|
+
const loudest = participants.reduce((a, b) => (b.effectiveLufs > a.effectiveLufs ? b : a));
|
|
493
|
+
let best = null;
|
|
494
|
+
for (let i = 0; i < participants.length; i++) {
|
|
495
|
+
for (let j = i + 1; j < participants.length; j++) {
|
|
496
|
+
const a = participants[i];
|
|
497
|
+
const b = participants[j];
|
|
498
|
+
if (a.lowDominant || b.lowDominant)
|
|
499
|
+
continue;
|
|
500
|
+
if (a === loudest || b === loudest)
|
|
501
|
+
continue;
|
|
502
|
+
const actx = a.cell.context;
|
|
503
|
+
const bctx = b.cell.context;
|
|
504
|
+
if (Math.abs(actx.pan) >= PAN_CENTER_GUARD || Math.abs(bctx.pan) >= PAN_CENTER_GUARD)
|
|
505
|
+
continue;
|
|
506
|
+
if (actx.panModulated || bctx.panModulated)
|
|
507
|
+
continue;
|
|
508
|
+
const overlap = Math.min(matrix[a.index][b.index], matrix[b.index][a.index]);
|
|
509
|
+
if (overlap < MASKING_PAIR_THRESHOLD)
|
|
510
|
+
continue;
|
|
511
|
+
if (dominantOverlapRegion(a.cell.analysis.barkDb, b.cell.analysis.barkDb) === 'low')
|
|
512
|
+
continue;
|
|
513
|
+
if (!best || overlap > best.overlap)
|
|
514
|
+
best = { a, b, overlap };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (best) {
|
|
518
|
+
const idA = best.a.cell.context.cellId;
|
|
519
|
+
const idB = best.b.cell.context.cellId;
|
|
520
|
+
const why = `cells ${idA} and ${idB} share ${Math.round(best.overlap * 100)}% of their spectrum — pan apart`;
|
|
521
|
+
propose(idA, `cell${idA}_pan`, -MAX_PAN_SPREAD, why, PAN_EPSILON);
|
|
522
|
+
propose(idB, `cell${idB}_pan`, MAX_PAN_SPREAD, why, PAN_EPSILON);
|
|
523
|
+
}
|
|
524
|
+
// Bass recenter, only when the master's low band actually reads unstable.
|
|
525
|
+
if (m.masterCorrLow !== undefined && m.masterCorrLow < BASS_RECENTER_CORR_LOW) {
|
|
526
|
+
for (const p of participants) {
|
|
527
|
+
const ctx = p.cell.context;
|
|
528
|
+
if (!p.lowDominant || Math.abs(ctx.pan) <= PAN_CENTER_GUARD || ctx.panModulated)
|
|
529
|
+
continue;
|
|
530
|
+
propose(ctx.cellId, `cell${ctx.cellId}_pan`, 0, `bass cancels in mono (correlation ${round2(m.masterCorrLow)}) — center it`, PAN_EPSILON);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// ---- EQ: bounded masking cuts on the masker's mixer 3-band --------------
|
|
535
|
+
if (scope.has('eq')) {
|
|
536
|
+
const candidates = [];
|
|
537
|
+
for (let i = 0; i < participants.length; i++) {
|
|
538
|
+
for (let j = i + 1; j < participants.length; j++) {
|
|
539
|
+
const a = participants[i];
|
|
540
|
+
const b = participants[j];
|
|
541
|
+
const overlap = Math.max(matrix[a.index][b.index], matrix[b.index][a.index]);
|
|
542
|
+
if (overlap < MASKING_PAIR_THRESHOLD)
|
|
543
|
+
continue;
|
|
544
|
+
const masker = a.effectiveLufs >= b.effectiveLufs ? a : b;
|
|
545
|
+
const masked = masker === a ? b : a;
|
|
546
|
+
candidates.push({ masked, masker, overlap });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
candidates.sort((x, y) => y.overlap - x.overlap);
|
|
550
|
+
const usedMaskers = new Set();
|
|
551
|
+
let cuts = 0;
|
|
552
|
+
for (const c of candidates) {
|
|
553
|
+
if (cuts >= MAX_EQ_CUTS)
|
|
554
|
+
break;
|
|
555
|
+
const maskerCtx = c.masker.cell.context;
|
|
556
|
+
const id = maskerCtx.cellId;
|
|
557
|
+
if (usedMaskers.has(id))
|
|
558
|
+
continue;
|
|
559
|
+
if (maskerCtx.eqModulated) {
|
|
560
|
+
guardsHit.push(`cell ${id} mixer EQ is modulated — no masking cut`);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const region = dominantOverlapRegion(c.masked.cell.analysis.barkDb, c.masker.cell.analysis.barkDb);
|
|
564
|
+
// Never carve the fundamental of a low-dominant source. Kick and bass
|
|
565
|
+
// conflicts belong to balance or sidechain, not a broad static low cut.
|
|
566
|
+
// Non-low-dominant sources can still be trimmed gently when their low
|
|
567
|
+
// spill masks another cell.
|
|
568
|
+
if (region === 'low' && c.masker.lowDominant) {
|
|
569
|
+
guardsHit.push(`cell ${id} is low-dominant — its low band is left alone`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const band = region === 'low' ? 'eqLowGain' : region === 'high' ? 'eqHighGain' : 'eqMidGain';
|
|
573
|
+
const key = `cell${id}_${band}`;
|
|
574
|
+
const maxCutDb = region === 'low' ? MAX_EQ_LOW_CUT_DB : MAX_EQ_CUT_DB;
|
|
575
|
+
const cutDb = Math.min(maxCutDb, 6 * c.overlap - 2);
|
|
576
|
+
if (cutDb <= EQ_EPSILON_DB)
|
|
577
|
+
continue;
|
|
578
|
+
const value = round1(cur(key) - cutDb);
|
|
579
|
+
propose(id, key, value, `cell ${id} covers cell ${c.masked.cell.context.cellId} in the ${region} band — cut ${round1(cutDb)} dB`, EQ_EPSILON_DB);
|
|
580
|
+
if (flat[key] !== undefined && !maskerCtx.eqEnabled) {
|
|
581
|
+
flat[`cell${id}_eqEnabled`] = 1;
|
|
582
|
+
guardsHit.push(`cell ${id} mixer EQ enabled for the cut`);
|
|
583
|
+
}
|
|
584
|
+
usedMaskers.add(id);
|
|
585
|
+
cuts++;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// ---- DYN: Ma-lite comp settings on crest-heavy percussive cells ---------
|
|
589
|
+
if (scope.has('dynamics')) {
|
|
590
|
+
for (const p of participants) {
|
|
591
|
+
const ctx = p.cell.context;
|
|
592
|
+
const a = p.cell.analysis;
|
|
593
|
+
const id = ctx.cellId;
|
|
594
|
+
if (a.plr === undefined || a.plr <= CREST_ENGAGE_DB)
|
|
595
|
+
continue;
|
|
596
|
+
if (p.role !== 'drums' && p.role !== 'melodic' && p.role !== 'kick')
|
|
597
|
+
continue;
|
|
598
|
+
if (ctx.compModulated) {
|
|
599
|
+
guardsHit.push(`cell ${id} compressor is modulated — left alone`);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (ctx.compressorEnabled) {
|
|
603
|
+
guardsHit.push(`cell ${id} compressor is already set up — left alone`);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const wp = Math.min(1, a.onsetDensity / PERCUSSIVITY_FULL_ONSET_DENSITY);
|
|
607
|
+
const ratio = round2(clamp(1.5 + 2.5 * wp, COMP_RATIO_MIN, COMP_RATIO_MAX));
|
|
608
|
+
// Stems are measured POST-fader but the compressor sits PRE-fader in the
|
|
609
|
+
// cell FX chain — divide the fader out (rideProfile.ts does the same for
|
|
610
|
+
// the rider's shares) or a trimmed cell gets a threshold too deep by its
|
|
611
|
+
// fader offset and over-compresses.
|
|
612
|
+
const stemLufs = (a.lufsS ?? a.lufsI ?? -20) - ctx.volume;
|
|
613
|
+
const threshold = round1(clamp(stemLufs + 6, -40, -3));
|
|
614
|
+
const attack = round2(clamp(2 + 25 * (1 - wp), 2, 30));
|
|
615
|
+
const release = round1(clamp(50 + 250 * (1 - wp), 50, 300));
|
|
616
|
+
// Half-compensate the peak gain reduction on the legacy-mislabelled
|
|
617
|
+
// MAKEUP key (`compressorRatio`, dB — see build.js). Without it the
|
|
618
|
+
// auto-enabled comp quietly lowers the cell and BAL, which ran on the
|
|
619
|
+
// pre-comp measurement, never sees the loss.
|
|
620
|
+
const peak = stemLufs + a.plr;
|
|
621
|
+
const makeup = round1(clamp((peak - threshold) * (1 - 1 / ratio) * 0.5, 0, 20));
|
|
622
|
+
const why = `cell ${id} spikes ${round1(a.plr)} dB over its loudness — soften the peaks`;
|
|
623
|
+
propose(id, `cell${id}_compressorThreshold`, threshold, why, COMP_EPSILON);
|
|
624
|
+
propose(id, `cell${id}_compressorRatioAmount`, ratio, why, COMP_EPSILON);
|
|
625
|
+
propose(id, `cell${id}_compressorAttack`, attack, why, COMP_EPSILON);
|
|
626
|
+
propose(id, `cell${id}_compressorRelease`, release, why, COMP_EPSILON);
|
|
627
|
+
propose(id, `cell${id}_compressorRatio`, makeup, why, COMP_EPSILON);
|
|
628
|
+
if (moves.some((mv) => mv.cellId === id && mv.key.startsWith(`cell${id}_compressor`))) {
|
|
629
|
+
flat[`cell${id}_compressorEnabled`] = 1;
|
|
630
|
+
guardsHit.push(`cell ${id} compressor enabled`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (moves.length === 0) {
|
|
635
|
+
return {
|
|
636
|
+
moves,
|
|
637
|
+
guardsHit,
|
|
638
|
+
flat: {},
|
|
639
|
+
summary: 'Mix reads balanced — nothing to move',
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
return { moves, guardsHit, flat };
|
|
643
|
+
}
|
|
644
|
+
function cellId(p) {
|
|
645
|
+
return p.cell.context.cellId;
|
|
646
|
+
}
|
|
647
|
+
//# sourceMappingURL=mixAssist.js.map
|