livepilot 1.10.6 → 1.10.8
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 +168 -0
- package/README.md +12 -10
- package/bin/livepilot.js +168 -30
- package/installer/install.js +117 -11
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +215 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +132 -33
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- 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 +68 -12
- 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 +66 -2
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +10 -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 +42 -4
- package/mcp_server/sample_engine/tools.py +48 -14
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +15 -4
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +75 -5
- package/mcp_server/services/singletons.py +68 -0
- 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/splice_client/client.py +29 -8
- 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 +105 -6
- package/mcp_server/tools/clips.py +46 -1
- 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 +23 -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/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +77 -2
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/router.py +13 -1
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcpbignore +0 -57
- package/AGENTS.md +0 -46
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -131
- package/scripts/sync_metadata.py +0 -132
|
@@ -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)
|
|
@@ -135,7 +135,18 @@ def develop_hook(
|
|
|
135
135
|
# Look up the actual hook to adapt strategies by type
|
|
136
136
|
hook_type = "melodic" # default
|
|
137
137
|
hook_description = "the hook"
|
|
138
|
-
|
|
138
|
+
# BUG-B31: when no hook_id is provided, default to the session's primary
|
|
139
|
+
# hook. Previously the tool emitted generic advice even though
|
|
140
|
+
# find_primary_hook was already available — users had to manually chain
|
|
141
|
+
# find_primary_hook → develop_hook to get type-specific tactics.
|
|
142
|
+
if not hook_id:
|
|
143
|
+
tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
|
|
144
|
+
primary = analyzer.find_primary_hook(tracks, motif_data, scenes)
|
|
145
|
+
if primary is not None:
|
|
146
|
+
hook_id = primary.hook_id
|
|
147
|
+
hook_type = primary.hook_type
|
|
148
|
+
hook_description = primary.description
|
|
149
|
+
elif hook_id:
|
|
139
150
|
tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
|
|
140
151
|
candidates = analyzer.find_hook_candidates(tracks, motif_data, scenes)
|
|
141
152
|
match = [c for c in candidates if c.hook_id == hook_id]
|
|
@@ -333,7 +344,14 @@ def suggest_payoff_repair(ctx: Context) -> dict:
|
|
|
333
344
|
|
|
334
345
|
|
|
335
346
|
def _get_section_data(ableton) -> list[dict]:
|
|
336
|
-
"""Build section data from Ableton scenes with real energy/density/has_drums.
|
|
347
|
+
"""Build section data from Ableton scenes with real energy/density/has_drums.
|
|
348
|
+
|
|
349
|
+
BUG-B51 fix: also fetches per-section note signals (unique pitch
|
|
350
|
+
count, note count, velocity-variance) so compare_phrase_impact can
|
|
351
|
+
differentiate two sections that share energy/density but have
|
|
352
|
+
different clip contents. Without these, the old comparator emitted
|
|
353
|
+
identical scores for every pair of same-density sections.
|
|
354
|
+
"""
|
|
337
355
|
sections: list[dict] = []
|
|
338
356
|
try:
|
|
339
357
|
matrix = ableton.send_command("get_scene_matrix")
|
|
@@ -342,11 +360,6 @@ def _get_section_data(ableton) -> list[dict]:
|
|
|
342
360
|
|
|
343
361
|
# Detect drum track indices by name
|
|
344
362
|
drum_keywords = {"drum", "beat", "kick", "hat", "perc", "snare"}
|
|
345
|
-
track_names = []
|
|
346
|
-
# tracks may be in matrix metadata or session_info
|
|
347
|
-
for ti, row_entry in enumerate(matrix_rows[0] if matrix_rows else []):
|
|
348
|
-
track_names.append("") # placeholder — we'll use scenes_list tracks if available
|
|
349
|
-
# Use scene matrix track info if available
|
|
350
363
|
track_info = matrix.get("tracks", [])
|
|
351
364
|
drum_indices = set()
|
|
352
365
|
for ti, track in enumerate(track_info):
|
|
@@ -361,16 +374,49 @@ def _get_section_data(ableton) -> list[dict]:
|
|
|
361
374
|
clip_count = sum(1 for c in row if c)
|
|
362
375
|
total_tracks = max(len(row), 1)
|
|
363
376
|
|
|
364
|
-
# has_drums: check if any drum track has a clip in this scene
|
|
365
377
|
has_drums = any(
|
|
366
378
|
di < len(row) and row[di]
|
|
367
379
|
for di in drum_indices
|
|
368
380
|
) if drum_indices else False
|
|
369
381
|
|
|
370
382
|
density = min(1.0, clip_count / total_tracks)
|
|
371
|
-
# energy: density + drum bonus
|
|
372
383
|
energy = min(1.0, density + (0.1 if has_drums else 0.0))
|
|
373
384
|
|
|
385
|
+
# BUG-B51: cheap per-section note signals. Sample up to 3
|
|
386
|
+
# non-drum tracks in this scene for a flavor of the
|
|
387
|
+
# section's harmonic/rhythmic content. Keeps the call
|
|
388
|
+
# count bounded so compare_phrase_impact doesn't explode.
|
|
389
|
+
unique_pitches: set = set()
|
|
390
|
+
note_count = 0
|
|
391
|
+
velocity_variance = 0.0
|
|
392
|
+
sampled = 0
|
|
393
|
+
for t_idx, cell in enumerate(row):
|
|
394
|
+
if sampled >= 3 or not cell:
|
|
395
|
+
continue
|
|
396
|
+
if t_idx in drum_indices:
|
|
397
|
+
continue
|
|
398
|
+
try:
|
|
399
|
+
notes_resp = ableton.send_command("get_notes", {
|
|
400
|
+
"track_index": t_idx, "clip_index": i,
|
|
401
|
+
})
|
|
402
|
+
except Exception:
|
|
403
|
+
continue
|
|
404
|
+
notes = notes_resp.get("notes", []) if isinstance(
|
|
405
|
+
notes_resp, dict
|
|
406
|
+
) else []
|
|
407
|
+
if not notes:
|
|
408
|
+
continue
|
|
409
|
+
sampled += 1
|
|
410
|
+
note_count += len(notes)
|
|
411
|
+
for n in notes:
|
|
412
|
+
unique_pitches.add(int(n.get("pitch", 0)) % 12)
|
|
413
|
+
vels = [int(n.get("velocity", 0)) for n in notes]
|
|
414
|
+
if len(vels) >= 2:
|
|
415
|
+
mean_v = sum(vels) / len(vels)
|
|
416
|
+
velocity_variance += sum(
|
|
417
|
+
(v - mean_v) ** 2 for v in vels
|
|
418
|
+
) / len(vels)
|
|
419
|
+
|
|
374
420
|
sections.append({
|
|
375
421
|
"id": f"scene_{i}",
|
|
376
422
|
"name": scene.get("name", f"Scene {i}"),
|
|
@@ -378,6 +424,11 @@ def _get_section_data(ableton) -> list[dict]:
|
|
|
378
424
|
"energy": round(energy, 3),
|
|
379
425
|
"density": round(density, 3),
|
|
380
426
|
"has_drums": has_drums,
|
|
427
|
+
# BUG-B51: these three differentiate otherwise-identical
|
|
428
|
+
# sections. Downstream phrase scorer reads them.
|
|
429
|
+
"unique_pitch_classes": len(unique_pitches),
|
|
430
|
+
"note_count": note_count,
|
|
431
|
+
"velocity_variance": round(velocity_variance, 3),
|
|
381
432
|
})
|
|
382
433
|
except Exception as exc:
|
|
383
434
|
logger.debug("_get_section_data failed: %s", exc)
|