feat(matrix): live variant generator

Add generate_live_variant_sc() to produce 4 deterministic live
variants per style (112 files). Each variant differs by:
  - pattern family (VARIANT_FAMILIES per style group)
  - era instrument kit overlay (house/M1, acid/hoover, virus/nord,
    raw 909/rave) merged from VARIANT_ERA_KITS
  - busy-block recolour config (VARIANT_BUSY, 4 distinct profiles)

Also: fix generator defaults to match live state:
  - globalActions: fill/doubleTime/washReverb/muteDrums/relanceEvolve/
    halfTime/next/muteMelo (was fill/drop/breakdown/evolve/...)
  - hand-mod depths 0.70 (was 0.40/0.50 for rHandX/rHandY/lHandX/lHandY)
  - INST_CHOICES synced with matrix.scd ~matInstChoices (10 new vintage
    synths: tr909_snare hoover m1_piano m1_organ dx7_epiano brass_stab
    nord_lead virus_saw rave_stab reese_juno)

Update test_global_actions.py to expect new 8-action mapping.
Add test_live_variants.py: 112 files, legal instruments, distinct
variants, determinism.
This commit is contained in:
L'électron rare
2026-07-02 20:27:36 +02:00
parent 876afc0cf9
commit c8318cecc2
3 changed files with 239 additions and 18 deletions
@@ -32,27 +32,29 @@ VI = {v: i for i, v in enumerate(VOICES)}
BARS = 64
# --- per-voice valid instrument choices (mirror of matrix.scd ~matInstChoices) ---
# Includes 10 vintage SynthDefs landed in commits a6556e8+876afc0:
# tr909_snare hoover m1_piano m1_organ dx7_epiano brass_stab nord_lead virus_saw rave_stab reese_juno
INST_CHOICES = {
"kick": ["lp_kick", "tr909_kick", "tr808_kick", "sp1200", "do_kick", "do_quake_sub"],
"hats": ["lp_hat", "tr909_hat", "tr808_hat", "sp1200", "do_hat"],
"clap": ["lp_clap", "tr909_clap", "tr808_clap", "sp1200", "lp_rim"],
"clap": ["lp_clap", "tr909_clap", "tr909_snare", "tr808_clap", "sp1200", "lp_rim"],
"perc": ["lp_perc", "tr808_cowbell", "sp1200", "akai_s", "do_strike"],
"sub": ["lp_sub", "sh101", "minimoog", "system100", "lp_reese"],
"sub": ["lp_sub", "sh101", "minimoog", "system100", "lp_reese", "m1_organ", "reese_juno"],
"acid": ["lp_acid", "tb303", "ms20", "system100", "lp_pluck"],
"arp": ["lp_pluck", "tb303", "supersaw", "juno", "do_strike"],
"lead": ["lp_pluck", "ms20", "supersaw", "do_plane", "sh101"],
"stab": ["lp_pluck", "ms20", "supersaw", "tb303", "lp_acid"],
"pad": ["lp_pad", "juno", "supersaw", "ms20", "lp_reese"],
"arp": ["lp_pluck", "tb303", "supersaw", "juno", "do_strike", "nord_lead", "virus_saw"],
"lead": ["lp_pluck", "ms20", "supersaw", "do_plane", "sh101", "hoover", "virus_saw"],
"stab": ["lp_pluck", "ms20", "supersaw", "tb303", "lp_acid", "brass_stab", "rave_stab"],
"pad": ["lp_pad", "juno", "supersaw", "ms20", "lp_reese", "reese_juno"],
"ride": ["lp_ride", "tr909_ride", "lp_hat", "do_hat"],
"rim": ["lp_rim", "tr808_cowbell", "tr909_clap", "sp1200"],
"tom": ["lp_tom", "sp1200", "do_strike", "do_quake_sub"],
"reese": ["lp_reese", "ms20", "minimoog", "system100", "supersaw"],
"bells": ["lp_bells", "dx7", "akai_s", "do_plane"],
"reese": ["lp_reese", "ms20", "minimoog", "system100", "supersaw", "hoover", "reese_juno"],
"bells": ["lp_bells", "dx7", "akai_s", "do_plane", "dx7_epiano"],
"sweep": ["lp_sweep", "akai_s", "lp_pad", "lp_reese"],
"melody": ["lp_pluck", "supersaw", "ms20", "sh101", "dx7"],
"chord": ["lp_pad", "juno", "supersaw", "ms20"],
"melody": ["lp_pluck", "supersaw", "ms20", "sh101", "dx7", "dx7_epiano", "nord_lead"],
"chord": ["lp_pad", "juno", "supersaw", "ms20", "m1_piano", "m1_organ", "dx7_epiano"],
"fx": ["lp_fx", "akai_s", "lp_sweep"],
"snare": ["lp_snare", "tr909_clap", "tr808_clap", "lp_clap"],
"snare": ["lp_snare", "tr909_snare", "tr909_clap", "tr808_clap", "lp_clap"],
"crash": ["lp_crash", "tr909_ride", "akai_s"],
"shaker": ["lp_shaker", "tr909_hat", "tr808_hat"],
}
@@ -579,9 +581,9 @@ for v in ("sub", "acid", "arp", "lead", "stab", "pad", "reese", "bells", "perc",
# Per-role default bindings (source, target, depth). Soloists -> hand sources
# (expressive, close-up); energy/structure -> body sources (robust at distance).
_ROLE_MODS = {
"lead": [("rHandX", "amp", 0.4), ("rHandY", "pitch", 0.4), ("lHandY", "cutoff", 0.5), ("lHandX", "res", 0.4)],
"bells": [("rHandX", "amp", 0.4), ("rHandY", "pitch", 0.4), ("lHandX", "res", 0.4)],
"melody": [("rHandX", "amp", 0.4), ("lHandY", "cutoff", 0.5), ("lHandX", "res", 0.4)],
"lead": [("rHandX", "amp", 0.70), ("rHandY", "pitch", 0.70), ("lHandY", "cutoff", 0.70), ("lHandX", "res", 0.70)],
"bells": [("rHandX", "amp", 0.70), ("rHandY", "pitch", 0.70), ("lHandX", "res", 0.70)],
"melody": [("rHandX", "amp", 0.70), ("lHandY", "cutoff", 0.70), ("lHandX", "res", 0.70)],
"reese": [("bodyVitesse", "cutoff", 0.5), ("bodyY", "pitch", 0.3), ("limbSpan", "res", 0.4), ("bodyX", "pan", 0.3)],
"sub": [("bodyVitesse", "res", 0.4), ("bodyY", "pitch", 0.3), ("bodyX", "pan", 0.3)],
"pad": [("bodyVitesse", "cutoff", 0.4), ("limbSpan", "rev", 0.4), ("bodyX", "pan", 0.3)],
@@ -1148,8 +1150,8 @@ def to_sc(name, arc_fn, instkit, melodies, extra, post=None):
"'colorDefs': [\n%s\n],\n"
"'voiceMods': [ %s ],\n"
"'voicePoses': [ %s ],\n"
"'globalActions': [ \\fill, \\drop, \\breakdown, \\evolve, "
"\\muteDrums, \\muteMelo, \\washReverb, \\next ],\n"
"'globalActions': [ \\fill, \\doubleTime, \\washReverb, "
"\\muteDrums, \\relanceEvolve, \\halfTime, \\next, \\muteMelo ],\n"
"'globalSettings': ( 'transientBars': 1, "
"'breakdownKeep': [ \\kick, \\sub ] ),\n"
"'pinchHoldMs': 300,\n"
@@ -1157,6 +1159,137 @@ def to_sc(name, arc_fn, instkit, melodies, extra, post=None):
% (rows, isyms, cds, vms, vps))
# ============================================================================
# Live variant generation (4 variants per style = 112 new files)
# ============================================================================
import patterns as _pats
# Era kit overlays per style group: 4 eras (house/M1, acid/hoover, virus/nord, rave/909).
# Each entry overrides selected voices in the base preset kit.
# All instruments validated against INST_CHOICES above (mirrors matrix.scd ~matInstChoices).
_VARIANT_ERA_KITS = {
"techno": [
{"chord": "m1_organ", "stab": "brass_stab", "bells": "dx7_epiano"}, # v1 house/M1
{"acid": "tb303", "lead": "hoover", "reese": "hoover"}, # v2 acid/hoover
{"lead": "virus_saw", "arp": "virus_saw", "stab": "rave_stab"}, # v3 virus/nord
{"kick": "tr909_kick", "snare": "tr909_snare", "stab": "brass_stab"}, # v4 909/rave
],
"psy": [
{"pad": "reese_juno", "chord": "m1_organ", "bells": "dx7_epiano"}, # v1 house/M1
{"acid": "tb303", "arp": "tb303", "sub": "system100"}, # v2 acid
{"lead": "virus_saw", "arp": "nord_lead", "reese": "reese_juno"}, # v3 virus/nord
{"kick": "tr909_kick", "snare": "tr909_snare"}, # v4 909/rave
],
"trance": [
{"chord": "m1_piano", "melody": "dx7_epiano", "bells": "dx7_epiano"}, # v1 house/M1
{"arp": "tb303", "sub": "system100", "lead": "ms20"}, # v2 acid/303
{"lead": "virus_saw", "arp": "nord_lead", "melody": "nord_lead"}, # v3 virus/nord
{"kick": "tr909_kick", "clap": "tr909_snare", "snare": "tr909_snare"}, # v4 909/rave
],
"breaks": [
{"pad": "juno", "chord": "m1_organ", "bells": "dx7_epiano"}, # v1 house/M1
{"reese": "hoover", "stab": "tb303", "sub": "system100"}, # v2 acid/hoover
{"reese": "reese_juno", "lead": "virus_saw", "pad": "reese_juno"}, # v3 virus/nord
{"kick": "sp1200", "snare": "tr909_snare", "stab": "rave_stab"}, # v4 909/rave
],
"electro": [
{"melody": "dx7_epiano", "chord": "m1_piano", "stab": "brass_stab"}, # v1 house/M1
{"acid": "tb303", "sub": "system100"}, # v2 acid/303
{"lead": "virus_saw", "stab": "nord_lead", "melody": "nord_lead"}, # v3 virus/nord
{"kick": "tr808_kick", "clap": "tr808_clap", "snare": "tr909_snare"}, # v4 808/rave
],
"industrial": [
{"stab": "brass_stab", "reese": "reese_juno"}, # v1 (no M1 piano)
{"acid": "tb303", "sub": "system100", "reese": "hoover"}, # v2 acid/hoover
{"stab": "rave_stab", "reese": "reese_juno", "lead": "virus_saw"}, # v3 virus/nord
{"kick": "tr909_kick", "clap": "tr909_snare", "snare": "tr909_snare"}, # v4 909/rave
],
"ambient": [
{"pad": "reese_juno", "bells": "dx7_epiano", "chord": "m1_piano"}, # v1 M1/DX7
{"pad": "juno", "sub": "system100", "melody": "ms20"}, # v2 analog
{"pad": "reese_juno", "melody": "nord_lead", "bells": "dx7_epiano"}, # v3 nord/reese
{"pad": "juno", "melody": "dx7_epiano", "chord": "m1_organ"}, # v4 M1 organ
],
}
# Pattern families per style group x variant (drum_family, mel_family).
# Pairs must be valid or will silently fall back to 'default' in patterns_for().
_VARIANT_FAMILIES = {
"techno": [("techno", "house"), ("techno", "psy"), ("electro", "default"), ("industrial", "default")],
"psy": [("techno", "psy"), ("techno", "dub"), ("breaks", "psy"), ("industrial", "psy")],
"trance": [("techno", "house"), ("techno", "psy"), ("electro", "house"), ("breaks", "house")],
"breaks": [("breaks", "default"), ("dnb", "default"), ("footwork", "default"), ("breaks", "psy")],
"electro": [("electro", "house"), ("ebm", "house"), ("electro", "default"), ("industrial", "default")],
"industrial": [("industrial", "default"), ("electro", "default"), ("breaks", "default"), ("ebm", "default")],
"ambient": [("ambient", "ambient"), ("techno", "ambient"), ("ambient", "dub"), ("ambient", "house")],
}
# Busy-block profiles for each variant (index 0-3 = v1-v4).
# busy_blocks: which 16-bar blocks get the recolour pass; busy_color: colour applied.
_VARIANT_BUSY = [
{"busy_blocks": [0, 2], "busy_color": 3}, # v1 octave-up on blocks 0 & 2
{"busy_blocks": [1, 3], "busy_color": 2}, # v2 double-time on blocks 1 & 3
{"busy_blocks": [0, 3], "busy_color": 3}, # v3 bookend blocks
{"busy_blocks": [2, 3], "busy_color": 6}, # v4 accent-roll second half
]
def _make_live_continuous_v(g, style, busy_cfg):
"""Like make_live_continuous but with a variant-specific busy configuration."""
ps, pn = SECTIONS["P"]
for row in g:
plateau = row[ps:ps + pn]
for b in range(len(row)):
row[b] = plateau[(b - ps) % pn]
prof = LIVE_VAR.get(style, LIVE_VAR["techno"])
for blk in busy_cfg["busy_blocks"]:
for v in prof["busy"]:
vi = VI[v]
for b in range(blk * 16, blk * 16 + 16):
if g[vi][b] > 0:
g[vi][b] = busy_cfg["busy_color"]
for blk in range(BARS // 16):
for v in prof["fill"]:
vi = VI[v]
for b in (blk * 16 + 14, blk * 16 + 15):
if g[vi][b] > 0:
g[vi][b] = 6
return g
def generate_live_variant_sc(name, variant_idx, m):
"""Build SC string for one live variant file.
variant_idx: 1-4. Deterministic: outcome depends only on name + variant_idx.
"""
style_group = LIVE_STYLE.get(name, "techno")
vi = variant_idx - 1 # 0-3
# Pattern family for this variant
fam = _VARIANT_FAMILIES.get(style_group, _VARIANT_FAMILIES["techno"])[vi]
# Build merged kit: base preset kit + era overlay
era_kit = _VARIANT_ERA_KITS.get(style_group, _VARIANT_ERA_KITS["techno"])[vi]
merged_kit = dict(m["instkit"])
for voice, inst in era_kit.items():
if voice in INST_CHOICES and inst in INST_CHOICES[voice]:
merged_kit[voice] = inst
# Busy config for the live variation pass
busy_cfg = _VARIANT_BUSY[vi]
# Register variant name in patterns.PRESET_FAMILY so emit_colordefs resolves correctly
vname = "%s_live-%d" % (name, variant_idx)
_pats.PRESET_FAMILY[vname] = fam
try:
post = (lambda s, bc: (lambda gg: _make_live_continuous_v(gg, s, bc)))(
style_group, busy_cfg)
sc = to_sc(vname, m["live"], merged_kit, m["melodies"], m["extra"], post)
finally:
del _pats.PRESET_FAMILY[vname]
return sc
def main():
import os
here = os.path.dirname(os.path.abspath(__file__))
@@ -1169,8 +1302,18 @@ def main():
with open(os.path.join(here, "%s_%s.matrix" % (name, sec)), "w") as f:
f.write(sc)
n += 1
# Generate the 4 live variants per style (112 new files)
nv = 0
for name, m in MORCEAUX.items():
for vi in range(1, 5):
sc = generate_live_variant_sc(name, vi, m)
path = os.path.join(here, "%s_live-%d.matrix" % (name, vi))
with open(path, "w") as f:
f.write(sc)
nv += 1
print("wrote %d presets (%d morceaux x intro/live/outro, 22 voices x %d bars)"
% (n, len(MORCEAUX), BARS))
print("wrote %d live-variant presets (%d morceaux x 4 variants)" % (nv, len(MORCEAUX)))
if __name__ == "__main__":
@@ -1,11 +1,11 @@
"""Every generated .matrix must carry the default globalActions mapping
(slots: LH open=fill, LH closed=drop, RH open=evolve, RH closed=breakdown).
(8 actions: fill doubleTime washReverb muteDrums relanceEvolve halfTime next muteMelo).
Run: cd .../matrix_presets && uv run python test_global_actions.py"""
import sys, os, glob
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import generate_presets as G
EXPECT = r"'globalActions': [ \fill, \drop, \breakdown, \evolve, \muteDrums, \muteMelo, \washReverb, \next ]"
EXPECT = r"'globalActions': [ \fill, \doubleTime, \washReverb, \muteDrums, \relanceEvolve, \halfTime, \next, \muteMelo ]"
ok = True
def check(c, m):
@@ -0,0 +1,78 @@
"""Validates the 112 live-variant files (*_live-[1-4].matrix).
Checks:
1. Exactly 112 files exist (28 styles × 4 variants)
2. All 28 styles have variant files 1-4
3. globalActions field matches the 8-action mapping
4. All instruments are legal for their voice
5. Variants differ from each other (grid or instruments)
6. Deterministic: regenerating produces identical bytes
Run: cd sound_algo/data_only/matrix_presets && uv run python test_live_variants.py
"""
import sys, os, glob, re
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import generate_presets as G
here = os.path.dirname(os.path.abspath(__file__))
EXPECTED_ACTIONS = r"'globalActions': [ \fill, \doubleTime, \washReverb, \muteDrums, \relanceEvolve, \halfTime, \next, \muteMelo ]"
EXPECTED_N_VARIANTS = 112
ok = True
def check(c, m):
global ok
if not c:
ok = False
print("FAIL:", m)
# 1. Exactly 112 files
variant_files = glob.glob(os.path.join(here, "*_live-[1-4].matrix"))
check(len(variant_files) == EXPECTED_N_VARIANTS,
"expected %d variant files, got %d" % (EXPECTED_N_VARIANTS, len(variant_files)))
# 2. Every style has all 4 variants
for name in G.PRESETS:
for v in range(1, 5):
fp = os.path.join(here, "%s_live-%d.matrix" % (name, v))
check(os.path.exists(fp), "missing %s_live-%d.matrix" % (name, v))
# 3. globalActions field
for fp in variant_files:
content = open(fp, encoding="utf-8").read()
check(EXPECTED_ACTIONS in content,
"wrong globalActions in %s" % os.path.basename(fp))
# 4. All instruments legal per voice
for fp in sorted(variant_files):
content = open(fp, encoding="utf-8").read()
m = re.search(r"'instruments':\s*\[\s*(.*?)\s*\]", content)
if m:
syms = re.findall(r"\\(\w+)", m.group(1))
for vi, sym in enumerate(syms):
voice = G.VOICES[vi]
check(sym in G.INST_CHOICES[voice],
"illegal instrument %r for voice %r in %s" % (sym, voice, os.path.basename(fp)))
# 5. Variants differ from each other
for name in G.PRESETS:
fps = [os.path.join(here, "%s_live-%d.matrix" % (name, v)) for v in range(1, 5)]
contents = [open(fp, encoding="utf-8").read() for fp in fps if os.path.exists(fp)]
check(len(set(contents)) > 1, "all variants identical for %s" % name)
# 6. Deterministic
for name in list(G.PRESETS.keys())[:3]:
m_data = G.MORCEAUX[name]
for vi in range(1, 5):
fp = os.path.join(here, "%s_live-%d.matrix" % (name, vi))
if os.path.exists(fp):
on_disk = open(fp, encoding="utf-8").read()
regenerated = G.generate_live_variant_sc(name, vi, m_data)
check(regenerated == on_disk,
"determinism fail for %s_live-%d" % (name, vi))
print("TEST PASS" if ok else "TEST FAIL")
sys.exit(0 if ok else 1)