feat(matrix): per-voice additive modulation pairs

Introduce ~matVoiceMods (IdentityDictionary voice→binding list) and
~matVoicePoses. Rewrite ~matModPairs to read ~matVoiceMods[name]
instead of spec[\mod], summing contributions per target additively.
Disallowed targets (not in ~matModTargets[name]) are silently dropped.
Test: pan-cancel, rev-depth, cutoff-gate — RED→GREEN confirmed.
This commit is contained in:
L'électron rare
2026-06-29 14:21:02 +02:00
parent 6964831036
commit 0c8ea8b104
2 changed files with 74 additions and 25 deletions
+40 -18
View File
@@ -149,27 +149,47 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
})
};
// -- ~matModPairs : capture-effect keys from the color's own mod (spec[\mod]) --
// Returns [] when mod is nil (no effect key). Used by both pattern builders.
// -- ~matModPairs : per-voice additive binding list -> flat pair list for Pbind --
// Reads ~matVoiceMods[name] (array of (source:,target:,depth:) events).
// Groups by target, sums contributions, returns flat [tgt1, Pfunc1, tgt2, Pfunc2, ...].
// Targets not in ~matModTargets[name] are silently dropped.
~matModPairs = { |name, spec, volOf|
var mod = spec[\mod];
mod.isNil.if({ [] }, {
var src = mod[\source], tgt = mod[\target], d = mod[\depth];
var allowed = ~matModTargets[name] ? [];
var byTarget = IdentityDictionary.new;
var pairs = [];
((~matVoiceMods[name] ? []).select { |b|
b[\source].notNil and: { b[\target].notNil } and: { allowed.includes(b[\target]) }
}).do { |b| byTarget[b[\target]] = (byTarget[b[\target]] ? []) ++ [b] };
byTarget.keysValuesDo { |tgt, list|
(tgt == \cutoff).if({
var c0 = ~matModNeutralCut[name] ? 1000;
[\cutoff, Pfunc { var s = ~matModSourceVal.(src); c0 * (s.linexp(0,1,200,6000)/c0).pow(d) }]
pairs = pairs ++ [\cutoff, Pfunc {
var f = c0;
list.do { |b| var s = ~matModSourceVal.(b[\source]);
f = f * (s.linexp(0, 1, 200, 6000) / c0).pow(b[\depth]) };
f
}]
}, {
(tgt == \pan).if({
[\pan, Pfunc { (~matModSourceVal.(src) * 2 - 1) * d }]
}, {
(tgt == \rev).if({
[\rev, Pfunc { (~matModSourceVal.(src) * d).clip(0, 1) }]
}, {
[\amp, Pfunc { ((spec[\amp] ? 1.0) * volOf.value) * (1 + (d * (~matModSourceVal.(src) * 2 - 1))).max(0) }]
})
})
})
})
(tgt == \pan).if({
pairs = pairs ++ [\pan, Pfunc {
var a = 0; list.do { |b| a = a + ((~matModSourceVal.(b[\source]) * 2 - 1) * b[\depth]) };
a.clip(-1, 1)
}]
}, {
(tgt == \rev).if({
pairs = pairs ++ [\rev, Pfunc {
var a = 0; list.do { |b| a = a + (~matModSourceVal.(b[\source]) * b[\depth]) };
a.clip(0, 1)
}]
}, {
pairs = pairs ++ [\amp, Pfunc {
var base = (spec[\amp] ? 1.0) * volOf.value;
list.do { |b| base = base * (1 + (b[\depth] * (~matModSourceVal.(b[\source]) * 2 - 1))).max(0) };
base
}]
}) }) })
};
pairs
};
// -- ~matVariationOverlay : assemble the Pchain overlay given a resolved spec --
@@ -865,7 +885,9 @@ OSCdef(\mat_audition, { |msg, time, addr|
}, '/matrix/audition');
// -- Capture mod source cache (nil-guarded; idempotent reload) --
~matModCache = ~matModCache ? IdentityDictionary.new;
~matModCache = ~matModCache ? IdentityDictionary.new;
~matVoiceMods = ~matVoiceMods ? IdentityDictionary.new; // voice -> [ (source:,target:,depth:) ]
~matVoicePoses = ~matVoicePoses ? IdentityDictionary.new; // voice -> [ (poseId:,action:) ]
~matModSources = ~matModSources ?
[\lHandY, \rHandY, \lHandX, \rHandX, \lOpen, \rOpen, \handSpeed, \handDist,
\bodyX, \bodyY, \depth,
+34 -7
View File
@@ -447,23 +447,50 @@ pass = pass and: { true };
// Fix 1: fresh color pose arrays and distinct per color
pass = pass and: { (~matColorDefs[5][1][\pose] === ~matColorDefs[5][2][\pose]).not };
// --- Engine reads per-color mod + per-color pose fire (Task 2 color-refactor) ---
// --- Engine reads per-voice mod (Task 2 per-voice additive) ---
pass = pass and: { ~matModPairs.notNil };
// a color with a cutoff mod -> the produced event has a \cutoff key
// a voice with a cutoff mod -> the produced event has a \cutoff key
~matModCache = IdentityDictionary[ \rHandY -> 1.0 ];
~matColorDefs[5][2][\mod] = (source: \rHandY, target: \cutoff, depth: 1.0);
~matVoiceMods[\acid] = [ (source: \rHandY, target: \cutoff, depth: 1.0) ];
~ev = ~matVariation.(\acid, 2, 5).asStream.next(());
pass = pass and: { ~ev[\cutoff].notNil and: { ~ev[\cutoff] > 3000 } };
~matColorDefs[5][2][\mod] = nil;
// a color with a rev mod -> the produced event has a \rev key
~matVoiceMods[\acid] = [];
// a voice with a rev mod -> the produced event has a \rev key
~matModCache = IdentityDictionary[ \rHandY -> 1.0 ];
~matColorDefs[0][1][\mod] = (source: \rHandY, target: \rev, depth: 0.5);
~matVoiceMods[\kick] = [ (source: \rHandY, target: \rev, depth: 0.5) ];
~revEv = ~matVariation.(\kick, 1, 0).asStream.next(());
pass = pass and: { ~revEv[\rev].notNil };
~matColorDefs[0][1][\mod] = nil;
~matVoiceMods[\kick] = [];
// per-voice ~matMod removed
pass = pass and: { ~matMod.isNil };
pass = pass and: { ~matPoseBindings.isNil };
// --- Per-voice additive modulation model (Task 2) ---
// pan: two sources cancel additively (rHandX=1.0 -> +0.5, bodyX=0.0 -> -0.5 -> sum 0)
~matVoiceMods[\acid] = [ (source: \rHandX, target: \pan, depth: 0.5),
(source: \bodyX, target: \pan, depth: 0.5) ];
~matModCache[\rHandX] = 1.0; ~matModCache[\bodyX] = 0.0;
pass = pass and: {
var pairs, pf;
pairs = ~matModPairs.(\acid, (amp: 1.0), { 1.0 });
pf = pairs[pairs.indexOf(\pan) + 1];
pf.notNil and: { pf.asStream.next(()).abs < 0.001 }
};
~matVoiceMods[\acid] = [];
// rev: single source, cache 0.4, depth 1.0 -> value 0.4
~matVoiceMods[\acid] = [ (source: \handDist, target: \rev, depth: 1.0) ];
~matModCache[\handDist] = 0.4;
pass = pass and: {
var pairs, pf;
pairs = ~matModPairs.(\acid, (amp: 1.0), { 1.0 });
pf = pairs[pairs.indexOf(\rev) + 1];
pf.notNil and: { (pf.asStream.next(()) - 0.4).abs < 0.001 }
};
~matVoiceMods[\acid] = [];
// disallowed target: kick has no cutoff in ~matModTargets -> gated out
~matVoiceMods[\kick] = [ (source: \bodyY, target: \cutoff, depth: 1.0) ];
pass = pass and: { ~matModPairs.(\kick, (amp: 1.0), { 1.0 }).includes(\cutoff).not };
~matVoiceMods[\kick] = [];
// per-color pose fire: a binding under a color, firing re-sources (no raise)
~matColorDefs[5][3][\pose] = [ (poseId: \poseX, action: \trigger) ];
try { ~matPoseFire.(\poseX) } { |e| pass = false; ("EXC poseFire: " ++ e.class.name).postln };