livepilot 1.10.6 → 1.10.7
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/.claude-plugin/marketplace.json +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +42 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +214 -2
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/creative_constraints/tools.py +206 -33
- package/mcp_server/experiment/engine.py +7 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +60 -9
- package/mcp_server/m4l_bridge.py +21 -6
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/performance_engine/tools.py +112 -29
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +22 -6
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +55 -5
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +47 -6
- package/mcp_server/runtime/execution_router.py +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +48 -14
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +68 -2
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tracker.py +14 -1
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +77 -13
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +41 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
- package/mcp_server/tools/_composition_engine/__init__.py +2 -2
- package/mcp_server/tools/_composition_engine/harmony.py +90 -0
- package/mcp_server/tools/_composition_engine/sections.py +47 -4
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +20 -3
- package/mcp_server/tools/analyzer.py +98 -0
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +66 -23
- package/mcp_server/tools/devices.py +22 -1
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +13 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +49 -3
- package/mcp_server/tools/research.py +24 -0
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
|
@@ -115,26 +115,70 @@ class AtlasManager:
|
|
|
115
115
|
query_words = query_lower.split()
|
|
116
116
|
results: List[Dict[str, Any]] = []
|
|
117
117
|
|
|
118
|
+
# BUG-B39: the real atlas scanner emits "instruments" /
|
|
119
|
+
# "audio_effects" but older callers and test fixtures sometimes
|
|
120
|
+
# pass the singular "instrument" / "effect". Build a tolerant
|
|
121
|
+
# category alias set so both forms work.
|
|
122
|
+
_CAT_ALIASES = {
|
|
123
|
+
"instrument": {"instrument", "instruments"},
|
|
124
|
+
"instruments": {"instrument", "instruments"},
|
|
125
|
+
"effect": {"effect", "effects", "audio_effects"},
|
|
126
|
+
"effects": {"effect", "effects", "audio_effects"},
|
|
127
|
+
"audio_effect": {"effect", "effects", "audio_effects",
|
|
128
|
+
"audio_effect"},
|
|
129
|
+
"audio_effects": {"effect", "effects", "audio_effects",
|
|
130
|
+
"audio_effect"},
|
|
131
|
+
}
|
|
132
|
+
allowed_cats = (
|
|
133
|
+
_CAT_ALIASES.get(category, {category})
|
|
134
|
+
if category != "all" else None
|
|
135
|
+
)
|
|
136
|
+
|
|
118
137
|
for dev in self._devices:
|
|
119
138
|
# Category filter
|
|
120
|
-
if
|
|
139
|
+
if allowed_cats is not None and dev.get("category", "") not in allowed_cats:
|
|
121
140
|
continue
|
|
122
141
|
|
|
123
142
|
score = 0
|
|
124
143
|
dev_name = dev.get("name", "")
|
|
125
144
|
dev_name_lower = dev_name.lower()
|
|
126
145
|
|
|
127
|
-
# Name scoring:
|
|
146
|
+
# Name scoring. BUG-B41 fix: dropped weight dramatically
|
|
147
|
+
# (was 100 exact / 50 substring) so a device literally
|
|
148
|
+
# named "Bass" no longer blows past character-tag matches
|
|
149
|
+
# for a sonic query like "warm analog bass". An exact name
|
|
150
|
+
# match is still the strongest single signal, but a device
|
|
151
|
+
# with 2+ matching character-tags now beats a name-only
|
|
152
|
+
# accident.
|
|
128
153
|
if dev_name_lower == query_lower:
|
|
129
|
-
score += 100
|
|
154
|
+
score += 45 # was 100
|
|
130
155
|
elif query_lower in dev_name_lower:
|
|
131
|
-
score += 50
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
156
|
+
score += 20 # was 50
|
|
157
|
+
else:
|
|
158
|
+
# Partial: any query word present in name — small signal
|
|
159
|
+
for word in query_words:
|
|
160
|
+
if len(word) >= 3 and word in dev_name_lower:
|
|
161
|
+
score += 5
|
|
162
|
+
|
|
163
|
+
# Tag scoring — prefer enriched character_tags.
|
|
164
|
+
# BUG-B40 / B41: also read character_tags so enriched devices
|
|
165
|
+
# actually compete with name-based matches.
|
|
166
|
+
dev_tags = [
|
|
167
|
+
t.lower() for t in (
|
|
168
|
+
dev.get("character_tags") or dev.get("tags", [])
|
|
169
|
+
)
|
|
170
|
+
]
|
|
171
|
+
# BUG-B41: bumped to 35pts per tag so multi-tag matches beat
|
|
172
|
+
# a single substring-name match.
|
|
135
173
|
for word in query_words:
|
|
136
174
|
if word in dev_tags:
|
|
137
|
-
score +=
|
|
175
|
+
score += 35
|
|
176
|
+
# Partial tag match (word appears as substring in a tag)
|
|
177
|
+
else:
|
|
178
|
+
for tag in dev_tags:
|
|
179
|
+
if word in tag:
|
|
180
|
+
score += 10
|
|
181
|
+
break
|
|
138
182
|
|
|
139
183
|
# Use case scoring: 25pts per match
|
|
140
184
|
for use_case in dev.get("use_cases", []):
|
|
@@ -142,10 +186,11 @@ class AtlasManager:
|
|
|
142
186
|
for word in query_words:
|
|
143
187
|
if word in use_lower:
|
|
144
188
|
score += 25
|
|
145
|
-
break
|
|
189
|
+
break
|
|
146
190
|
|
|
147
|
-
# Genre scoring: 20pts primary, 10pts secondary
|
|
148
|
-
|
|
191
|
+
# Genre scoring: 20pts primary, 10pts secondary.
|
|
192
|
+
# BUG-B40: also read genre_affinity (enriched field).
|
|
193
|
+
genres = dev.get("genre_affinity") or dev.get("genres", {}) or {}
|
|
149
194
|
for genre in genres.get("primary", []):
|
|
150
195
|
if query_lower in genre.lower() or genre.lower() in query_lower:
|
|
151
196
|
score += 20
|
|
@@ -153,8 +198,11 @@ class AtlasManager:
|
|
|
153
198
|
if query_lower in genre.lower() or genre.lower() in query_lower:
|
|
154
199
|
score += 10
|
|
155
200
|
|
|
156
|
-
# Description keyword scoring: 15pts
|
|
157
|
-
|
|
201
|
+
# Description keyword scoring: 15pts.
|
|
202
|
+
# BUG-B40: prefer sonic_description when present.
|
|
203
|
+
description = (
|
|
204
|
+
dev.get("sonic_description") or dev.get("description", "")
|
|
205
|
+
).lower()
|
|
158
206
|
for word in query_words:
|
|
159
207
|
if len(word) >= 3 and word in description:
|
|
160
208
|
score += 15
|
|
@@ -224,7 +272,13 @@ class AtlasManager:
|
|
|
224
272
|
def chain_suggest(
|
|
225
273
|
self, role: str, genre: str = ""
|
|
226
274
|
) -> Dict[str, Any]:
|
|
227
|
-
"""Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad').
|
|
275
|
+
"""Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad').
|
|
276
|
+
|
|
277
|
+
BUG-B39 fix: the old code passed category="instrument" (singular)
|
|
278
|
+
and category="effect" to self.search(), but the atlas stores
|
|
279
|
+
devices with category="instruments" / "audio_effects" (plural).
|
|
280
|
+
Every filtered search missed and the chain came back empty.
|
|
281
|
+
"""
|
|
228
282
|
chain: List[Dict[str, Any]] = []
|
|
229
283
|
position = 0
|
|
230
284
|
|
|
@@ -244,8 +298,8 @@ class AtlasManager:
|
|
|
244
298
|
intent = instrument_intents.get(role_lower, role_lower)
|
|
245
299
|
search_q = f"{intent} {genre}" if genre else intent
|
|
246
300
|
|
|
247
|
-
# Find instrument
|
|
248
|
-
instrument_candidates = self.search(search_q, category="
|
|
301
|
+
# Find instrument — atlas category is "instruments" (plural)
|
|
302
|
+
instrument_candidates = self.search(search_q, category="instruments", limit=3)
|
|
249
303
|
if instrument_candidates:
|
|
250
304
|
best = instrument_candidates[0]["device"]
|
|
251
305
|
chain.append({
|
|
@@ -255,7 +309,7 @@ class AtlasManager:
|
|
|
255
309
|
})
|
|
256
310
|
position += 1
|
|
257
311
|
|
|
258
|
-
# Stage 2: Effects
|
|
312
|
+
# Stage 2: Effects — atlas category is "audio_effects"
|
|
259
313
|
effect_stages = [
|
|
260
314
|
("eq", f"Shape the {role} tone"),
|
|
261
315
|
("compression", f"Control {role} dynamics"),
|
|
@@ -264,7 +318,9 @@ class AtlasManager:
|
|
|
264
318
|
|
|
265
319
|
for effect_type, reason in effect_stages:
|
|
266
320
|
effect_q = f"{effect_type} {genre}" if genre else effect_type
|
|
267
|
-
effect_candidates = self.search(
|
|
321
|
+
effect_candidates = self.search(
|
|
322
|
+
effect_q, category="audio_effects", limit=2,
|
|
323
|
+
)
|
|
268
324
|
if effect_candidates:
|
|
269
325
|
best = effect_candidates[0]["device"]
|
|
270
326
|
chain.append({
|
|
@@ -295,37 +351,48 @@ class AtlasManager:
|
|
|
295
351
|
return {"error": f"Device not found: {device_b}"}
|
|
296
352
|
|
|
297
353
|
def _summarize(dev: Dict[str, Any]) -> Dict[str, Any]:
|
|
354
|
+
# BUG-B40 fix: enriched atlas entries use character_tags /
|
|
355
|
+
# sonic_description / genre_affinity — the old _summarize
|
|
356
|
+
# looked for "tags" / "description" / "genres" which are
|
|
357
|
+
# the UN-enriched raw scanner fields. We prefer enriched
|
|
358
|
+
# fields, fall back to raw when enrichment is absent.
|
|
298
359
|
return {
|
|
299
360
|
"name": dev.get("name", ""),
|
|
300
361
|
"category": dev.get("category", ""),
|
|
301
|
-
"tags": dev.get("tags", []),
|
|
302
|
-
"genres": dev.get("genres", {}),
|
|
362
|
+
"tags": dev.get("character_tags") or dev.get("tags", []),
|
|
363
|
+
"genres": dev.get("genre_affinity") or dev.get("genres", {}),
|
|
303
364
|
"use_cases": dev.get("use_cases", []),
|
|
304
|
-
"description":
|
|
365
|
+
"description": (
|
|
366
|
+
dev.get("sonic_description")
|
|
367
|
+
or dev.get("description", "")
|
|
368
|
+
),
|
|
305
369
|
"cpu_weight": dev.get("cpu_weight", "unknown"),
|
|
306
370
|
"sweet_spot": dev.get("sweet_spot", ""),
|
|
371
|
+
"enriched": dev.get("enriched", False),
|
|
307
372
|
}
|
|
308
373
|
|
|
309
374
|
summary_a = _summarize(dev_a)
|
|
310
375
|
summary_b = _summarize(dev_b)
|
|
311
376
|
|
|
312
|
-
# Recommendation logic: score each for the role
|
|
377
|
+
# Recommendation logic: score each for the role.
|
|
378
|
+
# BUG-B40: scorer also reads the enriched field names.
|
|
313
379
|
score_a = 0
|
|
314
380
|
score_b = 0
|
|
315
381
|
if role:
|
|
316
382
|
role_lower = role.lower()
|
|
317
|
-
# Check use_cases
|
|
318
383
|
for uc in dev_a.get("use_cases", []):
|
|
319
384
|
if role_lower in uc.lower():
|
|
320
385
|
score_a += 20
|
|
321
386
|
for uc in dev_b.get("use_cases", []):
|
|
322
387
|
if role_lower in uc.lower():
|
|
323
388
|
score_b += 20
|
|
324
|
-
#
|
|
325
|
-
|
|
389
|
+
# Tag scoring — prefer character_tags (enriched)
|
|
390
|
+
a_tags = dev_a.get("character_tags") or dev_a.get("tags", [])
|
|
391
|
+
b_tags = dev_b.get("character_tags") or dev_b.get("tags", [])
|
|
392
|
+
for tag in a_tags:
|
|
326
393
|
if role_lower in tag.lower():
|
|
327
394
|
score_a += 10
|
|
328
|
-
for tag in
|
|
395
|
+
for tag in b_tags:
|
|
329
396
|
if role_lower in tag.lower():
|
|
330
397
|
score_b += 10
|
|
331
398
|
|
|
@@ -94,10 +94,13 @@ def distill_reference_principles(
|
|
|
94
94
|
if not reference_description.strip() and not style_name.strip():
|
|
95
95
|
return {"error": "Provide reference_description or style_name"}
|
|
96
96
|
|
|
97
|
-
#
|
|
97
|
+
# BUG-B17 fix: collect profile fragments from all sources and MERGE.
|
|
98
|
+
# The old flow stopped at the first non-empty source, so if
|
|
99
|
+
# get_style_tactics returned a half-filled profile the text-keyword
|
|
100
|
+
# fallback never ran and the description's rich content was lost.
|
|
101
|
+
# Now we always run the text fallback too and fill missing fields.
|
|
98
102
|
reference_profile: dict = {}
|
|
99
103
|
|
|
100
|
-
# Try to get style tactics if style_name is provided
|
|
101
104
|
if style_name:
|
|
102
105
|
try:
|
|
103
106
|
from ..tools._research_engine import get_style_tactics
|
|
@@ -114,18 +117,34 @@ def distill_reference_principles(
|
|
|
114
117
|
}
|
|
115
118
|
except Exception as exc:
|
|
116
119
|
logger.debug("distill_reference_principles failed: %s", exc)
|
|
117
|
-
|
|
120
|
+
|
|
121
|
+
# Try the built-in style profile builder
|
|
118
122
|
if not reference_profile:
|
|
119
123
|
try:
|
|
120
124
|
from ..reference_engine.profile_builder import build_style_reference_profile
|
|
121
125
|
profile = build_style_reference_profile(
|
|
122
126
|
style_name or reference_description
|
|
123
127
|
)
|
|
124
|
-
reference_profile = profile.to_dict()
|
|
128
|
+
reference_profile = profile.to_dict() if profile else {}
|
|
125
129
|
except Exception as exc:
|
|
126
130
|
logger.debug("distill_reference_principles failed: %s", exc)
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
|
|
132
|
+
# Text-keyword fallback ALWAYS merges in. Style tactics + profile
|
|
133
|
+
# builder typically leave some fields empty; the description's
|
|
134
|
+
# keywords fill those gaps. This is the B17 fix that makes the
|
|
135
|
+
# Dabrye reproducer produce non-empty principles.
|
|
136
|
+
if reference_description.strip():
|
|
137
|
+
text_profile = _profile_from_description(reference_description)
|
|
138
|
+
for key, value in text_profile.items():
|
|
139
|
+
existing = reference_profile.get(key)
|
|
140
|
+
is_empty = (
|
|
141
|
+
existing is None
|
|
142
|
+
or existing == ""
|
|
143
|
+
or existing == []
|
|
144
|
+
or existing == {}
|
|
145
|
+
)
|
|
146
|
+
if is_empty and value:
|
|
147
|
+
reference_profile[key] = value
|
|
129
148
|
|
|
130
149
|
distillation = engine.distill_reference_principles(
|
|
131
150
|
reference_profile=reference_profile,
|
|
@@ -216,23 +235,65 @@ def generate_constrained_variants(
|
|
|
216
235
|
taste_graph=taste_graph,
|
|
217
236
|
)
|
|
218
237
|
|
|
219
|
-
# Validate each variant's compiled_plan against constraints
|
|
238
|
+
# Validate each variant's compiled_plan against constraints.
|
|
239
|
+
# BUG-B46: two problems in the old code —
|
|
240
|
+
# 1) iterating `for step in v.compiled_plan` yields dict KEYS
|
|
241
|
+
# (compiled_plan is {'move_id': ..., 'steps': [...]}), so
|
|
242
|
+
# the validation ran on strings and silently passed.
|
|
243
|
+
# 2) when a variant was filtered, we only blanked compiled_plan
|
|
244
|
+
# and left status='pending' — callers had no way to tell
|
|
245
|
+
# which variants became shells.
|
|
246
|
+
# Now we iterate .get("steps", []) correctly, flip filtered
|
|
247
|
+
# variants to status='blocked', and count blocked_count in the
|
|
248
|
+
# response so callers can detect the "all variants filtered" case.
|
|
249
|
+
blocked_count = 0
|
|
220
250
|
for v in ps.variants:
|
|
221
|
-
v.what_preserved =
|
|
251
|
+
v.what_preserved = (
|
|
252
|
+
f"{v.what_preserved} | Constraints: "
|
|
253
|
+
f"{', '.join(active.constraints)}"
|
|
254
|
+
)
|
|
222
255
|
if v.compiled_plan:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
256
|
+
steps = v.compiled_plan.get("steps", []) if isinstance(
|
|
257
|
+
v.compiled_plan, dict
|
|
258
|
+
) else []
|
|
259
|
+
plan = {
|
|
260
|
+
"steps": [
|
|
261
|
+
{"action": step.get("tool", ""), **step}
|
|
262
|
+
for step in steps
|
|
263
|
+
]
|
|
264
|
+
}
|
|
265
|
+
validation = engine.validate_plan_against_constraints(
|
|
266
|
+
plan, active,
|
|
267
|
+
)
|
|
228
268
|
if not validation["valid"]:
|
|
229
269
|
v.compiled_plan = None
|
|
230
|
-
v.
|
|
270
|
+
v.status = "blocked"
|
|
271
|
+
v.what_changed = (
|
|
272
|
+
f"[FILTERED] {v.what_changed} — violates "
|
|
273
|
+
f"{', '.join(active.constraints)}"
|
|
274
|
+
)
|
|
275
|
+
blocked_count += 1
|
|
276
|
+
elif v.status == "blocked":
|
|
277
|
+
# Already blocked upstream (no compilable move)
|
|
278
|
+
blocked_count += 1
|
|
279
|
+
|
|
280
|
+
note = (
|
|
281
|
+
f"Variants with violating plans have been filtered "
|
|
282
|
+
f"({blocked_count}/{len(ps.variants)} blocked)"
|
|
283
|
+
)
|
|
284
|
+
if blocked_count == len(ps.variants) and ps.variants:
|
|
285
|
+
note = (
|
|
286
|
+
f"All {blocked_count} variants violate constraints "
|
|
287
|
+
f"{active.constraints!r}. Try loosening constraints or a "
|
|
288
|
+
f"different request."
|
|
289
|
+
)
|
|
231
290
|
|
|
232
291
|
return {
|
|
233
292
|
"preview_set": ps.to_dict(),
|
|
234
293
|
"constraints_applied": active.constraints,
|
|
235
|
-
"
|
|
294
|
+
"blocked_count": blocked_count,
|
|
295
|
+
"executable_count": len(ps.variants) - blocked_count,
|
|
296
|
+
"note": note,
|
|
236
297
|
}
|
|
237
298
|
except Exception as e:
|
|
238
299
|
return {"error": f"Failed to generate constrained variants: {e}"}
|
|
@@ -256,9 +317,29 @@ def generate_reference_inspired_variants(
|
|
|
256
317
|
if _cached_distillation is None:
|
|
257
318
|
return {"error": "No reference distilled yet — call distill_reference_principles first"}
|
|
258
319
|
|
|
320
|
+
# BUG-B54: the reference-engine chain (distill → map → generate_variants)
|
|
321
|
+
# used to silently degrade when distill_reference_principles returned
|
|
322
|
+
# empty principles (BUG-B17). Callers got 3 shell variants branded
|
|
323
|
+
# "reference-inspired" with no reference material driving them.
|
|
324
|
+
# Refuse to run when principles are empty — the user should fix the
|
|
325
|
+
# distillation step first.
|
|
326
|
+
principles_list = list(_cached_distillation.principles or [])
|
|
327
|
+
if not principles_list:
|
|
328
|
+
return {
|
|
329
|
+
"error": (
|
|
330
|
+
"distill_reference_principles returned no principles — "
|
|
331
|
+
"reference-inspired variant generation refuses to run on "
|
|
332
|
+
"empty input (would produce meaningless 'reference-inspired' "
|
|
333
|
+
"shell variants). Try a more specific reference description "
|
|
334
|
+
"or pick a reference covered by the built-in style corpus."
|
|
335
|
+
),
|
|
336
|
+
"reference": _cached_distillation.reference_description,
|
|
337
|
+
"principles_applied": [],
|
|
338
|
+
}
|
|
339
|
+
|
|
259
340
|
# Build request text from reference principles
|
|
260
341
|
principles_text = ", ".join(
|
|
261
|
-
p.principle for p in
|
|
342
|
+
p.principle for p in principles_list[:3]
|
|
262
343
|
)
|
|
263
344
|
full_request = (
|
|
264
345
|
f"Inspired by: {_cached_distillation.reference_description}. "
|
|
@@ -288,7 +369,9 @@ def generate_reference_inspired_variants(
|
|
|
288
369
|
return {
|
|
289
370
|
"preview_set": ps.to_dict(),
|
|
290
371
|
"reference": _cached_distillation.reference_description,
|
|
291
|
-
"principles_applied": [
|
|
372
|
+
"principles_applied": [
|
|
373
|
+
p.to_dict() for p in principles_list[:5]
|
|
374
|
+
],
|
|
292
375
|
"note": "Variants are shaped by reference principles, not surface imitation",
|
|
293
376
|
}
|
|
294
377
|
except Exception as e:
|
|
@@ -311,32 +394,122 @@ def _get_song_brain_dict() -> dict:
|
|
|
311
394
|
|
|
312
395
|
|
|
313
396
|
def _profile_from_description(description: str) -> dict:
|
|
314
|
-
"""Build a rough reference profile from text description.
|
|
397
|
+
"""Build a rough reference profile from a free-text description.
|
|
398
|
+
|
|
399
|
+
BUG-B17 fix: the old version only mapped 8 emotion keywords and
|
|
400
|
+
left every other field empty, so distill_reference_principles
|
|
401
|
+
returned empty principles for any description that didn't include
|
|
402
|
+
exactly one of those 8 words. We now scan for a rich keyword set
|
|
403
|
+
across emotional / spectral / width / groove / harmonic / density
|
|
404
|
+
dimensions so a description like "cold 90s hip-hop with ghostly
|
|
405
|
+
vocal chops and dusty drums" actually produces principles.
|
|
406
|
+
"""
|
|
315
407
|
desc_lower = description.lower()
|
|
316
408
|
|
|
409
|
+
# Emotional stance
|
|
317
410
|
emotional_map = {
|
|
318
|
-
"dark": "tense",
|
|
319
|
-
"bright": "euphoric",
|
|
320
|
-
"sad": "melancholic",
|
|
321
|
-
"
|
|
322
|
-
"
|
|
323
|
-
"
|
|
324
|
-
"
|
|
325
|
-
"minimal": "restrained",
|
|
411
|
+
"dark": "tense", "cold": "tense", "ominous": "tense", "eerie": "tense",
|
|
412
|
+
"bright": "euphoric", "warm": "warm", "sunny": "euphoric",
|
|
413
|
+
"sad": "melancholic", "longing": "melancholic", "wistful": "melancholic",
|
|
414
|
+
"nostalgic": "nostalgic", "dust": "nostalgic", "dusty": "nostalgic",
|
|
415
|
+
"aggressive": "aggressive", "violent": "aggressive", "intense": "aggressive",
|
|
416
|
+
"dreamy": "dreamy", "dream": "dreamy", "floaty": "dreamy",
|
|
417
|
+
"chill": "relaxed", "meditative": "relaxed",
|
|
418
|
+
"minimal": "restrained", "restrained": "restrained",
|
|
419
|
+
"ghostly": "haunted", "haunted": "haunted", "ghost": "haunted",
|
|
420
|
+
"euphoric": "euphoric", "ecstatic": "euphoric",
|
|
326
421
|
}
|
|
327
|
-
|
|
328
422
|
emotional = ""
|
|
329
423
|
for keyword, stance in emotional_map.items():
|
|
330
424
|
if keyword in desc_lower:
|
|
331
425
|
emotional = stance
|
|
332
426
|
break
|
|
333
427
|
|
|
428
|
+
# Spectral contour — from brightness / color keywords
|
|
429
|
+
spectral_contour: dict = {}
|
|
430
|
+
if any(k in desc_lower for k in ("dark", "muddy", "lo-fi", "lofi",
|
|
431
|
+
"dusty", "cold", "underwater",
|
|
432
|
+
"warm", "vintage")):
|
|
433
|
+
spectral_contour = {
|
|
434
|
+
"band_balance": {"sub": 0.4, "low": 0.5, "mid": 0.35,
|
|
435
|
+
"high_mid": 0.2, "high": 0.1},
|
|
436
|
+
"centroid_hint": "dark / roll-off near 4kHz",
|
|
437
|
+
}
|
|
438
|
+
elif any(k in desc_lower for k in ("bright", "crisp", "shiny", "airy",
|
|
439
|
+
"glittery", "sparkly", "cinematic")):
|
|
440
|
+
spectral_contour = {
|
|
441
|
+
"band_balance": {"sub": 0.25, "low": 0.3, "mid": 0.4,
|
|
442
|
+
"high_mid": 0.55, "high": 0.6},
|
|
443
|
+
"centroid_hint": "bright / open high shelf",
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
# Width / depth — mono vs wide vs deep
|
|
447
|
+
width_depth: dict = {}
|
|
448
|
+
if any(k in desc_lower for k in ("narrow", "mono", "focused", "tight",
|
|
449
|
+
"centered")):
|
|
450
|
+
width_depth = {"stereo_width": 0.25, "depth_hint": "close, upfront"}
|
|
451
|
+
elif any(k in desc_lower for k in ("wide", "spacious", "spatial",
|
|
452
|
+
"ambient", "washy", "drifting")):
|
|
453
|
+
width_depth = {"stereo_width": 0.85, "depth_hint": "deep, spatial"}
|
|
454
|
+
elif any(k in desc_lower for k in ("intimate", "dry")):
|
|
455
|
+
width_depth = {"stereo_width": 0.4, "depth_hint": "dry, intimate"}
|
|
456
|
+
|
|
457
|
+
# Groove posture — rhythm keywords
|
|
458
|
+
groove_posture: dict = {}
|
|
459
|
+
if any(k in desc_lower for k in ("swing", "shuffle", "dilla", "slouchy")):
|
|
460
|
+
groove_posture = {"feel": "swung", "stiffness": 0.25}
|
|
461
|
+
elif any(k in desc_lower for k in ("tight", "clean", "quantized",
|
|
462
|
+
"precise", "crispy")):
|
|
463
|
+
groove_posture = {"feel": "straight", "stiffness": 0.9}
|
|
464
|
+
elif any(k in desc_lower for k in ("loose", "sloppy", "drunk",
|
|
465
|
+
"organic", "human")):
|
|
466
|
+
groove_posture = {"feel": "humanized", "stiffness": 0.3}
|
|
467
|
+
elif any(k in desc_lower for k in ("driving", "motorik", "pulsing",
|
|
468
|
+
"throbbing", "hypnotic")):
|
|
469
|
+
groove_posture = {"feel": "driving", "stiffness": 0.8}
|
|
470
|
+
|
|
471
|
+
# Density motion — when the user hints at pacing
|
|
472
|
+
density_arc: list[float] = []
|
|
473
|
+
if any(k in desc_lower for k in ("slow burn", "patient", "gradually",
|
|
474
|
+
"builds", "buildup")):
|
|
475
|
+
density_arc = [0.2, 0.3, 0.5, 0.7, 0.9]
|
|
476
|
+
elif any(k in desc_lower for k in ("explodes", "immediate", "front-loaded",
|
|
477
|
+
"hits from the start")):
|
|
478
|
+
density_arc = [0.9, 0.85, 0.8, 0.5, 0.3]
|
|
479
|
+
elif any(k in desc_lower for k in ("dual drop", "return", "second wind")):
|
|
480
|
+
density_arc = [0.4, 0.8, 0.5, 0.3, 0.9]
|
|
481
|
+
|
|
482
|
+
# Harmonic character
|
|
483
|
+
harmonic = ""
|
|
484
|
+
if any(k in desc_lower for k in ("minor", "dorian", "phrygian",
|
|
485
|
+
"melancholic", "tense")):
|
|
486
|
+
harmonic = "minor_modal"
|
|
487
|
+
elif any(k in desc_lower for k in ("major", "ionian", "lydian",
|
|
488
|
+
"euphoric", "triumphant")):
|
|
489
|
+
harmonic = "major_modal"
|
|
490
|
+
elif any(k in desc_lower for k in ("dissonant", "dense", "clusters",
|
|
491
|
+
"microtonal")):
|
|
492
|
+
harmonic = "dissonant_clustered"
|
|
493
|
+
elif any(k in desc_lower for k in ("ambient", "drone", "pad",
|
|
494
|
+
"atmospheric", "washy")):
|
|
495
|
+
harmonic = "atmospheric_filtered"
|
|
496
|
+
|
|
497
|
+
# Payoff / section pacing
|
|
498
|
+
section_pacing: list[dict] = []
|
|
499
|
+
if any(k in desc_lower for k in ("sparse intro", "sparse", "slow start")):
|
|
500
|
+
section_pacing.append({"label": "sparse_intro", "bars": 16})
|
|
501
|
+
if any(k in desc_lower for k in ("buildup", "builds", "growing")):
|
|
502
|
+
section_pacing.append({"label": "gradual_buildup", "bars": 16})
|
|
503
|
+
if any(k in desc_lower for k in ("drop", "peak", "payoff",
|
|
504
|
+
"strip back", "pulled out")):
|
|
505
|
+
section_pacing.append({"label": "strip_back_payoff", "bars": 16})
|
|
506
|
+
|
|
334
507
|
return {
|
|
335
508
|
"emotional_stance": emotional,
|
|
336
|
-
"density_arc":
|
|
337
|
-
"section_pacing":
|
|
338
|
-
"width_depth":
|
|
339
|
-
"spectral_contour":
|
|
340
|
-
"groove_posture":
|
|
341
|
-
"harmonic_character":
|
|
509
|
+
"density_arc": density_arc,
|
|
510
|
+
"section_pacing": section_pacing,
|
|
511
|
+
"width_depth": width_depth,
|
|
512
|
+
"spectral_contour": spectral_contour,
|
|
513
|
+
"groove_posture": groove_posture,
|
|
514
|
+
"harmonic_character": harmonic,
|
|
342
515
|
}
|
|
@@ -122,6 +122,8 @@ def _run_branch_sync(branch, ableton, compiled_plan, capture_fn):
|
|
|
122
122
|
branch.compiled_plan = compiled_plan
|
|
123
123
|
branch.before_snapshot = capture_fn()
|
|
124
124
|
|
|
125
|
+
from ..runtime.execution_router import READ_ONLY_TOOLS
|
|
126
|
+
|
|
125
127
|
steps_executed = 0
|
|
126
128
|
log = []
|
|
127
129
|
for step in compiled_plan.get("steps", []):
|
|
@@ -129,7 +131,7 @@ def _run_branch_sync(branch, ableton, compiled_plan, capture_fn):
|
|
|
129
131
|
params = step.get("params", {})
|
|
130
132
|
if not tool:
|
|
131
133
|
continue
|
|
132
|
-
if tool in
|
|
134
|
+
if tool in READ_ONLY_TOOLS:
|
|
133
135
|
continue
|
|
134
136
|
try:
|
|
135
137
|
result = ableton.send_command(tool, params)
|
|
@@ -173,21 +175,17 @@ async def run_branch_async(
|
|
|
173
175
|
analyze_mix) are skipped in the apply pass — they're used for snapshot
|
|
174
176
|
capture separately.
|
|
175
177
|
"""
|
|
176
|
-
from ..runtime.execution_router import execute_plan_steps_async
|
|
178
|
+
from ..runtime.execution_router import execute_plan_steps_async, filter_apply_steps
|
|
177
179
|
|
|
178
180
|
branch.status = "running"
|
|
179
181
|
branch.compiled_plan = compiled_plan
|
|
180
182
|
|
|
181
183
|
branch.before_snapshot = capture_fn()
|
|
182
184
|
|
|
183
|
-
# Filter out read-only verification steps from the apply pass
|
|
185
|
+
# Filter out read-only verification steps from the apply pass (canonical
|
|
186
|
+
# list lives in execution_router.READ_ONLY_TOOLS).
|
|
184
187
|
all_steps = compiled_plan.get("steps", []) or []
|
|
185
|
-
apply_steps =
|
|
186
|
-
s for s in all_steps
|
|
187
|
-
if s.get("tool") and s.get("tool") not in (
|
|
188
|
-
"get_track_meters", "get_master_spectrum", "analyze_mix",
|
|
189
|
-
)
|
|
190
|
-
]
|
|
188
|
+
apply_steps = filter_apply_steps(all_steps)
|
|
191
189
|
|
|
192
190
|
exec_results = await execute_plan_steps_async(
|
|
193
191
|
apply_steps,
|
|
@@ -33,14 +33,30 @@ def find_hook_candidates(
|
|
|
33
33
|
candidates: list[HookCandidate] = []
|
|
34
34
|
|
|
35
35
|
# 1. Motif-based hooks
|
|
36
|
-
|
|
36
|
+
#
|
|
37
|
+
# BUG-B8 fix: the old code used motif.get('name', 'unknown'); the motif
|
|
38
|
+
# engine emits `motif_id` (not `name`), so every candidate collapsed
|
|
39
|
+
# onto hook_id="motif_unknown" and rank_hook_candidates returned 4+
|
|
40
|
+
# duplicate rows with empty location strings. We now prefer motif_id,
|
|
41
|
+
# then name, then a per-iteration index fallback to guarantee uniqueness.
|
|
42
|
+
# A final post-filter dedupes by (hook_id, hook_type, description) in
|
|
43
|
+
# case another producer slips a duplicate in.
|
|
44
|
+
for idx, motif in enumerate(motif_data.get("motifs", [])):
|
|
37
45
|
salience = motif.get("salience", 0)
|
|
38
46
|
recurrence = motif.get("recurrence", 0)
|
|
39
47
|
if salience > 0.2 or recurrence > 0.3:
|
|
48
|
+
identifier = (
|
|
49
|
+
motif.get("motif_id")
|
|
50
|
+
or motif.get("name")
|
|
51
|
+
or f"idx{idx}"
|
|
52
|
+
)
|
|
40
53
|
candidates.append(HookCandidate(
|
|
41
|
-
hook_id=f"motif_{
|
|
54
|
+
hook_id=f"motif_{identifier}",
|
|
42
55
|
hook_type="melodic",
|
|
43
|
-
description=motif.get(
|
|
56
|
+
description=motif.get(
|
|
57
|
+
"description",
|
|
58
|
+
motif.get("name") or motif.get("motif_id") or f"motif #{idx}",
|
|
59
|
+
),
|
|
44
60
|
location=motif.get("location", ""),
|
|
45
61
|
memorability=min(1.0, salience * 1.2),
|
|
46
62
|
recurrence=recurrence,
|
|
@@ -109,6 +125,20 @@ def find_hook_candidates(
|
|
|
109
125
|
if "groove" in c.hook_id:
|
|
110
126
|
c.evidence_sources.append("clip_reuse")
|
|
111
127
|
|
|
128
|
+
# BUG-B8: post-filter dedupe. Even after the motif_id fix above, other
|
|
129
|
+
# producers (track-name, groove-pattern) could collide on the same
|
|
130
|
+
# hook_id if session conventions repeat (e.g. two tracks named "Lead").
|
|
131
|
+
# Keep the first occurrence (sorted by salience below picks the winner
|
|
132
|
+
# among the originals), drop later duplicates by hook_id.
|
|
133
|
+
seen_ids: set[str] = set()
|
|
134
|
+
unique_candidates: list[HookCandidate] = []
|
|
135
|
+
for c in candidates:
|
|
136
|
+
if c.hook_id in seen_ids:
|
|
137
|
+
continue
|
|
138
|
+
seen_ids.add(c.hook_id)
|
|
139
|
+
unique_candidates.append(c)
|
|
140
|
+
candidates = unique_candidates
|
|
141
|
+
|
|
112
142
|
# Sort by salience
|
|
113
143
|
candidates.sort(key=lambda c: c.salience, reverse=True)
|
|
114
144
|
return candidates
|
|
@@ -154,17 +184,40 @@ def score_phrase_impact(
|
|
|
154
184
|
# Anticipation: was there a dip before?
|
|
155
185
|
anticipation = min(1.0, max(0.0, (0.5 - prev_energy) * 2)) if prev_energy < 0.5 else 0.2
|
|
156
186
|
|
|
157
|
-
#
|
|
158
|
-
|
|
187
|
+
# BUG-B51: note-content signals differentiate sections with
|
|
188
|
+
# identical energy/density. Without these, compare_phrase_impact
|
|
189
|
+
# emitted identical scores for every pair of same-density sections.
|
|
190
|
+
pitch_classes = int(section_data.get("unique_pitch_classes", 0) or 0)
|
|
191
|
+
note_count = int(section_data.get("note_count", 0) or 0)
|
|
192
|
+
velocity_variance = float(section_data.get("velocity_variance", 0) or 0)
|
|
193
|
+
# Pitch-class diversity → contrast lift: 0 classes = 0, 7+ = +0.3
|
|
194
|
+
pc_contrast_bonus = min(0.3, pitch_classes * 0.04)
|
|
195
|
+
# Note-density signal: more notes = richer content
|
|
196
|
+
note_density_signal = min(1.0, note_count / 50.0)
|
|
197
|
+
# Velocity variance → dynamic interest
|
|
198
|
+
dynamic_interest = min(1.0, velocity_variance / 200.0)
|
|
199
|
+
|
|
200
|
+
# Contrast: density / energy change + pitch-class diversity
|
|
201
|
+
contrast = min(
|
|
202
|
+
1.0,
|
|
203
|
+
abs(density - prev_density) + abs(energy_delta) + pc_contrast_bonus,
|
|
204
|
+
)
|
|
159
205
|
|
|
160
|
-
# Repetition fatigue: high density
|
|
161
|
-
|
|
206
|
+
# Repetition fatigue: high density + low dynamic variance = fatiguing
|
|
207
|
+
base_fatigue = max(0.0, 1.0 - contrast) * 0.5
|
|
208
|
+
# Flat velocity → more fatigue; dynamic variation → less
|
|
209
|
+
fatigue = round(max(0.0, base_fatigue - dynamic_interest * 0.15), 3)
|
|
162
210
|
|
|
163
|
-
# Section clarity: does it have a clear role?
|
|
164
|
-
|
|
211
|
+
# Section clarity: does it have a clear role + content to back it up?
|
|
212
|
+
label_clarity = 0.7 if section_data.get("label") else 0.3
|
|
213
|
+
content_clarity = 0.1 * min(1.0, note_count / 20.0)
|
|
214
|
+
clarity = min(1.0, label_clarity + content_clarity)
|
|
165
215
|
|
|
166
216
|
# Groove continuity: rhythm present
|
|
167
217
|
groove = 0.7 if section_data.get("has_drums", True) else 0.3
|
|
218
|
+
# Boost groove continuity when the section has genuine rhythmic
|
|
219
|
+
# activity (note_density_signal nudges it up, flat sections down)
|
|
220
|
+
groove = min(1.0, groove + note_density_signal * 0.1)
|
|
168
221
|
|
|
169
222
|
# Payoff balance
|
|
170
223
|
payoff = min(1.0, (arrival + anticipation) / 2)
|