livepilot 1.10.5 → 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 +92 -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-evaluation/references/capability-modes.md +1 -1
- 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 +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- 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 +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- 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/runtime/tools.py +8 -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 +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- 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 +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- 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 +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- 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
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -17,7 +17,9 @@ from fastmcp import Context
|
|
|
17
17
|
from ..server import mcp
|
|
18
18
|
from . import engine
|
|
19
19
|
from .models import CONSTRAINT_MODES
|
|
20
|
+
import logging
|
|
20
21
|
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
21
23
|
|
|
22
24
|
# Module-level cache for active constraints and distillations
|
|
23
25
|
_active_constraints: Optional[engine.ConstraintSet] = None
|
|
@@ -92,10 +94,13 @@ def distill_reference_principles(
|
|
|
92
94
|
if not reference_description.strip() and not style_name.strip():
|
|
93
95
|
return {"error": "Provide reference_description or style_name"}
|
|
94
96
|
|
|
95
|
-
#
|
|
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.
|
|
96
102
|
reference_profile: dict = {}
|
|
97
103
|
|
|
98
|
-
# Try to get style tactics if style_name is provided
|
|
99
104
|
if style_name:
|
|
100
105
|
try:
|
|
101
106
|
from ..tools._research_engine import get_style_tactics
|
|
@@ -110,20 +115,36 @@ def distill_reference_principles(
|
|
|
110
115
|
"groove_posture": tactics.get("groove_posture", {}),
|
|
111
116
|
"harmonic_character": tactics.get("harmonic_character", ""),
|
|
112
117
|
}
|
|
113
|
-
except Exception:
|
|
114
|
-
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
logger.debug("distill_reference_principles failed: %s", exc)
|
|
115
120
|
|
|
116
|
-
# Try
|
|
121
|
+
# Try the built-in style profile builder
|
|
117
122
|
if not reference_profile:
|
|
118
123
|
try:
|
|
119
124
|
from ..reference_engine.profile_builder import build_style_reference_profile
|
|
120
125
|
profile = build_style_reference_profile(
|
|
121
126
|
style_name or reference_description
|
|
122
127
|
)
|
|
123
|
-
reference_profile = profile.to_dict()
|
|
124
|
-
except Exception:
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
reference_profile = profile.to_dict() if profile else {}
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
logger.debug("distill_reference_principles failed: %s", exc)
|
|
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
|
|
127
148
|
|
|
128
149
|
distillation = engine.distill_reference_principles(
|
|
129
150
|
reference_profile=reference_profile,
|
|
@@ -204,9 +225,8 @@ def generate_constrained_variants(
|
|
|
204
225
|
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
205
226
|
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
206
227
|
taste_graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store).to_dict()
|
|
207
|
-
except Exception:
|
|
208
|
-
|
|
209
|
-
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
logger.debug("generate_constrained_variants failed: %s", exc)
|
|
210
230
|
ps = ps_engine.create_preview_set(
|
|
211
231
|
request_text=f"[Constrained: {', '.join(active.constraints)}] {request_text}",
|
|
212
232
|
kernel_id=kernel_id,
|
|
@@ -215,23 +235,65 @@ def generate_constrained_variants(
|
|
|
215
235
|
taste_graph=taste_graph,
|
|
216
236
|
)
|
|
217
237
|
|
|
218
|
-
# 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
|
|
219
250
|
for v in ps.variants:
|
|
220
|
-
v.what_preserved =
|
|
251
|
+
v.what_preserved = (
|
|
252
|
+
f"{v.what_preserved} | Constraints: "
|
|
253
|
+
f"{', '.join(active.constraints)}"
|
|
254
|
+
)
|
|
221
255
|
if v.compiled_plan:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
)
|
|
227
268
|
if not validation["valid"]:
|
|
228
269
|
v.compiled_plan = None
|
|
229
|
-
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
|
+
)
|
|
230
290
|
|
|
231
291
|
return {
|
|
232
292
|
"preview_set": ps.to_dict(),
|
|
233
293
|
"constraints_applied": active.constraints,
|
|
234
|
-
"
|
|
294
|
+
"blocked_count": blocked_count,
|
|
295
|
+
"executable_count": len(ps.variants) - blocked_count,
|
|
296
|
+
"note": note,
|
|
235
297
|
}
|
|
236
298
|
except Exception as e:
|
|
237
299
|
return {"error": f"Failed to generate constrained variants: {e}"}
|
|
@@ -255,9 +317,29 @@ def generate_reference_inspired_variants(
|
|
|
255
317
|
if _cached_distillation is None:
|
|
256
318
|
return {"error": "No reference distilled yet — call distill_reference_principles first"}
|
|
257
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
|
+
|
|
258
340
|
# Build request text from reference principles
|
|
259
341
|
principles_text = ", ".join(
|
|
260
|
-
p.principle for p in
|
|
342
|
+
p.principle for p in principles_list[:3]
|
|
261
343
|
)
|
|
262
344
|
full_request = (
|
|
263
345
|
f"Inspired by: {_cached_distillation.reference_description}. "
|
|
@@ -287,13 +369,14 @@ def generate_reference_inspired_variants(
|
|
|
287
369
|
return {
|
|
288
370
|
"preview_set": ps.to_dict(),
|
|
289
371
|
"reference": _cached_distillation.reference_description,
|
|
290
|
-
"principles_applied": [
|
|
372
|
+
"principles_applied": [
|
|
373
|
+
p.to_dict() for p in principles_list[:5]
|
|
374
|
+
],
|
|
291
375
|
"note": "Variants are shaped by reference principles, not surface imitation",
|
|
292
376
|
}
|
|
293
377
|
except Exception as e:
|
|
294
378
|
return {"error": f"Failed to generate reference-inspired variants: {e}"}
|
|
295
379
|
|
|
296
|
-
|
|
297
380
|
# ── Helpers ───────────────────────────────────────────────────────
|
|
298
381
|
|
|
299
382
|
|
|
@@ -305,37 +388,128 @@ def _get_song_brain_dict() -> dict:
|
|
|
305
388
|
except Exception as _e:
|
|
306
389
|
if __debug__:
|
|
307
390
|
import sys
|
|
391
|
+
|
|
308
392
|
print(f"LivePilot: SongBrain unavailable in creative_constraints: {_e}", file=sys.stderr)
|
|
309
393
|
return {}
|
|
310
394
|
|
|
311
395
|
|
|
312
396
|
def _profile_from_description(description: str) -> dict:
|
|
313
|
-
"""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
|
+
"""
|
|
314
407
|
desc_lower = description.lower()
|
|
315
408
|
|
|
409
|
+
# Emotional stance
|
|
316
410
|
emotional_map = {
|
|
317
|
-
"dark": "tense",
|
|
318
|
-
"bright": "euphoric",
|
|
319
|
-
"sad": "melancholic",
|
|
320
|
-
"
|
|
321
|
-
"
|
|
322
|
-
"
|
|
323
|
-
"
|
|
324
|
-
"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",
|
|
325
421
|
}
|
|
326
|
-
|
|
327
422
|
emotional = ""
|
|
328
423
|
for keyword, stance in emotional_map.items():
|
|
329
424
|
if keyword in desc_lower:
|
|
330
425
|
emotional = stance
|
|
331
426
|
break
|
|
332
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
|
+
|
|
333
507
|
return {
|
|
334
508
|
"emotional_stance": emotional,
|
|
335
|
-
"density_arc":
|
|
336
|
-
"section_pacing":
|
|
337
|
-
"width_depth":
|
|
338
|
-
"spectral_contour":
|
|
339
|
-
"groove_posture":
|
|
340
|
-
"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,
|
|
341
515
|
}
|
|
@@ -22,7 +22,9 @@ import time
|
|
|
22
22
|
from typing import Optional
|
|
23
23
|
|
|
24
24
|
from .models import ExperimentSet, ExperimentBranch, BranchSnapshot
|
|
25
|
+
import logging
|
|
25
26
|
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
26
28
|
|
|
27
29
|
# ── In-memory experiment store ───────────────────────────────────────────────
|
|
28
30
|
|
|
@@ -34,9 +36,9 @@ def _gen_id(prefix: str, seed: str) -> str:
|
|
|
34
36
|
h = hashlib.sha256(f"{prefix}:{seed}:{time.time()}".encode()).hexdigest()[:8]
|
|
35
37
|
return f"{prefix}_{h}"
|
|
36
38
|
|
|
37
|
-
|
|
38
39
|
# ── Create experiments ───────────────────────────────────────────────────────
|
|
39
40
|
|
|
41
|
+
|
|
40
42
|
def create_experiment(
|
|
41
43
|
request_text: str,
|
|
42
44
|
move_ids: list[str],
|
|
@@ -83,9 +85,9 @@ def list_experiments() -> list[dict]:
|
|
|
83
85
|
"""List all experiment sets."""
|
|
84
86
|
return [exp.to_dict() for exp in _EXPERIMENTS.values()]
|
|
85
87
|
|
|
86
|
-
|
|
87
88
|
# ── Run experiments (requires Ableton connection) ────────────────────────────
|
|
88
89
|
|
|
90
|
+
|
|
89
91
|
def run_branch(
|
|
90
92
|
branch: ExperimentBranch,
|
|
91
93
|
ableton, # AbletonConnection
|
|
@@ -120,6 +122,8 @@ def _run_branch_sync(branch, ableton, compiled_plan, capture_fn):
|
|
|
120
122
|
branch.compiled_plan = compiled_plan
|
|
121
123
|
branch.before_snapshot = capture_fn()
|
|
122
124
|
|
|
125
|
+
from ..runtime.execution_router import READ_ONLY_TOOLS
|
|
126
|
+
|
|
123
127
|
steps_executed = 0
|
|
124
128
|
log = []
|
|
125
129
|
for step in compiled_plan.get("steps", []):
|
|
@@ -127,7 +131,7 @@ def _run_branch_sync(branch, ableton, compiled_plan, capture_fn):
|
|
|
127
131
|
params = step.get("params", {})
|
|
128
132
|
if not tool:
|
|
129
133
|
continue
|
|
130
|
-
if tool in
|
|
134
|
+
if tool in READ_ONLY_TOOLS:
|
|
131
135
|
continue
|
|
132
136
|
try:
|
|
133
137
|
result = ableton.send_command(tool, params)
|
|
@@ -143,7 +147,8 @@ def _run_branch_sync(branch, ableton, compiled_plan, capture_fn):
|
|
|
143
147
|
for _ in range(steps_executed):
|
|
144
148
|
try:
|
|
145
149
|
ableton.send_command("undo", {})
|
|
146
|
-
except Exception:
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
logger.debug("_run_branch_sync failed: %s", exc)
|
|
147
152
|
break
|
|
148
153
|
|
|
149
154
|
branch.status = "evaluated" if steps_executed > 0 else "failed"
|
|
@@ -170,21 +175,17 @@ async def run_branch_async(
|
|
|
170
175
|
analyze_mix) are skipped in the apply pass — they're used for snapshot
|
|
171
176
|
capture separately.
|
|
172
177
|
"""
|
|
173
|
-
from ..runtime.execution_router import execute_plan_steps_async
|
|
178
|
+
from ..runtime.execution_router import execute_plan_steps_async, filter_apply_steps
|
|
174
179
|
|
|
175
180
|
branch.status = "running"
|
|
176
181
|
branch.compiled_plan = compiled_plan
|
|
177
182
|
|
|
178
183
|
branch.before_snapshot = capture_fn()
|
|
179
184
|
|
|
180
|
-
# 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).
|
|
181
187
|
all_steps = compiled_plan.get("steps", []) or []
|
|
182
|
-
apply_steps =
|
|
183
|
-
s for s in all_steps
|
|
184
|
-
if s.get("tool") and s.get("tool") not in (
|
|
185
|
-
"get_track_meters", "get_master_spectrum", "analyze_mix",
|
|
186
|
-
)
|
|
187
|
-
]
|
|
188
|
+
apply_steps = filter_apply_steps(all_steps)
|
|
188
189
|
|
|
189
190
|
exec_results = await execute_plan_steps_async(
|
|
190
191
|
apply_steps,
|
|
@@ -215,7 +216,8 @@ async def run_branch_async(
|
|
|
215
216
|
for _ in range(steps_executed):
|
|
216
217
|
try:
|
|
217
218
|
ableton.send_command("undo", {})
|
|
218
|
-
except Exception:
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
logger.debug("run_branch_async failed: %s", exc)
|
|
219
221
|
break
|
|
220
222
|
|
|
221
223
|
# A branch is "evaluated" only if it actually applied at least one step.
|
|
@@ -244,9 +246,9 @@ def evaluate_branch(
|
|
|
244
246
|
branch.score = result.get("score", 0.0)
|
|
245
247
|
return branch
|
|
246
248
|
|
|
247
|
-
|
|
248
249
|
# ── Commit / discard ─────────────────────────────────────────────────────────
|
|
249
250
|
|
|
251
|
+
|
|
250
252
|
async def commit_branch_async(
|
|
251
253
|
experiment: ExperimentSet,
|
|
252
254
|
branch_id: str,
|
|
@@ -18,6 +18,9 @@ from fastmcp import Context
|
|
|
18
18
|
from ..server import mcp
|
|
19
19
|
from . import engine
|
|
20
20
|
from .models import BranchSnapshot
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
def _get_ableton(ctx: Context):
|
|
@@ -35,25 +38,22 @@ def _capture_snapshot(ctx: Context) -> BranchSnapshot:
|
|
|
35
38
|
try:
|
|
36
39
|
meters = ableton.send_command("get_track_meters", {"include_stereo": True})
|
|
37
40
|
snapshot.track_meters = meters.get("tracks", [])
|
|
38
|
-
except Exception:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
logger.debug("_capture_snapshot failed: %s", exc)
|
|
41
43
|
# Spectral data (requires M4L analyzer)
|
|
42
44
|
if spectral and spectral.is_connected:
|
|
43
45
|
try:
|
|
44
46
|
spec = spectral.get("spectrum")
|
|
45
47
|
if spec:
|
|
46
48
|
snapshot.spectrum = spec.get("value", {})
|
|
47
|
-
except Exception:
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
logger.debug("_capture_snapshot failed: %s", exc)
|
|
50
51
|
try:
|
|
51
52
|
rms_data = spectral.get("rms")
|
|
52
53
|
if rms_data:
|
|
53
54
|
snapshot.rms = rms_data.get("value")
|
|
54
|
-
except Exception:
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
logger.debug("_capture_snapshot failed: %s", exc)
|
|
57
57
|
return snapshot
|
|
58
58
|
|
|
59
59
|
|
|
@@ -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)
|