livepilot 1.23.6 → 1.24.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 +37 -0
- package/README.md +59 -13
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +17 -3
- package/mcp_server/audit/__init__.py +6 -0
- package/mcp_server/audit/checks.py +618 -0
- package/mcp_server/audit/tools.py +232 -0
- package/mcp_server/composer/branch_producer.py +5 -2
- package/mcp_server/composer/develop/__init__.py +19 -0
- package/mcp_server/composer/develop/apply.py +217 -0
- package/mcp_server/composer/develop/brief_builder.py +269 -0
- package/mcp_server/composer/develop/seed_introspector.py +195 -0
- package/mcp_server/composer/engine.py +15 -521
- package/mcp_server/composer/fast/__init__.py +62 -0
- package/mcp_server/composer/fast/apply.py +533 -0
- package/mcp_server/composer/fast/brief_builder.py +1479 -0
- package/mcp_server/composer/fast/tier_classification.py +159 -0
- package/mcp_server/composer/framework/__init__.py +0 -0
- package/mcp_server/composer/framework/applier.py +179 -0
- package/mcp_server/composer/framework/artist_loader.py +63 -0
- package/mcp_server/composer/framework/brief.py +79 -0
- package/mcp_server/composer/framework/event_lexicon.py +71 -0
- package/mcp_server/composer/framework/genre_loader.py +77 -0
- package/mcp_server/composer/framework/intent_source.py +137 -0
- package/mcp_server/composer/framework/knowledge_pack.py +49 -0
- package/mcp_server/composer/framework/plan_compiler.py +10 -0
- package/mcp_server/composer/full/__init__.py +10 -0
- package/mcp_server/composer/full/apply.py +1139 -0
- package/mcp_server/composer/full/brief_builder.py +144 -0
- package/mcp_server/composer/full/engine.py +541 -0
- package/mcp_server/composer/full/layer_planner.py +491 -0
- package/mcp_server/composer/layer_planner.py +19 -465
- package/mcp_server/composer/sample_resolver.py +80 -7
- package/mcp_server/composer/tools.py +626 -28
- package/mcp_server/server.py +1 -0
- package/mcp_server/splice_client/client.py +7 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +162 -6
- package/mcp_server/tools/_planner_engine.py +25 -63
- package/mcp_server/tools/analyzer.py +10 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""Pure-computation §5 layer checks. No I/O — operates on fetched data.
|
|
2
|
+
|
|
3
|
+
Each check returns:
|
|
4
|
+
{
|
|
5
|
+
"severity": "pass" | "warn" | "fail" | "n/a",
|
|
6
|
+
"summary": str,
|
|
7
|
+
"issues": [{"code": str, "detail": str}, ...],
|
|
8
|
+
"evidence": {...},
|
|
9
|
+
}
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from statistics import mean, pstdev
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Role inference ───────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
_ROLE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
21
|
+
("kick", ("kick", "kik", "bd", "bass drum", "808 kick")),
|
|
22
|
+
("snare", ("snare", "snr", "sd", "clap", "rim")),
|
|
23
|
+
("hat", ("hihat", "hi-hat", "hi hat", "hat", "hh", "open hh", "closed hh")),
|
|
24
|
+
("perc", ("perc", "tom", "shaker", "ride", "crash", "cym", "cowbell", "tamb", "conga")),
|
|
25
|
+
("bass", ("bass", "sub", "808", "bs ")),
|
|
26
|
+
("vox", ("vox", "vocal", "voc", "lead vocal", "vocoder")),
|
|
27
|
+
("lead", ("lead", "melody", "main", "hook", "topline", "arp")),
|
|
28
|
+
("pad", ("pad", "chord", "keys", "piano", "wurli", "rhodes", "string")),
|
|
29
|
+
("atmos", ("atmos", "drone", "wash", "texture", "ambient", "field")),
|
|
30
|
+
("fx", ("fx", "riser", "hit", "impact", "swoosh", "downer")),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def infer_role(track_name: str, devices: list[dict]) -> str:
|
|
35
|
+
"""Best-effort role inference from track name + first instrument class."""
|
|
36
|
+
name = (track_name or "").lower()
|
|
37
|
+
for role, kws in _ROLE_KEYWORDS:
|
|
38
|
+
for kw in kws:
|
|
39
|
+
if kw in name:
|
|
40
|
+
return role
|
|
41
|
+
# Fallback: look at the first instrument-class device
|
|
42
|
+
for dev in devices or []:
|
|
43
|
+
cls = (dev.get("class_name") or "").lower()
|
|
44
|
+
if cls in ("drumgroup", "drumrack", "drum rack"):
|
|
45
|
+
return "perc"
|
|
46
|
+
if cls in ("simpler", "sampler"):
|
|
47
|
+
return "perc" # most common single-Simpler use
|
|
48
|
+
if cls in ("operator", "wavetable", "drift", "analog", "poli", "meld", "tension", "collision"):
|
|
49
|
+
return "lead"
|
|
50
|
+
return "unknown"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── §5.1 Timbre via spectrum ─────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
# Loose role → expected spectrum-band dominance.
|
|
56
|
+
# Uses 7-band convention: SUB_LOW, LOW, LOW_MID, MID, PRESENCE, HIGH, AIR.
|
|
57
|
+
_ROLE_BAND_EXPECTATIONS: dict[str, tuple[str, ...]] = {
|
|
58
|
+
"kick": ("SUB_LOW", "LOW", "MID"),
|
|
59
|
+
"snare": ("MID", "PRESENCE", "HIGH"),
|
|
60
|
+
"hat": ("PRESENCE", "HIGH", "AIR"),
|
|
61
|
+
"perc": ("MID", "PRESENCE", "HIGH"),
|
|
62
|
+
"bass": ("SUB_LOW", "LOW", "LOW_MID"),
|
|
63
|
+
"pad": ("LOW_MID", "MID", "PRESENCE"),
|
|
64
|
+
"lead": ("MID", "PRESENCE", "HIGH"),
|
|
65
|
+
"atmos": ("LOW_MID", "MID", "PRESENCE", "HIGH"),
|
|
66
|
+
"vox": ("MID", "PRESENCE", "HIGH"),
|
|
67
|
+
"fx": ("PRESENCE", "HIGH", "AIR"),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_timbre(role: str, fingerprint: dict | None) -> dict:
|
|
72
|
+
"""§5.1 — does the layer's spectral shape match its role?"""
|
|
73
|
+
if not fingerprint:
|
|
74
|
+
return {
|
|
75
|
+
"severity": "n/a",
|
|
76
|
+
"summary": "No timbre fingerprint available (M4L bridge not connected or solo not run)",
|
|
77
|
+
"issues": [],
|
|
78
|
+
"evidence": {"source": "unavailable"},
|
|
79
|
+
}
|
|
80
|
+
bands = fingerprint.get("bands") or fingerprint.get("band_energy") or {}
|
|
81
|
+
if not bands:
|
|
82
|
+
return {
|
|
83
|
+
"severity": "n/a",
|
|
84
|
+
"summary": "Timbre fingerprint had no band energy",
|
|
85
|
+
"issues": [],
|
|
86
|
+
"evidence": {"source": "empty"},
|
|
87
|
+
}
|
|
88
|
+
expected = _ROLE_BAND_EXPECTATIONS.get(role)
|
|
89
|
+
if not expected:
|
|
90
|
+
return {
|
|
91
|
+
"severity": "pass",
|
|
92
|
+
"summary": f"Role '{role}' has no fixed band expectation",
|
|
93
|
+
"issues": [],
|
|
94
|
+
"evidence": {"bands": bands},
|
|
95
|
+
}
|
|
96
|
+
# Find dominant band(s) — top1 is the discriminator
|
|
97
|
+
sorted_bands = sorted(bands.items(), key=lambda kv: kv[1], reverse=True)
|
|
98
|
+
top2 = [b for b, _ in sorted_bands[:2]]
|
|
99
|
+
expected_set = set(expected)
|
|
100
|
+
if top2[0] in expected_set:
|
|
101
|
+
return {
|
|
102
|
+
"severity": "pass",
|
|
103
|
+
"summary": f"{role} dominates {top2[0]} — within expected {expected}",
|
|
104
|
+
"issues": [],
|
|
105
|
+
"evidence": {"top2": top2, "expected": list(expected)},
|
|
106
|
+
}
|
|
107
|
+
if len(top2) > 1 and top2[1] in expected_set:
|
|
108
|
+
return {
|
|
109
|
+
"severity": "warn",
|
|
110
|
+
"summary": f"{role}: dominant band {top2[0]} is off-role; expected {expected[0]} prominent",
|
|
111
|
+
"issues": [{
|
|
112
|
+
"code": "off_band_dominance",
|
|
113
|
+
"detail": f"Top band is {top2[0]}; expected dominance in {expected}. Secondary band {top2[1]} is in range.",
|
|
114
|
+
}],
|
|
115
|
+
"evidence": {"top2": top2, "expected": list(expected)},
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
"severity": "fail",
|
|
119
|
+
"summary": f"{role} should dominate {expected}; actually dominates {top2}",
|
|
120
|
+
"issues": [{
|
|
121
|
+
"code": "wrong_band_dominance",
|
|
122
|
+
"detail": f"Sample/patch reads as {top2[0]}-dominant — wrong sample for {role}",
|
|
123
|
+
}],
|
|
124
|
+
"evidence": {"top2": top2, "expected": list(expected)},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── §5.2 Sequence critique ───────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _collect_clip_notes(track_info: dict) -> list[dict]:
|
|
132
|
+
"""Pull notes from track_info; if not embedded, caller must fetch separately.
|
|
133
|
+
|
|
134
|
+
Note: get_track_info does NOT include per-clip notes (only clip metadata).
|
|
135
|
+
The audit_layer orchestrator fetches them via get_notes per clip.
|
|
136
|
+
"""
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def check_sequence(role: str, notes_per_clip: list[list[dict]]) -> dict:
|
|
141
|
+
"""§5.2 — humanization, ghost notes, swing, variation."""
|
|
142
|
+
if not notes_per_clip or all(not n for n in notes_per_clip):
|
|
143
|
+
return {
|
|
144
|
+
"severity": "n/a",
|
|
145
|
+
"summary": "No notes on this track (no MIDI clips, or audio track)",
|
|
146
|
+
"issues": [],
|
|
147
|
+
"evidence": {"clip_count": len(notes_per_clip)},
|
|
148
|
+
}
|
|
149
|
+
issues: list[dict[str, str]] = []
|
|
150
|
+
all_velocities: list[int] = []
|
|
151
|
+
ghost_count = 0
|
|
152
|
+
duration_set: set[float] = set()
|
|
153
|
+
pitch_set: set[int] = set()
|
|
154
|
+
total_notes = 0
|
|
155
|
+
for notes in notes_per_clip:
|
|
156
|
+
for n in notes:
|
|
157
|
+
v = int(n.get("velocity", 100))
|
|
158
|
+
all_velocities.append(v)
|
|
159
|
+
if 25 <= v <= 45:
|
|
160
|
+
ghost_count += 1
|
|
161
|
+
duration_set.add(round(float(n.get("duration", 0)), 3))
|
|
162
|
+
pitch_set.add(int(n.get("pitch", 60)))
|
|
163
|
+
total_notes += 1
|
|
164
|
+
|
|
165
|
+
if total_notes == 0:
|
|
166
|
+
return {
|
|
167
|
+
"severity": "n/a",
|
|
168
|
+
"summary": "Clips exist but contain no notes",
|
|
169
|
+
"issues": [],
|
|
170
|
+
"evidence": {},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
vel_stddev = pstdev(all_velocities) if len(all_velocities) > 1 else 0.0
|
|
174
|
+
vel_mean = mean(all_velocities) if all_velocities else 0.0
|
|
175
|
+
|
|
176
|
+
if vel_stddev < 3.0 and total_notes >= 4:
|
|
177
|
+
issues.append({
|
|
178
|
+
"code": "no_humanization",
|
|
179
|
+
"detail": f"Velocities have stddev={vel_stddev:.1f} — robotic. Spread ±5-10 for organic feel.",
|
|
180
|
+
})
|
|
181
|
+
if role in ("snare", "hat", "perc") and ghost_count == 0 and total_notes >= 8:
|
|
182
|
+
issues.append({
|
|
183
|
+
"code": "no_ghost_notes",
|
|
184
|
+
"detail": f"{role} has no ghost notes (vel 25-45). Add 16th-note ghosts at vel ~35 for groove.",
|
|
185
|
+
})
|
|
186
|
+
if len(duration_set) <= 1 and total_notes >= 4 and role in ("pad", "lead", "bass", "vox"):
|
|
187
|
+
issues.append({
|
|
188
|
+
"code": "uniform_durations",
|
|
189
|
+
"detail": "All notes have identical duration. Vary durations for phrasing.",
|
|
190
|
+
})
|
|
191
|
+
if role in ("pad", "lead") and len(pitch_set) <= 2 and total_notes >= 4:
|
|
192
|
+
issues.append({
|
|
193
|
+
"code": "low_pitch_variety",
|
|
194
|
+
"detail": f"{role} uses only {len(pitch_set)} pitch(es). Add melodic motion or chord extensions.",
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
severity = "pass" if not issues else ("warn" if len(issues) <= 1 else "fail")
|
|
198
|
+
return {
|
|
199
|
+
"severity": severity,
|
|
200
|
+
"summary": (
|
|
201
|
+
f"{total_notes} notes, vel µ={vel_mean:.0f}±{vel_stddev:.1f}, "
|
|
202
|
+
f"ghosts={ghost_count}, durations={len(duration_set)}, pitches={len(pitch_set)}"
|
|
203
|
+
),
|
|
204
|
+
"issues": issues,
|
|
205
|
+
"evidence": {
|
|
206
|
+
"total_notes": total_notes,
|
|
207
|
+
"velocity_stddev": round(vel_stddev, 2),
|
|
208
|
+
"velocity_mean": round(vel_mean, 1),
|
|
209
|
+
"ghost_count": ghost_count,
|
|
210
|
+
"duration_variants": len(duration_set),
|
|
211
|
+
"pitch_variants": len(pitch_set),
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ── §5.3 Stereo image ────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def check_stereo(role: str, track_info: dict) -> dict:
|
|
220
|
+
"""§5.3 — pan + bass-mono + width."""
|
|
221
|
+
mixer = track_info.get("mixer", {})
|
|
222
|
+
pan = float(mixer.get("panning", 0.0))
|
|
223
|
+
issues: list[dict[str, str]] = []
|
|
224
|
+
if role == "bass" and abs(pan) > 0.05:
|
|
225
|
+
issues.append({
|
|
226
|
+
"code": "panned_bass",
|
|
227
|
+
"detail": f"Bass is panned {pan:+.2f}. Sub-bass should be center for translation.",
|
|
228
|
+
})
|
|
229
|
+
if role in ("kick", "snare") and abs(pan) > 0.15:
|
|
230
|
+
issues.append({
|
|
231
|
+
"code": "panned_drum_anchor",
|
|
232
|
+
"detail": f"{role} panned {pan:+.2f} — drum anchors usually center.",
|
|
233
|
+
})
|
|
234
|
+
severity = "warn" if issues else "pass"
|
|
235
|
+
return {
|
|
236
|
+
"severity": severity,
|
|
237
|
+
"summary": f"pan={pan:+.2f}",
|
|
238
|
+
"issues": issues,
|
|
239
|
+
"evidence": {"pan": pan},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ── §5.4 Masking (cross-track frequency collision) ──────────────────
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def check_masking(track_index: int, masking_report: dict | None) -> dict:
|
|
247
|
+
"""§5.4 — pull this track's collisions out of the global masking report."""
|
|
248
|
+
if not masking_report:
|
|
249
|
+
return {
|
|
250
|
+
"severity": "n/a",
|
|
251
|
+
"summary": "No masking report available",
|
|
252
|
+
"issues": [],
|
|
253
|
+
"evidence": {},
|
|
254
|
+
}
|
|
255
|
+
entries = masking_report.get("masking", {}).get("entries", []) or []
|
|
256
|
+
my_collisions = [
|
|
257
|
+
e for e in entries
|
|
258
|
+
if e.get("track_a") == track_index or e.get("track_b") == track_index
|
|
259
|
+
]
|
|
260
|
+
if not my_collisions:
|
|
261
|
+
return {
|
|
262
|
+
"severity": "pass",
|
|
263
|
+
"summary": "No detected masking collisions",
|
|
264
|
+
"issues": [],
|
|
265
|
+
"evidence": {"collision_count": 0},
|
|
266
|
+
}
|
|
267
|
+
issues = []
|
|
268
|
+
for c in my_collisions:
|
|
269
|
+
sev = c.get("severity", "warn")
|
|
270
|
+
other = c.get("track_b") if c.get("track_a") == track_index else c.get("track_a")
|
|
271
|
+
band = c.get("band") or c.get("frequency_band") or "?"
|
|
272
|
+
issues.append({
|
|
273
|
+
"code": "masking_collision",
|
|
274
|
+
"detail": f"Frequency clash with track {other} in band {band} (severity={sev})",
|
|
275
|
+
})
|
|
276
|
+
severity = "fail" if any(c.get("severity") == "high" for c in my_collisions) else "warn"
|
|
277
|
+
return {
|
|
278
|
+
"severity": severity,
|
|
279
|
+
"summary": f"{len(my_collisions)} masking collision(s) involving this track",
|
|
280
|
+
"issues": issues,
|
|
281
|
+
"evidence": {"collisions": my_collisions[:5]}, # cap response size
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ── §5.5 Modulation/automation (mandatory by §4) ────────────────────
|
|
286
|
+
|
|
287
|
+
_INSTRUMENT_CLASSES = frozenset({
|
|
288
|
+
"Drift", "Wavetable", "Operator", "Analog", "Poli", "Meld",
|
|
289
|
+
"Tension", "Collision", "Electric",
|
|
290
|
+
# Sampler family — Live exposes multiple class names depending on
|
|
291
|
+
# device generation. OriginalSimpler is the legacy Simpler core,
|
|
292
|
+
# MultiSampler is the Sampler core. Verified against live sessions
|
|
293
|
+
# 2026-05-01: Phantasm Pad → MultiSampler, Hihat 808 Close → OriginalSimpler.
|
|
294
|
+
"Simpler", "OriginalSimpler", "Sampler", "MultiSampler",
|
|
295
|
+
"DrumGroup", "DrumRack", "DrumGroupDevice",
|
|
296
|
+
"InstrumentVector", "InstrumentRack",
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def check_modulation(
|
|
301
|
+
role: str,
|
|
302
|
+
devices: list[dict],
|
|
303
|
+
clip_automation_present: bool,
|
|
304
|
+
wavetable_mod_routings: int,
|
|
305
|
+
) -> dict:
|
|
306
|
+
"""§5.5 + §4 — at least one modulation routing per layer.
|
|
307
|
+
|
|
308
|
+
Counts active routings across the native '<dest> < <source>' parameter
|
|
309
|
+
naming convention (Live's standard for mod routings on Simpler/Sampler/
|
|
310
|
+
Drift/etc.) plus generic mod/lfo/env-amount params. Validated against
|
|
311
|
+
Phantasm Pad (MultiSampler, Filt < Vel: 0.59, Filt < Key: 1.0) on a
|
|
312
|
+
live session 2026-05-01.
|
|
313
|
+
"""
|
|
314
|
+
routings = 0
|
|
315
|
+
for dev in devices or []:
|
|
316
|
+
cls = dev.get("class_name", "")
|
|
317
|
+
if cls not in _INSTRUMENT_CLASSES:
|
|
318
|
+
continue
|
|
319
|
+
params = dev.get("parameters", []) or []
|
|
320
|
+
# Build a lookup so we can gate "<dest> < <source>" on its
|
|
321
|
+
# corresponding "On" toggle (e.g. Fe < Env only counts if Fe On=1).
|
|
322
|
+
by_name: dict[str, float] = {}
|
|
323
|
+
for p in params:
|
|
324
|
+
by_name[(p.get("name") or "").lower()] = float(p.get("value", 0.0))
|
|
325
|
+
|
|
326
|
+
for name, value in by_name.items():
|
|
327
|
+
# Generic mod-amount conventions
|
|
328
|
+
if any(tok in name for tok in (
|
|
329
|
+
"lfo amount", "env amount", "mod amount", "fm amount",
|
|
330
|
+
"osc mod", "ring mod",
|
|
331
|
+
)):
|
|
332
|
+
if abs(value) > 0.01:
|
|
333
|
+
routings += 1
|
|
334
|
+
continue
|
|
335
|
+
# Live's "<dest> < <source>" routing convention.
|
|
336
|
+
# Examples: "Filt < Env", "Filt < Vel", "Filt < Key", "Filt < LFO",
|
|
337
|
+
# "Vol < Vel", "Vol < LFO", "Pan < Rnd", "Pan < LFO",
|
|
338
|
+
# "Pitch < LFO", "Pe < Env", "Fe < Env", "Time < Key".
|
|
339
|
+
if " < " in name and abs(value) > 0.01:
|
|
340
|
+
# Gate filter-envelope amount on Fe On
|
|
341
|
+
if name in ("fe < env", "fil < env", "filt < env"):
|
|
342
|
+
if by_name.get("fe on", 1.0) < 0.5:
|
|
343
|
+
continue
|
|
344
|
+
# Gate pitch-envelope amount on Pe On
|
|
345
|
+
if name == "pe < env":
|
|
346
|
+
if by_name.get("pe on", 1.0) < 0.5:
|
|
347
|
+
continue
|
|
348
|
+
# Gate LFO routings on L On (legacy Simpler) or L 1/2/3 On
|
|
349
|
+
if "< lfo" in name:
|
|
350
|
+
lfo_on = (
|
|
351
|
+
by_name.get("l on", 0.0) > 0.5
|
|
352
|
+
or by_name.get("l 1 on", 0.0) > 0.5
|
|
353
|
+
or by_name.get("l 2 on", 0.0) > 0.5
|
|
354
|
+
or by_name.get("l 3 on", 0.0) > 0.5
|
|
355
|
+
)
|
|
356
|
+
if not lfo_on:
|
|
357
|
+
continue
|
|
358
|
+
routings += 1
|
|
359
|
+
|
|
360
|
+
routings += max(0, int(wavetable_mod_routings))
|
|
361
|
+
automation = bool(clip_automation_present)
|
|
362
|
+
|
|
363
|
+
issues: list[dict[str, str]] = []
|
|
364
|
+
if role in ("pad", "lead", "bass", "atmos") and routings == 0 and not automation:
|
|
365
|
+
issues.append({
|
|
366
|
+
"code": "static_layer",
|
|
367
|
+
"detail": f"§4 violation: {role} has 0 modulation routings AND no automation. Add LFO→filter, env→pitch, or clip automation.",
|
|
368
|
+
})
|
|
369
|
+
severity = "fail"
|
|
370
|
+
elif routings == 0 and not automation:
|
|
371
|
+
issues.append({
|
|
372
|
+
"code": "no_movement",
|
|
373
|
+
"detail": "Layer has no modulation or automation. Adds life via LFO/envelope or per-clip automation.",
|
|
374
|
+
})
|
|
375
|
+
severity = "warn"
|
|
376
|
+
else:
|
|
377
|
+
severity = "pass"
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
"severity": severity,
|
|
381
|
+
"summary": f"routings={routings}, automation={automation}",
|
|
382
|
+
"issues": issues,
|
|
383
|
+
"evidence": {"routings_count": routings, "automation_present": automation},
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ── §5.6 Synth params (default-detection) ───────────────────────────
|
|
388
|
+
|
|
389
|
+
# Param name fragments that, if at exactly 0 or factory-default, indicate
|
|
390
|
+
# the user didn't program the source (§2 violation).
|
|
391
|
+
_SUSPICIOUS_AT_ZERO: tuple[str, ...] = (
|
|
392
|
+
"fe < env", # filter envelope amount
|
|
393
|
+
"filt < env",
|
|
394
|
+
"pe < env", # pitch envelope amount
|
|
395
|
+
"spread",
|
|
396
|
+
"detune",
|
|
397
|
+
"unison",
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def check_params(role: str, devices: list[dict]) -> dict:
|
|
402
|
+
"""§5.6 + §2 — instrument programming, not just defaults."""
|
|
403
|
+
if not devices:
|
|
404
|
+
return {
|
|
405
|
+
"severity": "n/a",
|
|
406
|
+
"summary": "No devices on track",
|
|
407
|
+
"issues": [],
|
|
408
|
+
"evidence": {},
|
|
409
|
+
}
|
|
410
|
+
instrument = next(
|
|
411
|
+
(d for d in devices if d.get("class_name") in _INSTRUMENT_CLASSES),
|
|
412
|
+
None,
|
|
413
|
+
)
|
|
414
|
+
if not instrument:
|
|
415
|
+
return {
|
|
416
|
+
"severity": "n/a",
|
|
417
|
+
"summary": "No native instrument on track (audio track or 3rd-party VST)",
|
|
418
|
+
"issues": [],
|
|
419
|
+
"evidence": {"first_device_class": devices[0].get("class_name")},
|
|
420
|
+
}
|
|
421
|
+
cls = instrument.get("class_name", "")
|
|
422
|
+
params = instrument.get("parameters", []) or []
|
|
423
|
+
by_name = {(p.get("name") or "").lower(): float(p.get("value", 0.0)) for p in params}
|
|
424
|
+
untouched: list[str] = []
|
|
425
|
+
for p in params:
|
|
426
|
+
name_l = (p.get("name") or "").lower()
|
|
427
|
+
value = float(p.get("value", 0.0))
|
|
428
|
+
for tok in _SUSPICIOUS_AT_ZERO:
|
|
429
|
+
if tok in name_l and abs(value) < 0.001:
|
|
430
|
+
# Gate envelope-amount params on their On toggle —
|
|
431
|
+
# Fe < Env: 0 with Fe On: 0 is a deliberate choice, not laziness.
|
|
432
|
+
if tok in ("fe < env", "filt < env"):
|
|
433
|
+
if by_name.get("fe on", 1.0) < 0.5:
|
|
434
|
+
break
|
|
435
|
+
if tok == "pe < env":
|
|
436
|
+
if by_name.get("pe on", 1.0) < 0.5:
|
|
437
|
+
break
|
|
438
|
+
untouched.append(p.get("name", "?"))
|
|
439
|
+
break
|
|
440
|
+
issues: list[dict[str, str]] = []
|
|
441
|
+
# BUG-E (caught 2026-05-01 live test): kick/snare/hat/perc are simple
|
|
442
|
+
# by design — single sample + minimal shaping is the correct creative
|
|
443
|
+
# choice. Mirror the suppression that sound_design/tools.py applies
|
|
444
|
+
# via _is_simple_role_track. Without this, every drum track flags
|
|
445
|
+
# 4 default-shaping params (Spread/Detune/Pe<Env/Fe<Env) which is
|
|
446
|
+
# noise the LLM has to re-evaluate.
|
|
447
|
+
_SIMPLE_ROLES = ("kick", "snare", "hat", "perc")
|
|
448
|
+
if role in _SIMPLE_ROLES:
|
|
449
|
+
return {
|
|
450
|
+
"severity": "pass",
|
|
451
|
+
"summary": f"{cls}: simple-role ({role}) — default shaping params expected",
|
|
452
|
+
"issues": [],
|
|
453
|
+
"evidence": {
|
|
454
|
+
"instrument_class": cls,
|
|
455
|
+
"untouched_params": untouched[:8],
|
|
456
|
+
"suppressed_for_role": role,
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
if role in ("pad", "lead", "bass") and len(untouched) >= 3:
|
|
460
|
+
issues.append({
|
|
461
|
+
"code": "unprogrammed_instrument",
|
|
462
|
+
"detail": (
|
|
463
|
+
f"§2 violation: {cls} has {len(untouched)} key shaping params at 0/default "
|
|
464
|
+
f"({', '.join(untouched[:5])}). Open the instrument and program envelopes/spread/detune."
|
|
465
|
+
),
|
|
466
|
+
})
|
|
467
|
+
severity = "fail"
|
|
468
|
+
elif len(untouched) >= 4:
|
|
469
|
+
issues.append({
|
|
470
|
+
"code": "many_default_params",
|
|
471
|
+
"detail": f"{cls}: {len(untouched)} shaping params at default. Likely not programmed.",
|
|
472
|
+
})
|
|
473
|
+
severity = "warn"
|
|
474
|
+
else:
|
|
475
|
+
severity = "pass"
|
|
476
|
+
return {
|
|
477
|
+
"severity": severity,
|
|
478
|
+
"summary": f"{cls}: {len(params)} params, {len(untouched)} at suspect-default",
|
|
479
|
+
"issues": issues,
|
|
480
|
+
"evidence": {"instrument_class": cls, "untouched_params": untouched[:8]},
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ── §5.7 Sample audition (Simpler/Sampler only) ─────────────────────
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
_SAMPLER_CLASSES = frozenset({"Simpler", "OriginalSimpler", "Sampler", "MultiSampler"})
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def check_samples(role: str, devices: list[dict], slice_classifications: list[dict] | None) -> dict:
|
|
491
|
+
"""§5.7 — Simpler/Sampler sample-fit signals."""
|
|
492
|
+
simpler = next(
|
|
493
|
+
(d for d in devices if d.get("class_name") in _SAMPLER_CLASSES),
|
|
494
|
+
None,
|
|
495
|
+
)
|
|
496
|
+
if not simpler:
|
|
497
|
+
return {
|
|
498
|
+
"severity": "n/a",
|
|
499
|
+
"summary": "No Simpler/Sampler on track",
|
|
500
|
+
"issues": [],
|
|
501
|
+
"evidence": {},
|
|
502
|
+
}
|
|
503
|
+
issues: list[dict[str, str]] = []
|
|
504
|
+
# Heuristic: Simpler default volume is -12 dB (memory: feedback_simpler_default_volume).
|
|
505
|
+
# Read Volume param if present.
|
|
506
|
+
params = simpler.get("parameters", []) or []
|
|
507
|
+
vol = next((p for p in params if (p.get("name") or "").lower() == "volume"), None)
|
|
508
|
+
if vol and float(vol.get("value", 0.0)) < -10.0 and role in ("pad", "lead", "bass", "vox"):
|
|
509
|
+
issues.append({
|
|
510
|
+
"code": "simpler_default_volume",
|
|
511
|
+
"detail": f"Simpler Volume at {vol.get('value'):.1f} dB (default -12). Set to 0 for sustained roles.",
|
|
512
|
+
})
|
|
513
|
+
if slice_classifications:
|
|
514
|
+
unclassified = sum(1 for s in slice_classifications if s.get("classification") in (None, "unknown"))
|
|
515
|
+
if unclassified > 0:
|
|
516
|
+
issues.append({
|
|
517
|
+
"code": "unclassified_slices",
|
|
518
|
+
"detail": f"{unclassified} slice(s) unclassified — programming drums by index without spectral check.",
|
|
519
|
+
})
|
|
520
|
+
severity = "warn" if issues else "pass"
|
|
521
|
+
return {
|
|
522
|
+
"severity": severity,
|
|
523
|
+
"summary": f"Simpler/Sampler present; {len(issues)} issue(s)",
|
|
524
|
+
"issues": issues,
|
|
525
|
+
"evidence": {
|
|
526
|
+
"has_volume_default": bool(vol and float(vol.get("value", 0.0)) < -10.0),
|
|
527
|
+
"slice_count": len(slice_classifications) if slice_classifications else 0,
|
|
528
|
+
},
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ── §5.8 Effects chain coverage ─────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
_EFFECT_CATEGORIES: dict[str, tuple[str, ...]] = {
|
|
535
|
+
"eq": ("EQ Eight", "EQ Three", "Channel EQ"),
|
|
536
|
+
"compressor": ("Compressor", "Glue Compressor", "Compressor 2", "Multiband Dynamics"),
|
|
537
|
+
"saturation": ("Saturator", "Overdrive", "Pedal", "Drive", "Roar"),
|
|
538
|
+
"spatial": ("Reverb", "Hybrid Reverb", "Echo", "Delay", "Chorus-Ensemble", "Chorus"),
|
|
539
|
+
"filter": ("Auto Filter",),
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def check_effects(role: str, devices: list[dict]) -> dict:
|
|
544
|
+
"""§5.8 — every track should have shaping FX, not just a bare instrument."""
|
|
545
|
+
if not devices:
|
|
546
|
+
return {"severity": "n/a", "summary": "No devices", "issues": [], "evidence": {}}
|
|
547
|
+
classes = [d.get("class_name", "") for d in devices]
|
|
548
|
+
coverage: dict[str, bool] = {}
|
|
549
|
+
for cat, names in _EFFECT_CATEGORIES.items():
|
|
550
|
+
coverage[cat] = any(c in names for c in classes)
|
|
551
|
+
|
|
552
|
+
issues: list[dict[str, str]] = []
|
|
553
|
+
# Roles where EQ + Comp are pretty much mandatory
|
|
554
|
+
if role in ("kick", "snare", "bass", "vox", "lead") and not coverage["eq"]:
|
|
555
|
+
issues.append({"code": "no_eq", "detail": f"{role} has no EQ. Carve frequencies for translation."})
|
|
556
|
+
if role in ("bass", "vox", "lead") and not coverage["compressor"]:
|
|
557
|
+
issues.append({"code": "no_compressor", "detail": f"{role} has no compressor. Glue dynamics."})
|
|
558
|
+
if role in ("pad", "atmos", "vox", "lead") and not coverage["spatial"]:
|
|
559
|
+
issues.append({"code": "no_space", "detail": f"{role} has no reverb/delay. Add depth."})
|
|
560
|
+
severity = "warn" if issues else "pass"
|
|
561
|
+
return {
|
|
562
|
+
"severity": severity,
|
|
563
|
+
"summary": f"effects: {sum(coverage.values())}/{len(coverage)} categories covered",
|
|
564
|
+
"issues": issues,
|
|
565
|
+
"evidence": {"coverage": coverage, "device_classes": classes},
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ── Severity rollup + fix ranking ───────────────────────────────────
|
|
570
|
+
|
|
571
|
+
_SEVERITY_RANK = {"n/a": 0, "pass": 1, "warn": 2, "fail": 3}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def rollup_severity(checks: dict[str, dict]) -> str:
|
|
575
|
+
"""Highest severity across all checks."""
|
|
576
|
+
worst = max((_SEVERITY_RANK.get(c.get("severity", "pass"), 1) for c in checks.values()), default=1)
|
|
577
|
+
for k, v in _SEVERITY_RANK.items():
|
|
578
|
+
if v == worst:
|
|
579
|
+
return k
|
|
580
|
+
return "pass"
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
_FIX_PRIORITY: dict[str, str] = {
|
|
584
|
+
"wrong_band_dominance": "high",
|
|
585
|
+
"static_layer": "high",
|
|
586
|
+
"unprogrammed_instrument": "high",
|
|
587
|
+
"panned_bass": "high",
|
|
588
|
+
"masking_collision": "high",
|
|
589
|
+
"no_eq": "medium",
|
|
590
|
+
"no_compressor": "medium",
|
|
591
|
+
"no_space": "medium",
|
|
592
|
+
"no_humanization": "medium",
|
|
593
|
+
"no_ghost_notes": "medium",
|
|
594
|
+
"uniform_durations": "low",
|
|
595
|
+
"low_pitch_variety": "medium",
|
|
596
|
+
"no_movement": "medium",
|
|
597
|
+
"many_default_params": "low",
|
|
598
|
+
"simpler_default_volume": "high",
|
|
599
|
+
"unclassified_slices": "medium",
|
|
600
|
+
"panned_drum_anchor": "low",
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def rank_fixes(checks: dict[str, dict]) -> list[dict[str, Any]]:
|
|
605
|
+
"""Flatten all issues into a ranked fix list."""
|
|
606
|
+
fixes: list[dict[str, Any]] = []
|
|
607
|
+
for check_name, result in checks.items():
|
|
608
|
+
for issue in result.get("issues", []) or []:
|
|
609
|
+
code = issue.get("code", "")
|
|
610
|
+
fixes.append({
|
|
611
|
+
"priority": _FIX_PRIORITY.get(code, "low"),
|
|
612
|
+
"check": check_name,
|
|
613
|
+
"code": code,
|
|
614
|
+
"fix": issue.get("detail", ""),
|
|
615
|
+
})
|
|
616
|
+
rank_order = {"high": 0, "medium": 1, "low": 2}
|
|
617
|
+
fixes.sort(key=lambda f: rank_order.get(f["priority"], 99))
|
|
618
|
+
return fixes
|