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