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
|
@@ -11,8 +11,10 @@ from .models import AutomationGraph
|
|
|
11
11
|
def build_automation_graph(
|
|
12
12
|
track_infos: list[dict],
|
|
13
13
|
sections: list[dict] | None = None,
|
|
14
|
+
clip_automation: list[dict] | None = None,
|
|
14
15
|
) -> AutomationGraph:
|
|
15
|
-
"""Build an AutomationGraph
|
|
16
|
+
"""Build an AutomationGraph covering both device-parameter automation
|
|
17
|
+
hints and real clip envelopes (BUG-E2).
|
|
16
18
|
|
|
17
19
|
Args:
|
|
18
20
|
track_infos: list of per-track info dicts. Each may contain:
|
|
@@ -20,18 +22,52 @@ def build_automation_graph(
|
|
|
20
22
|
- name: track name
|
|
21
23
|
- devices: [{name, class_name, parameters: [{name, value, is_automated, ...}]}]
|
|
22
24
|
sections: optional list of section dicts (for density_by_section).
|
|
25
|
+
clip_automation: optional list of per-clip envelope descriptors:
|
|
26
|
+
[{section_id, track_index, track_name, clip_index,
|
|
27
|
+
parameter_name, parameter_type, device_name}].
|
|
28
|
+
This is the ground truth — `device.parameters[i].is_automated`
|
|
29
|
+
only reflects mapping state, not the presence of an envelope.
|
|
23
30
|
|
|
24
31
|
Returns:
|
|
25
32
|
AutomationGraph with automated_params and density_by_section.
|
|
26
33
|
"""
|
|
27
34
|
graph = AutomationGraph()
|
|
28
35
|
|
|
29
|
-
if not track_infos:
|
|
36
|
+
if not track_infos and not clip_automation:
|
|
30
37
|
return graph
|
|
31
38
|
|
|
32
|
-
automated_params = []
|
|
39
|
+
automated_params: list[dict] = []
|
|
40
|
+
# Track which (track_index, device_name, param_name) we've already seen
|
|
41
|
+
# so device-hint entries don't duplicate clip-envelope entries.
|
|
42
|
+
seen: set[tuple[int, str, str]] = set()
|
|
33
43
|
|
|
34
|
-
|
|
44
|
+
# 1) Seed with real clip envelopes. These are the source of truth.
|
|
45
|
+
per_section_counts: dict[str, int] = {}
|
|
46
|
+
for env in clip_automation or []:
|
|
47
|
+
t_idx = int(env.get("track_index", 0))
|
|
48
|
+
dev = str(env.get("device_name") or env.get("parameter_type") or "")
|
|
49
|
+
name = str(env.get("parameter_name") or "")
|
|
50
|
+
key = (t_idx, dev, name)
|
|
51
|
+
if key in seen:
|
|
52
|
+
continue
|
|
53
|
+
seen.add(key)
|
|
54
|
+
automated_params.append({
|
|
55
|
+
"track_index": t_idx,
|
|
56
|
+
"track_name": env.get("track_name", ""),
|
|
57
|
+
"device_name": dev or None,
|
|
58
|
+
"param_name": name,
|
|
59
|
+
"parameter_type": env.get("parameter_type", ""),
|
|
60
|
+
"clip_index": env.get("clip_index"),
|
|
61
|
+
"section_id": env.get("section_id"),
|
|
62
|
+
"source": "clip_envelope",
|
|
63
|
+
})
|
|
64
|
+
sec = env.get("section_id")
|
|
65
|
+
if sec:
|
|
66
|
+
per_section_counts[sec] = per_section_counts.get(sec, 0) + 1
|
|
67
|
+
|
|
68
|
+
# 2) Add device-level hints (track-wide is_automated flags) that
|
|
69
|
+
# aren't already covered by an envelope entry.
|
|
70
|
+
for track in track_infos or []:
|
|
35
71
|
t_idx = track.get("index", 0)
|
|
36
72
|
t_name = track.get("name", "")
|
|
37
73
|
devices = track.get("devices", [])
|
|
@@ -41,29 +77,45 @@ def build_automation_graph(
|
|
|
41
77
|
parameters = device.get("parameters", [])
|
|
42
78
|
|
|
43
79
|
for param in parameters:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
80
|
+
is_flagged = (
|
|
81
|
+
param.get("is_automated", False)
|
|
82
|
+
or param.get("automation_state", 0) > 0
|
|
83
|
+
)
|
|
84
|
+
if not is_flagged:
|
|
85
|
+
continue
|
|
86
|
+
p_name = param.get("name", "")
|
|
87
|
+
key = (t_idx, str(device_name), str(p_name))
|
|
88
|
+
if key in seen:
|
|
89
|
+
continue
|
|
90
|
+
seen.add(key)
|
|
91
|
+
automated_params.append({
|
|
92
|
+
"track_index": t_idx,
|
|
93
|
+
"track_name": t_name,
|
|
94
|
+
"device_name": device_name,
|
|
95
|
+
"param_name": p_name,
|
|
96
|
+
"param_value": param.get("value"),
|
|
97
|
+
"source": "device_hint",
|
|
98
|
+
})
|
|
52
99
|
|
|
53
100
|
graph.automated_params = automated_params
|
|
54
101
|
|
|
55
|
-
# Compute density_by_section
|
|
102
|
+
# Compute density_by_section.
|
|
56
103
|
if sections:
|
|
57
104
|
total_automated = len(automated_params)
|
|
58
105
|
for sec in sections:
|
|
59
106
|
section_id = sec.get("section_id", "")
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
107
|
+
if per_section_counts:
|
|
108
|
+
# Use real per-section counts when we have them.
|
|
109
|
+
count = per_section_counts.get(section_id, 0)
|
|
110
|
+
# Normalize by max(1, largest section count) so the
|
|
111
|
+
# densest section is 1.0 and others fall below.
|
|
112
|
+
max_ct = max(per_section_counts.values()) or 1
|
|
113
|
+
graph.density_by_section[section_id] = round(count / max_ct, 3)
|
|
114
|
+
elif total_automated > 0:
|
|
115
|
+
# Fallback: approximate from section density (old behavior)
|
|
116
|
+
sec_density = sec.get("density", 0.0)
|
|
65
117
|
graph.density_by_section[section_id] = round(
|
|
66
|
-
sec_density * min(total_automated / max(len(track_infos), 1), 1.0),
|
|
118
|
+
sec_density * min(total_automated / max(len(track_infos or []), 1), 1.0),
|
|
67
119
|
3,
|
|
68
120
|
)
|
|
69
121
|
else:
|
|
@@ -23,6 +23,7 @@ def build_project_state_from_data(
|
|
|
23
23
|
track_infos: Optional[list[dict]] = None,
|
|
24
24
|
notes_map: Optional[dict[str, dict[int, list[dict]]]] = None,
|
|
25
25
|
arrangement_clips: Optional[dict] = None,
|
|
26
|
+
clip_automation: Optional[list[dict]] = None,
|
|
26
27
|
analyzer_ok: bool = False,
|
|
27
28
|
flucoma_ok: bool = False,
|
|
28
29
|
plugin_health: Optional[dict[str, Any]] = None,
|
|
@@ -105,6 +106,7 @@ def build_project_state_from_data(
|
|
|
105
106
|
state.automation_graph = build_automation_graph(
|
|
106
107
|
track_infos=track_infos or [],
|
|
107
108
|
sections=section_dicts_for_auto,
|
|
109
|
+
clip_automation=clip_automation or [],
|
|
108
110
|
)
|
|
109
111
|
state.automation_graph.freshness.mark_fresh(state.revision)
|
|
110
112
|
|
|
@@ -88,14 +88,20 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
88
88
|
# Shape: {section_id: {track_index: [notes]}}. Without this, role_graph
|
|
89
89
|
# falls back to "assume all tracks active in every section" which destroys
|
|
90
90
|
# section-scoped role confidence.
|
|
91
|
+
#
|
|
92
|
+
# BUG-E1: section_id must match what build_section_graph_from_scenes emits.
|
|
93
|
+
# The composition engine emits `sec_{i:02d}` using the RAW enumerate index
|
|
94
|
+
# of the scene — it skips unnamed scenes (gap-preserving), so e.g. scenes
|
|
95
|
+
# ["Intro", "", "Verse"] become sections sec_00 and sec_02, not sec_01.
|
|
96
|
+
# Our notes_map must mirror that or keys won't align.
|
|
91
97
|
notes_map: dict[str, dict[int, list[dict]]] = {}
|
|
92
98
|
try:
|
|
93
99
|
for scene_idx, scene in enumerate(scenes or []):
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
scene_name = str(scene.get("name", "")).strip()
|
|
101
|
+
if not scene_name:
|
|
102
|
+
continue # mirror _ce_build_sections: unnamed scenes skipped
|
|
103
|
+
section_id = f"sec_{scene_idx:02d}"
|
|
104
|
+
|
|
99
105
|
per_track: dict[int, list[dict]] = {}
|
|
100
106
|
for track in tracks:
|
|
101
107
|
t_idx = track.get("index", 0)
|
|
@@ -119,6 +125,49 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
119
125
|
# Overall failure: empty map, degrade to "all tracks active" fallback
|
|
120
126
|
notes_map = {}
|
|
121
127
|
|
|
128
|
+
# 5c. Scan clip automation across the session (BUG-E2).
|
|
129
|
+
# Device-parameter is_automated flags only reflect whether a parameter
|
|
130
|
+
# is mapped somewhere — they don't reveal clip envelopes. Ableton's
|
|
131
|
+
# automation actually lives on each clip (session + arrangement). We
|
|
132
|
+
# walk every clip slot that has a clip and ask get_clip_automation, then
|
|
133
|
+
# aggregate into a flat list keyed by section.
|
|
134
|
+
clip_automation: list[dict] = []
|
|
135
|
+
try:
|
|
136
|
+
# Iterate session scenes x tracks, plus arrangement clips we already have.
|
|
137
|
+
# Use the raw enumerate index for section_id so it stays aligned with
|
|
138
|
+
# arrangement_graph sections (which use the same scheme — see E1 fix).
|
|
139
|
+
for scene_idx, scene in enumerate(scenes or []):
|
|
140
|
+
scene_name = str(scene.get("name", "")).strip()
|
|
141
|
+
if not scene_name:
|
|
142
|
+
continue
|
|
143
|
+
section_id = f"sec_{scene_idx:02d}"
|
|
144
|
+
for track in tracks:
|
|
145
|
+
t_idx = track.get("index", 0)
|
|
146
|
+
try:
|
|
147
|
+
auto_resp = ableton.send_command("get_clip_automation", {
|
|
148
|
+
"track_index": t_idx,
|
|
149
|
+
"clip_index": scene_idx,
|
|
150
|
+
})
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
# No clip in slot, or remote script rejected — skip
|
|
153
|
+
logger.debug("build_project_brain automation skip: %s", exc)
|
|
154
|
+
continue
|
|
155
|
+
if not isinstance(auto_resp, dict):
|
|
156
|
+
continue
|
|
157
|
+
envs = auto_resp.get("envelopes") or []
|
|
158
|
+
for env in envs:
|
|
159
|
+
clip_automation.append({
|
|
160
|
+
"section_id": section_id,
|
|
161
|
+
"track_index": t_idx,
|
|
162
|
+
"track_name": track.get("name", ""),
|
|
163
|
+
"clip_index": scene_idx,
|
|
164
|
+
"parameter_name": env.get("parameter_name", ""),
|
|
165
|
+
"parameter_type": env.get("parameter_type", ""),
|
|
166
|
+
"device_name": env.get("device_name"),
|
|
167
|
+
})
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
logger.debug("build_project_brain automation scan failed: %s", exc)
|
|
170
|
+
|
|
122
171
|
# 6. Probe capabilities (direct SpectralCache access, not TCP)
|
|
123
172
|
analyzer_ok = False
|
|
124
173
|
analyzer_fresh = False
|
|
@@ -146,6 +195,7 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
146
195
|
track_infos=track_infos if track_infos else None,
|
|
147
196
|
notes_map=notes_map if notes_map else None,
|
|
148
197
|
arrangement_clips=arrangement_clips if arrangement_clips else None,
|
|
198
|
+
clip_automation=clip_automation if clip_automation else None,
|
|
149
199
|
analyzer_ok=analyzer_ok,
|
|
150
200
|
flucoma_ok=flucoma_ok,
|
|
151
201
|
session_ok=True,
|
|
@@ -96,11 +96,22 @@ def build_style_reference_profile(style_tactics: list[dict]) -> ReferenceProfile
|
|
|
96
96
|
# Estimate density from arrangement pattern count
|
|
97
97
|
density_arc = _estimate_density_from_patterns(style_tactics)
|
|
98
98
|
|
|
99
|
+
# BUG-B50 fix: the old code hardcoded loudness_posture=0.0 and
|
|
100
|
+
# empty spectral_contour / width_depth because StyleTactic entries
|
|
101
|
+
# don't carry those fields. We now derive them heuristically from
|
|
102
|
+
# the device_chain (each device's params leak its intended sonic
|
|
103
|
+
# shape — e.g., Auto Filter at 800Hz low-pass = darker spectrum,
|
|
104
|
+
# Utility Width > 0.6 = wider stereo). Rough but non-empty values
|
|
105
|
+
# are better than zeros for downstream reference-gap analysis.
|
|
106
|
+
loudness_posture = _derive_loudness_posture(style_tactics)
|
|
107
|
+
spectral_contour = _derive_spectral_contour(style_tactics)
|
|
108
|
+
width_depth = _derive_width_depth(style_tactics)
|
|
109
|
+
|
|
99
110
|
return ReferenceProfile(
|
|
100
111
|
source_type="style",
|
|
101
|
-
loudness_posture=
|
|
102
|
-
spectral_contour=
|
|
103
|
-
width_depth=
|
|
112
|
+
loudness_posture=loudness_posture,
|
|
113
|
+
spectral_contour=spectral_contour,
|
|
114
|
+
width_depth=width_depth,
|
|
104
115
|
density_arc=density_arc,
|
|
105
116
|
section_pacing=section_pacing,
|
|
106
117
|
harmonic_character=harmonic_character,
|
|
@@ -147,3 +158,118 @@ def _estimate_density_from_patterns(style_tactics: list[dict]) -> list[float]:
|
|
|
147
158
|
densities.append(min(0.9, 0.3 + n * 0.15))
|
|
148
159
|
|
|
149
160
|
return densities
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── BUG-B50 derivations — style → loudness/spectral/width heuristics ──────
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _derive_loudness_posture(style_tactics: list[dict]) -> float:
|
|
167
|
+
"""Heuristic: compression / saturation / limiter devices imply a
|
|
168
|
+
specific loudness posture. Glue / Ratio>3 → loud, saturator drive
|
|
169
|
+
high → cranked/loud, delay/reverb-heavy → quieter mix."""
|
|
170
|
+
loud_signals = 0
|
|
171
|
+
quiet_signals = 0
|
|
172
|
+
for tactic in style_tactics:
|
|
173
|
+
for dev in tactic.get("device_chain", []):
|
|
174
|
+
name = str(dev.get("name", "")).lower()
|
|
175
|
+
params = dev.get("params", {}) or {}
|
|
176
|
+
if "glue" in name or "limiter" in name:
|
|
177
|
+
loud_signals += 2
|
|
178
|
+
if name == "saturator" or "overdrive" in name:
|
|
179
|
+
drive = float(params.get("Drive", 0) or 0)
|
|
180
|
+
if drive >= 6:
|
|
181
|
+
loud_signals += 1
|
|
182
|
+
if name == "compressor":
|
|
183
|
+
ratio = float(params.get("Ratio", 0) or 0)
|
|
184
|
+
if ratio >= 4:
|
|
185
|
+
loud_signals += 1
|
|
186
|
+
if name == "reverb":
|
|
187
|
+
# Heavy reverb tails push the mix quieter
|
|
188
|
+
wet = float(params.get("Dry/Wet", 0) or 0)
|
|
189
|
+
if wet >= 0.6:
|
|
190
|
+
quiet_signals += 1
|
|
191
|
+
# Normalize to -1..+1 range (loud=+1, quiet=-1, neutral=0)
|
|
192
|
+
if loud_signals == 0 and quiet_signals == 0:
|
|
193
|
+
return 0.0
|
|
194
|
+
net = (loud_signals - quiet_signals) / max(
|
|
195
|
+
loud_signals + quiet_signals, 1
|
|
196
|
+
)
|
|
197
|
+
return round(net, 2)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _derive_spectral_contour(style_tactics: list[dict]) -> dict:
|
|
201
|
+
"""Heuristic: Auto Filter frequencies + device names leak the
|
|
202
|
+
target spectrum. Low-pass filters → dark, EQ Eight with no params
|
|
203
|
+
→ neutral, saturators → mid-forward."""
|
|
204
|
+
brightness = 0.5 # neutral starting point
|
|
205
|
+
mid_emphasis = 0.5
|
|
206
|
+
dark_hits = 0
|
|
207
|
+
bright_hits = 0
|
|
208
|
+
for tactic in style_tactics:
|
|
209
|
+
for dev in tactic.get("device_chain", []):
|
|
210
|
+
name = str(dev.get("name", "")).lower()
|
|
211
|
+
params = dev.get("params", {}) or {}
|
|
212
|
+
if "auto filter" in name or name == "autofilter":
|
|
213
|
+
freq = float(params.get("Frequency", 1500) or 1500)
|
|
214
|
+
# Ableton Auto Filter Frequency is 20-22000; below 2k hints dark
|
|
215
|
+
if freq < 2000:
|
|
216
|
+
dark_hits += 1
|
|
217
|
+
elif freq > 5000:
|
|
218
|
+
bright_hits += 1
|
|
219
|
+
if "saturator" in name or "overdrive" in name:
|
|
220
|
+
mid_emphasis += 0.1
|
|
221
|
+
if dark_hits > bright_hits:
|
|
222
|
+
brightness = 0.3
|
|
223
|
+
elif bright_hits > dark_hits:
|
|
224
|
+
brightness = 0.75
|
|
225
|
+
return {
|
|
226
|
+
"band_balance": {
|
|
227
|
+
"sub": 0.45,
|
|
228
|
+
"low": 0.5,
|
|
229
|
+
"mid": min(1.0, mid_emphasis),
|
|
230
|
+
"high_mid": round(0.3 + brightness * 0.4, 2),
|
|
231
|
+
"high": round(0.2 + brightness * 0.5, 2),
|
|
232
|
+
},
|
|
233
|
+
"brightness": round(brightness, 2),
|
|
234
|
+
"centroid_hint": "dark" if brightness < 0.4
|
|
235
|
+
else "bright" if brightness > 0.65
|
|
236
|
+
else "neutral",
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _derive_width_depth(style_tactics: list[dict]) -> dict:
|
|
241
|
+
"""Heuristic: Utility Width, Chorus-Ensemble, or heavy reverb
|
|
242
|
+
implies a wider stereo field; dry chains imply narrow."""
|
|
243
|
+
width_value = 0.5
|
|
244
|
+
depth_value = 0.5
|
|
245
|
+
for tactic in style_tactics:
|
|
246
|
+
for dev in tactic.get("device_chain", []):
|
|
247
|
+
name = str(dev.get("name", "")).lower()
|
|
248
|
+
params = dev.get("params", {}) or {}
|
|
249
|
+
if name == "utility":
|
|
250
|
+
w = params.get("Width")
|
|
251
|
+
if w is not None:
|
|
252
|
+
try:
|
|
253
|
+
width_value = max(width_value, float(w))
|
|
254
|
+
except (TypeError, ValueError):
|
|
255
|
+
pass
|
|
256
|
+
if "reverb" in name:
|
|
257
|
+
wet = float(params.get("Dry/Wet", 0) or 0)
|
|
258
|
+
if wet > 0.4:
|
|
259
|
+
depth_value = max(depth_value, 0.7)
|
|
260
|
+
width_value = max(width_value, 0.65)
|
|
261
|
+
if "chorus" in name or "ensemble" in name:
|
|
262
|
+
width_value = max(width_value, 0.75)
|
|
263
|
+
if "delay" in name:
|
|
264
|
+
wet = float(params.get("Dry/Wet", 0) or 0)
|
|
265
|
+
if wet > 0.2:
|
|
266
|
+
depth_value = max(depth_value, 0.6)
|
|
267
|
+
return {
|
|
268
|
+
"stereo_width": round(width_value, 2),
|
|
269
|
+
"depth": round(depth_value, 2),
|
|
270
|
+
"depth_hint": (
|
|
271
|
+
"deep" if depth_value > 0.6 else
|
|
272
|
+
"intimate" if depth_value < 0.4 else
|
|
273
|
+
"neutral"
|
|
274
|
+
),
|
|
275
|
+
}
|
|
@@ -30,6 +30,20 @@ def _fetch_comparison_data(ctx: Context, mix_path: str, reference_path: str) ->
|
|
|
30
30
|
return compare_to_reference(mix_path, reference_path, normalize=True)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _fetch_memory_tactics(style: str) -> list[dict]:
|
|
34
|
+
"""BUG-B19: pull user-saved techniques tagged with *style* so they
|
|
35
|
+
feed into the reference-profile lookup path. Best-effort — returns
|
|
36
|
+
empty list when the memory store isn't available."""
|
|
37
|
+
if not style:
|
|
38
|
+
return []
|
|
39
|
+
try:
|
|
40
|
+
from ..tools.research import _memory_store
|
|
41
|
+
return _memory_store.search(query=style, limit=10)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
logger.debug("_fetch_memory_tactics failed for %r: %s", style, exc)
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
|
|
33
47
|
def _fetch_project_snapshot(ctx: Context) -> dict:
|
|
34
48
|
"""Build a lightweight project snapshot for gap analysis."""
|
|
35
49
|
ableton = ctx.lifespan_context["ableton"]
|
|
@@ -110,10 +124,21 @@ def build_reference_profile(
|
|
|
110
124
|
return profile.to_dict()
|
|
111
125
|
|
|
112
126
|
if style:
|
|
113
|
-
tactics
|
|
127
|
+
# BUG-B19 fix: also pass user-memory tactics so custom styles
|
|
128
|
+
# (e.g. "prefuse73" saved via memory_learn) resolve to real
|
|
129
|
+
# profiles instead of NOT_FOUND. Silently ignore if memory
|
|
130
|
+
# isn't available (keeps the built-in path working).
|
|
131
|
+
memory_tactics = _fetch_memory_tactics(style)
|
|
132
|
+
tactics = get_style_tactics(style, memory_tactics=memory_tactics)
|
|
114
133
|
if not tactics:
|
|
115
134
|
return {
|
|
116
|
-
"error":
|
|
135
|
+
"error": (
|
|
136
|
+
f"No style tactics found for '{style}' — neither in the "
|
|
137
|
+
f"built-in library (burial / daft punk / techno / ambient / "
|
|
138
|
+
f"trap / lo-fi) nor in your saved memories. Use "
|
|
139
|
+
f"memory_learn to save a '{style}' technique or try "
|
|
140
|
+
f"reference_path=<audio-file> for an audio-based profile."
|
|
141
|
+
),
|
|
117
142
|
"code": "NOT_FOUND",
|
|
118
143
|
}
|
|
119
144
|
tactic_dicts = [t.to_dict() for t in tactics]
|
|
@@ -158,9 +183,17 @@ def analyze_reference_gaps(
|
|
|
158
183
|
return comparison
|
|
159
184
|
profile = build_audio_reference_profile(comparison)
|
|
160
185
|
elif style:
|
|
161
|
-
|
|
186
|
+
# BUG-B19: hydrate from saved memories too
|
|
187
|
+
memory_tactics = _fetch_memory_tactics(style)
|
|
188
|
+
tactics = get_style_tactics(style, memory_tactics=memory_tactics)
|
|
162
189
|
if not tactics:
|
|
163
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
"error": (
|
|
192
|
+
f"No style tactics found for '{style}' — neither in the "
|
|
193
|
+
f"built-in library nor in saved memories."
|
|
194
|
+
),
|
|
195
|
+
"code": "NOT_FOUND",
|
|
196
|
+
}
|
|
164
197
|
tactic_dicts = [t.to_dict() for t in tactics]
|
|
165
198
|
profile = build_style_reference_profile(tactic_dicts)
|
|
166
199
|
else:
|
|
@@ -212,9 +245,17 @@ def plan_reference_moves(
|
|
|
212
245
|
return comparison
|
|
213
246
|
profile = build_audio_reference_profile(comparison)
|
|
214
247
|
elif style:
|
|
215
|
-
|
|
248
|
+
# BUG-B19: hydrate from saved memories too
|
|
249
|
+
memory_tactics = _fetch_memory_tactics(style)
|
|
250
|
+
tactics = get_style_tactics(style, memory_tactics=memory_tactics)
|
|
216
251
|
if not tactics:
|
|
217
|
-
return {
|
|
252
|
+
return {
|
|
253
|
+
"error": (
|
|
254
|
+
f"No style tactics found for '{style}' — neither in the "
|
|
255
|
+
f"built-in library nor in saved memories."
|
|
256
|
+
),
|
|
257
|
+
"code": "NOT_FOUND",
|
|
258
|
+
}
|
|
218
259
|
tactic_dicts = [t.to_dict() for t in tactics]
|
|
219
260
|
profile = build_style_reference_profile(tactic_dicts)
|
|
220
261
|
else:
|
|
@@ -50,6 +50,56 @@ MCP_TOOLS: frozenset[str] = frozenset({
|
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
# Tools that observe session state without mutating it. Executors use this set
|
|
54
|
+
# to separate "apply pass" steps (writes) from "verification reads". Counting
|
|
55
|
+
# reads toward applied_count and then calling undo that many times walks back
|
|
56
|
+
# earlier user edits — see preview_studio/tools.py and experiment/engine.py.
|
|
57
|
+
READ_ONLY_TOOLS: frozenset[str] = frozenset({
|
|
58
|
+
"get_track_meters",
|
|
59
|
+
"get_master_spectrum",
|
|
60
|
+
"get_master_meters",
|
|
61
|
+
"get_master_rms",
|
|
62
|
+
"get_mix_snapshot",
|
|
63
|
+
"get_session_info",
|
|
64
|
+
"get_track_info",
|
|
65
|
+
"get_track_routing",
|
|
66
|
+
"get_return_tracks",
|
|
67
|
+
"get_device_info",
|
|
68
|
+
"get_device_parameters",
|
|
69
|
+
"get_clip_info",
|
|
70
|
+
"get_notes",
|
|
71
|
+
"get_arrangement_notes",
|
|
72
|
+
"get_arrangement_clips",
|
|
73
|
+
"get_scenes_info",
|
|
74
|
+
"get_scene_matrix",
|
|
75
|
+
"get_playing_clips",
|
|
76
|
+
"get_cue_points",
|
|
77
|
+
"get_rack_chains",
|
|
78
|
+
"get_clip_automation",
|
|
79
|
+
"analyze_mix",
|
|
80
|
+
"get_emotional_arc",
|
|
81
|
+
"get_motif_graph",
|
|
82
|
+
"get_session_diagnostics",
|
|
83
|
+
"ping",
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def filter_apply_steps(steps: list) -> list:
|
|
88
|
+
"""Return only the steps that mutate session state.
|
|
89
|
+
|
|
90
|
+
Read-only steps (meters, spectrum, info) do not create undo points in
|
|
91
|
+
Ableton. Including them in an applied_count and then undoing that many
|
|
92
|
+
times walks back earlier user edits. Always filter writes from reads
|
|
93
|
+
before the apply pass and before the undo loop.
|
|
94
|
+
"""
|
|
95
|
+
out = []
|
|
96
|
+
for s in steps:
|
|
97
|
+
tool = (s.get("tool") if isinstance(s, dict) else getattr(s, "tool", "")) or ""
|
|
98
|
+
if tool and tool not in READ_ONLY_TOOLS:
|
|
99
|
+
out.append(s)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
53
103
|
@dataclass
|
|
54
104
|
class ExecutionResult:
|
|
55
105
|
"""Result of executing a single plan step."""
|
|
@@ -276,8 +326,22 @@ async def execute_plan_steps_async(
|
|
|
276
326
|
results.append(result)
|
|
277
327
|
|
|
278
328
|
# Record successful step result for future bindings
|
|
279
|
-
if result.ok and step_id
|
|
280
|
-
|
|
329
|
+
if result.ok and step_id:
|
|
330
|
+
if isinstance(result.result, dict):
|
|
331
|
+
step_results[step_id] = result.result
|
|
332
|
+
else:
|
|
333
|
+
# Log but DO NOT silently drop the binding without telling
|
|
334
|
+
# anyone — the previous version let non-dict results slip
|
|
335
|
+
# past, which meant any downstream {"$from_step": step_id}
|
|
336
|
+
# reference blew up with a confusing "step_id not found"
|
|
337
|
+
# instead of the real "result wasn't a dict" cause.
|
|
338
|
+
import logging as _logging
|
|
339
|
+
_logging.getLogger(__name__).warning(
|
|
340
|
+
"step_results: dropping non-dict result for "
|
|
341
|
+
"step_id=%s tool=%s type=%s. Any $from_step refs to "
|
|
342
|
+
"this step_id will fail with 'step_id not found'.",
|
|
343
|
+
step_id, tool, type(result.result).__name__,
|
|
344
|
+
)
|
|
281
345
|
|
|
282
346
|
if not result.ok and stop_on_failure:
|
|
283
347
|
break
|
|
@@ -14,18 +14,33 @@ To add a new in-process tool to plans:
|
|
|
14
14
|
2. Add an _adapter function here that imports the real implementation and
|
|
15
15
|
adapts its kwargs from a plan-style params dict.
|
|
16
16
|
3. Register the adapter in build_mcp_dispatch_registry.
|
|
17
|
+
|
|
18
|
+
Every entry in MCP_TOOLS must have a matching adapter here — the contract
|
|
19
|
+
test tests/test_mcp_dispatch_contract.py enforces this.
|
|
17
20
|
"""
|
|
18
21
|
|
|
19
22
|
from __future__ import annotations
|
|
20
23
|
|
|
24
|
+
import inspect
|
|
21
25
|
from typing import Any, Callable
|
|
22
26
|
|
|
23
27
|
|
|
24
|
-
async def
|
|
25
|
-
"""
|
|
28
|
+
async def _call(fn, ctx, params: dict) -> Any:
|
|
29
|
+
"""Call an MCP tool with ctx + kwargs from a plan params dict.
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
Filters params to the tool's declared parameters, awaits if coroutine.
|
|
32
|
+
Unknown params are dropped silently — plans may carry extra metadata.
|
|
28
33
|
"""
|
|
34
|
+
sig = inspect.signature(fn)
|
|
35
|
+
accepted = set(sig.parameters.keys())
|
|
36
|
+
kwargs = {k: v for k, v in params.items() if k in accepted}
|
|
37
|
+
result = fn(ctx, **kwargs)
|
|
38
|
+
if inspect.isawaitable(result):
|
|
39
|
+
result = await result
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
|
|
29
44
|
from ..tools.analyzer import load_sample_to_simpler
|
|
30
45
|
return await load_sample_to_simpler(
|
|
31
46
|
ctx,
|
|
@@ -35,12 +50,69 @@ async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
|
|
|
35
50
|
)
|
|
36
51
|
|
|
37
52
|
|
|
53
|
+
async def _apply_automation_shape(params: dict, ctx: Any = None) -> dict:
|
|
54
|
+
from ..tools.automation import apply_automation_shape
|
|
55
|
+
return await _call(apply_automation_shape, ctx, params)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _apply_gesture_template(params: dict, ctx: Any = None) -> dict:
|
|
59
|
+
from ..tools.composition import apply_gesture_template
|
|
60
|
+
return await _call(apply_gesture_template, ctx, params)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _analyze_mix(params: dict, ctx: Any = None) -> dict:
|
|
64
|
+
from ..mix_engine.tools import analyze_mix
|
|
65
|
+
return await _call(analyze_mix, ctx, params)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _get_master_spectrum(params: dict, ctx: Any = None) -> dict:
|
|
69
|
+
from ..tools.analyzer import get_master_spectrum
|
|
70
|
+
return await _call(get_master_spectrum, ctx, params)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _get_emotional_arc(params: dict, ctx: Any = None) -> dict:
|
|
74
|
+
from ..tools.research import get_emotional_arc
|
|
75
|
+
return await _call(get_emotional_arc, ctx, params)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _get_motif_graph(params: dict, ctx: Any = None) -> dict:
|
|
79
|
+
from ..tools.motif import get_motif_graph
|
|
80
|
+
return await _call(get_motif_graph, ctx, params)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _generate_m4l_effect(params: dict, ctx: Any = None) -> dict:
|
|
84
|
+
from ..device_forge.tools import generate_m4l_effect
|
|
85
|
+
return await _call(generate_m4l_effect, ctx, params)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _install_m4l_device(params: dict, ctx: Any = None) -> dict:
|
|
89
|
+
from ..device_forge.tools import install_m4l_device
|
|
90
|
+
return await _call(install_m4l_device, ctx, params)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _list_genexpr_templates(params: dict, ctx: Any = None) -> dict:
|
|
94
|
+
from ..device_forge.tools import list_genexpr_templates
|
|
95
|
+
return await _call(list_genexpr_templates, ctx, params)
|
|
96
|
+
|
|
97
|
+
|
|
38
98
|
def build_mcp_dispatch_registry() -> dict[str, Callable]:
|
|
39
99
|
"""Return the canonical registry of MCP-only tools for plan execution.
|
|
40
100
|
|
|
41
101
|
Callers (typically the server lifespan init) should call this once and
|
|
42
102
|
pass the registry to execute_plan_steps_async via the mcp_registry kwarg.
|
|
103
|
+
|
|
104
|
+
INVARIANT: the set of keys here must equal MCP_TOOLS in execution_router.
|
|
105
|
+
Enforced by tests/test_mcp_dispatch_contract.py.
|
|
43
106
|
"""
|
|
44
107
|
return {
|
|
45
108
|
"load_sample_to_simpler": _load_sample_to_simpler,
|
|
109
|
+
"apply_automation_shape": _apply_automation_shape,
|
|
110
|
+
"apply_gesture_template": _apply_gesture_template,
|
|
111
|
+
"analyze_mix": _analyze_mix,
|
|
112
|
+
"get_master_spectrum": _get_master_spectrum,
|
|
113
|
+
"get_emotional_arc": _get_emotional_arc,
|
|
114
|
+
"get_motif_graph": _get_motif_graph,
|
|
115
|
+
"generate_m4l_effect": _generate_m4l_effect,
|
|
116
|
+
"install_m4l_device": _install_m4l_device,
|
|
117
|
+
"list_genexpr_templates": _list_genexpr_templates,
|
|
46
118
|
}
|