livepilot 1.9.21 → 1.9.23
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/.mcpbignore +40 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +47 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +47 -72
- package/bin/livepilot.js +135 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
- package/livepilot/commands/arrange.md +42 -14
- package/livepilot/commands/beat.md +68 -21
- package/livepilot/commands/evaluate.md +23 -13
- package/livepilot/commands/mix.md +35 -11
- package/livepilot/commands/perform.md +31 -19
- package/livepilot/commands/sounddesign.md +38 -17
- package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +60 -4
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
- package/livepilot/skills/livepilot-core/references/overview.md +4 -4
- package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
- package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
- package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
- package/livepilot/skills/livepilot-release/SKILL.md +15 -15
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
- package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
- package/livepilot.mcpb +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/manifest.json +91 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/creative_constraints/__init__.py +6 -0
- package/mcp_server/creative_constraints/engine.py +277 -0
- package/mcp_server/creative_constraints/models.py +75 -0
- package/mcp_server/creative_constraints/tools.py +341 -0
- package/mcp_server/experiment/__init__.py +6 -0
- package/mcp_server/experiment/engine.py +213 -0
- package/mcp_server/experiment/models.py +120 -0
- package/mcp_server/experiment/tools.py +263 -0
- package/mcp_server/hook_hunter/__init__.py +5 -0
- package/mcp_server/hook_hunter/analyzer.py +342 -0
- package/mcp_server/hook_hunter/models.py +57 -0
- package/mcp_server/hook_hunter/tools.py +586 -0
- package/mcp_server/memory/taste_graph.py +261 -0
- package/mcp_server/memory/tools.py +88 -0
- package/mcp_server/mix_engine/critics.py +2 -2
- package/mcp_server/mix_engine/models.py +1 -1
- package/mcp_server/mix_engine/state_builder.py +2 -2
- package/mcp_server/musical_intelligence/__init__.py +8 -0
- package/mcp_server/musical_intelligence/detectors.py +421 -0
- package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
- package/mcp_server/musical_intelligence/tools.py +221 -0
- package/mcp_server/preview_studio/__init__.py +5 -0
- package/mcp_server/preview_studio/engine.py +280 -0
- package/mcp_server/preview_studio/models.py +73 -0
- package/mcp_server/preview_studio/tools.py +423 -0
- package/mcp_server/runtime/session_kernel.py +96 -0
- package/mcp_server/runtime/tools.py +90 -1
- package/mcp_server/semantic_moves/__init__.py +13 -0
- package/mcp_server/semantic_moves/compiler.py +116 -0
- package/mcp_server/semantic_moves/mix_compilers.py +291 -0
- package/mcp_server/semantic_moves/mix_moves.py +157 -0
- package/mcp_server/semantic_moves/models.py +46 -0
- package/mcp_server/semantic_moves/performance_compilers.py +208 -0
- package/mcp_server/semantic_moves/performance_moves.py +81 -0
- package/mcp_server/semantic_moves/registry.py +32 -0
- package/mcp_server/semantic_moves/resolvers.py +126 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
- package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
- package/mcp_server/semantic_moves/tools.py +204 -0
- package/mcp_server/semantic_moves/transition_compilers.py +222 -0
- package/mcp_server/semantic_moves/transition_moves.py +76 -0
- package/mcp_server/server.py +10 -0
- package/mcp_server/session_continuity/__init__.py +6 -0
- package/mcp_server/session_continuity/models.py +86 -0
- package/mcp_server/session_continuity/tools.py +230 -0
- package/mcp_server/session_continuity/tracker.py +235 -0
- package/mcp_server/song_brain/__init__.py +6 -0
- package/mcp_server/song_brain/builder.py +477 -0
- package/mcp_server/song_brain/models.py +132 -0
- package/mcp_server/song_brain/tools.py +294 -0
- package/mcp_server/stuckness_detector/__init__.py +5 -0
- package/mcp_server/stuckness_detector/detector.py +400 -0
- package/mcp_server/stuckness_detector/models.py +66 -0
- package/mcp_server/stuckness_detector/tools.py +195 -0
- package/mcp_server/tools/_conductor.py +104 -6
- package/mcp_server/tools/analyzer.py +1 -1
- package/mcp_server/tools/devices.py +34 -0
- package/mcp_server/wonder_mode/__init__.py +6 -0
- package/mcp_server/wonder_mode/diagnosis.py +84 -0
- package/mcp_server/wonder_mode/engine.py +493 -0
- package/mcp_server/wonder_mode/session.py +114 -0
- package/mcp_server/wonder_mode/tools.py +285 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/browser.py +4 -1
- package/remote_script/LivePilot/devices.py +29 -0
- package/remote_script/LivePilot/tracks.py +11 -4
- package/scripts/generate_tool_catalog.py +131 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Creative Constraints data models — pure dataclasses, zero I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CONSTRAINT_MODES = [
|
|
10
|
+
"use_loaded_devices_only",
|
|
11
|
+
"no_new_tracks",
|
|
12
|
+
"subtraction_only",
|
|
13
|
+
"arrangement_only",
|
|
14
|
+
"mood_shift_without_new_fx",
|
|
15
|
+
"make_it_stranger_but_keep_the_hook",
|
|
16
|
+
"club_translation_safe",
|
|
17
|
+
"performance_safe_creative",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ConstraintSet:
|
|
23
|
+
"""A set of creative constraints for planning."""
|
|
24
|
+
|
|
25
|
+
constraints: list[str] = field(default_factory=list)
|
|
26
|
+
description: str = ""
|
|
27
|
+
reason: str = "" # why these constraints help
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
return asdict(self)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ReferencePrinciple:
|
|
35
|
+
"""A distilled principle from a reference track."""
|
|
36
|
+
|
|
37
|
+
domain: str = "" # "arrangement", "texture", "density", "width", "payoff", "emotional"
|
|
38
|
+
principle: str = "" # human-readable principle
|
|
39
|
+
value: float = 0.0 # quantified where possible
|
|
40
|
+
applicability: float = 0.5 # 0-1 how applicable to current song
|
|
41
|
+
note: str = ""
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict:
|
|
44
|
+
return asdict(self)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ReferenceDistillation:
|
|
49
|
+
"""Distilled principles from a reference, ready to apply."""
|
|
50
|
+
|
|
51
|
+
reference_id: str = ""
|
|
52
|
+
reference_description: str = ""
|
|
53
|
+
principles: list[ReferencePrinciple] = field(default_factory=list)
|
|
54
|
+
emotional_posture: str = ""
|
|
55
|
+
density_motion: str = ""
|
|
56
|
+
arrangement_patience: str = ""
|
|
57
|
+
texture_treatment: str = ""
|
|
58
|
+
foreground_background: str = ""
|
|
59
|
+
width_strategy: str = ""
|
|
60
|
+
payoff_architecture: str = ""
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
return {
|
|
64
|
+
"reference_id": self.reference_id,
|
|
65
|
+
"reference_description": self.reference_description,
|
|
66
|
+
"principles": [p.to_dict() for p in self.principles],
|
|
67
|
+
"emotional_posture": self.emotional_posture,
|
|
68
|
+
"density_motion": self.density_motion,
|
|
69
|
+
"arrangement_patience": self.arrangement_patience,
|
|
70
|
+
"texture_treatment": self.texture_treatment,
|
|
71
|
+
"foreground_background": self.foreground_background,
|
|
72
|
+
"width_strategy": self.width_strategy,
|
|
73
|
+
"payoff_architecture": self.payoff_architecture,
|
|
74
|
+
"principle_count": len(self.principles),
|
|
75
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Creative Constraints MCP tools — 5 tools for constrained creativity
|
|
2
|
+
and reference distillation.
|
|
3
|
+
|
|
4
|
+
apply_creative_constraint_set — activate creative constraints
|
|
5
|
+
distill_reference_principles — learn principles from a reference
|
|
6
|
+
map_reference_principles_to_song — translate reference into current song
|
|
7
|
+
generate_constrained_variants — generate triptych variants under constraints
|
|
8
|
+
generate_reference_inspired_variants — variants from reference principles
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from fastmcp import Context
|
|
16
|
+
|
|
17
|
+
from ..server import mcp
|
|
18
|
+
from . import engine
|
|
19
|
+
from .models import CONSTRAINT_MODES
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Module-level cache for active constraints and distillations
|
|
23
|
+
_active_constraints: Optional[engine.ConstraintSet] = None
|
|
24
|
+
_cached_distillation: Optional[engine.ReferenceDistillation] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@mcp.tool()
|
|
28
|
+
def apply_creative_constraint_set(
|
|
29
|
+
ctx: Context,
|
|
30
|
+
constraints: list[str] | None = None,
|
|
31
|
+
) -> dict:
|
|
32
|
+
"""Apply creative constraints to focus suggestions.
|
|
33
|
+
|
|
34
|
+
Constraints modify planning and ranking, not just validation.
|
|
35
|
+
When stuck, try adding constraints instead of more unconstrained advice.
|
|
36
|
+
|
|
37
|
+
Available constraints:
|
|
38
|
+
- use_loaded_devices_only — only use what's already loaded
|
|
39
|
+
- no_new_tracks — work within existing tracks
|
|
40
|
+
- subtraction_only — only remove/reduce, no additions
|
|
41
|
+
- arrangement_only — only structural changes
|
|
42
|
+
- mood_shift_without_new_fx — shift mood with existing tools
|
|
43
|
+
- make_it_stranger_but_keep_the_hook — push novelty safely
|
|
44
|
+
- club_translation_safe — keep changes club/DJ-friendly
|
|
45
|
+
- performance_safe_creative — only live-safe changes
|
|
46
|
+
|
|
47
|
+
constraints: list of constraint names to activate
|
|
48
|
+
"""
|
|
49
|
+
global _active_constraints
|
|
50
|
+
|
|
51
|
+
if not constraints:
|
|
52
|
+
return {
|
|
53
|
+
"error": "No constraints provided",
|
|
54
|
+
"available": CONSTRAINT_MODES,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cs = engine.build_constraint_set(constraints)
|
|
58
|
+
_active_constraints = cs
|
|
59
|
+
|
|
60
|
+
invalid = [c for c in constraints if c not in CONSTRAINT_MODES]
|
|
61
|
+
result = {
|
|
62
|
+
"active_constraints": cs.constraints,
|
|
63
|
+
"description": cs.description,
|
|
64
|
+
"reason": cs.reason,
|
|
65
|
+
}
|
|
66
|
+
if invalid:
|
|
67
|
+
result["invalid_constraints"] = invalid
|
|
68
|
+
result["available"] = CONSTRAINT_MODES
|
|
69
|
+
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@mcp.tool()
|
|
74
|
+
def distill_reference_principles(
|
|
75
|
+
ctx: Context,
|
|
76
|
+
reference_description: str = "",
|
|
77
|
+
style_name: str = "",
|
|
78
|
+
) -> dict:
|
|
79
|
+
"""Learn musical principles from a reference — not surface traits.
|
|
80
|
+
|
|
81
|
+
Extracts: emotional posture, density motion, arrangement patience,
|
|
82
|
+
texture treatment, width strategy, and payoff architecture.
|
|
83
|
+
|
|
84
|
+
Never outputs a plan that copies surface traits directly.
|
|
85
|
+
Always translates through the current song's identity.
|
|
86
|
+
|
|
87
|
+
reference_description: text description of the reference
|
|
88
|
+
style_name: optional style/genre name for style-based references
|
|
89
|
+
"""
|
|
90
|
+
global _cached_distillation
|
|
91
|
+
|
|
92
|
+
if not reference_description.strip() and not style_name.strip():
|
|
93
|
+
return {"error": "Provide reference_description or style_name"}
|
|
94
|
+
|
|
95
|
+
# Build a reference profile from available data
|
|
96
|
+
reference_profile: dict = {}
|
|
97
|
+
|
|
98
|
+
# Try to get style tactics if style_name is provided
|
|
99
|
+
if style_name:
|
|
100
|
+
try:
|
|
101
|
+
from ..tools._research_engine import get_style_tactics
|
|
102
|
+
tactics = get_style_tactics(style_name)
|
|
103
|
+
if tactics:
|
|
104
|
+
reference_profile = {
|
|
105
|
+
"emotional_stance": tactics.get("emotional_stance", ""),
|
|
106
|
+
"density_arc": tactics.get("density_arc", []),
|
|
107
|
+
"section_pacing": tactics.get("section_pacing", []),
|
|
108
|
+
"width_depth": tactics.get("width_depth", {}),
|
|
109
|
+
"spectral_contour": tactics.get("spectral_contour", {}),
|
|
110
|
+
"groove_posture": tactics.get("groove_posture", {}),
|
|
111
|
+
"harmonic_character": tactics.get("harmonic_character", ""),
|
|
112
|
+
}
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
# Try to get a reference profile from the reference engine
|
|
117
|
+
if not reference_profile:
|
|
118
|
+
try:
|
|
119
|
+
from ..reference_engine.profile_builder import build_style_reference_profile
|
|
120
|
+
profile = build_style_reference_profile(
|
|
121
|
+
style_name or reference_description
|
|
122
|
+
)
|
|
123
|
+
reference_profile = profile.to_dict()
|
|
124
|
+
except Exception:
|
|
125
|
+
# Fallback: build from description keywords
|
|
126
|
+
reference_profile = _profile_from_description(reference_description)
|
|
127
|
+
|
|
128
|
+
distillation = engine.distill_reference_principles(
|
|
129
|
+
reference_profile=reference_profile,
|
|
130
|
+
reference_description=reference_description or style_name,
|
|
131
|
+
)
|
|
132
|
+
_cached_distillation = distillation
|
|
133
|
+
|
|
134
|
+
return distillation.to_dict()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@mcp.tool()
|
|
138
|
+
def map_reference_principles_to_song(
|
|
139
|
+
ctx: Context,
|
|
140
|
+
) -> dict:
|
|
141
|
+
"""Map distilled reference principles to the current song.
|
|
142
|
+
|
|
143
|
+
Must call distill_reference_principles first. Translates each
|
|
144
|
+
principle through the song's identity, loaded tools, and hook.
|
|
145
|
+
|
|
146
|
+
Returns actionable mappings — how to apply each principle
|
|
147
|
+
while preserving the song's own character.
|
|
148
|
+
"""
|
|
149
|
+
if _cached_distillation is None:
|
|
150
|
+
return {"error": "No reference distilled yet — call distill_reference_principles first"}
|
|
151
|
+
|
|
152
|
+
song_brain = _get_song_brain_dict()
|
|
153
|
+
|
|
154
|
+
mappings = engine.map_principles_to_song(song_brain, _cached_distillation)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"reference": _cached_distillation.reference_description,
|
|
158
|
+
"mappings": mappings,
|
|
159
|
+
"mapping_count": len(mappings),
|
|
160
|
+
"note": "Principles are adapted to your song — not copied from the reference",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@mcp.tool()
|
|
165
|
+
def generate_constrained_variants(
|
|
166
|
+
ctx: Context,
|
|
167
|
+
request_text: str,
|
|
168
|
+
constraints: list[str] | None = None,
|
|
169
|
+
kernel_id: str = "",
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""Generate creative variants under active constraints.
|
|
172
|
+
|
|
173
|
+
Combines constraint filtering with the Preview Studio's triptych.
|
|
174
|
+
Each variant respects the constraint set — e.g., "subtraction_only"
|
|
175
|
+
means no variant adds new elements.
|
|
176
|
+
|
|
177
|
+
request_text: what the user wants
|
|
178
|
+
constraints: list of constraint names to apply (or uses currently active)
|
|
179
|
+
kernel_id: optional session kernel reference
|
|
180
|
+
"""
|
|
181
|
+
if not request_text.strip():
|
|
182
|
+
return {"error": "request_text cannot be empty"}
|
|
183
|
+
|
|
184
|
+
# Apply constraints
|
|
185
|
+
active = _active_constraints
|
|
186
|
+
if constraints:
|
|
187
|
+
active = engine.build_constraint_set(constraints)
|
|
188
|
+
|
|
189
|
+
if not active or not active.constraints:
|
|
190
|
+
return {
|
|
191
|
+
"error": "No constraints active — call apply_creative_constraint_set first or provide constraints",
|
|
192
|
+
"available": CONSTRAINT_MODES,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# Generate variants via preview studio
|
|
196
|
+
try:
|
|
197
|
+
from ..preview_studio import engine as ps_engine
|
|
198
|
+
song_brain = _get_song_brain_dict()
|
|
199
|
+
taste_graph = {}
|
|
200
|
+
try:
|
|
201
|
+
from ..memory.taste_graph import build_taste_graph
|
|
202
|
+
from ..memory.taste_memory import TasteMemoryStore
|
|
203
|
+
from ..memory.anti_memory import AntiMemoryStore
|
|
204
|
+
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
205
|
+
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
206
|
+
taste_graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store).to_dict()
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
ps = ps_engine.create_preview_set(
|
|
211
|
+
request_text=f"[Constrained: {', '.join(active.constraints)}] {request_text}",
|
|
212
|
+
kernel_id=kernel_id,
|
|
213
|
+
strategy="creative_triptych",
|
|
214
|
+
song_brain=song_brain,
|
|
215
|
+
taste_graph=taste_graph,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Validate each variant's compiled_plan against constraints
|
|
219
|
+
for v in ps.variants:
|
|
220
|
+
v.what_preserved = f"{v.what_preserved} | Constraints: {', '.join(active.constraints)}"
|
|
221
|
+
if v.compiled_plan:
|
|
222
|
+
plan = {"steps": [
|
|
223
|
+
{"action": step.get("tool", ""), **step}
|
|
224
|
+
for step in v.compiled_plan
|
|
225
|
+
]}
|
|
226
|
+
validation = engine.validate_plan_against_constraints(plan, active)
|
|
227
|
+
if not validation["valid"]:
|
|
228
|
+
v.compiled_plan = None
|
|
229
|
+
v.what_changed = f"[FILTERED] {v.what_changed} — violates {', '.join(active.constraints)}"
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"preview_set": ps.to_dict(),
|
|
233
|
+
"constraints_applied": active.constraints,
|
|
234
|
+
"note": "Variants with violating plans have been filtered",
|
|
235
|
+
}
|
|
236
|
+
except Exception as e:
|
|
237
|
+
return {"error": f"Failed to generate constrained variants: {e}"}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@mcp.tool()
|
|
241
|
+
def generate_reference_inspired_variants(
|
|
242
|
+
ctx: Context,
|
|
243
|
+
request_text: str = "",
|
|
244
|
+
kernel_id: str = "",
|
|
245
|
+
) -> dict:
|
|
246
|
+
"""Generate creative variants inspired by a distilled reference.
|
|
247
|
+
|
|
248
|
+
Requires a prior call to distill_reference_principles.
|
|
249
|
+
Uses the distilled principles (not surface traits) to shape
|
|
250
|
+
each variant through the current song's identity.
|
|
251
|
+
|
|
252
|
+
request_text: optional extra context for what the user wants
|
|
253
|
+
kernel_id: optional session kernel reference
|
|
254
|
+
"""
|
|
255
|
+
if _cached_distillation is None:
|
|
256
|
+
return {"error": "No reference distilled yet — call distill_reference_principles first"}
|
|
257
|
+
|
|
258
|
+
# Build request text from reference principles
|
|
259
|
+
principles_text = ", ".join(
|
|
260
|
+
p.principle for p in _cached_distillation.principles[:3]
|
|
261
|
+
)
|
|
262
|
+
full_request = (
|
|
263
|
+
f"Inspired by: {_cached_distillation.reference_description}. "
|
|
264
|
+
f"Key principles: {principles_text}. "
|
|
265
|
+
f"{request_text}"
|
|
266
|
+
).strip()
|
|
267
|
+
|
|
268
|
+
# Generate variants via preview studio
|
|
269
|
+
try:
|
|
270
|
+
from ..preview_studio import engine as ps_engine
|
|
271
|
+
song_brain = _get_song_brain_dict()
|
|
272
|
+
|
|
273
|
+
ps = ps_engine.create_preview_set(
|
|
274
|
+
request_text=full_request,
|
|
275
|
+
kernel_id=kernel_id,
|
|
276
|
+
strategy="creative_triptych",
|
|
277
|
+
song_brain=song_brain,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Annotate variants with reference info
|
|
281
|
+
for v in ps.variants:
|
|
282
|
+
v.why_it_matters = (
|
|
283
|
+
f"Reference-inspired: {_cached_distillation.reference_description}. "
|
|
284
|
+
f"{v.why_it_matters}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
"preview_set": ps.to_dict(),
|
|
289
|
+
"reference": _cached_distillation.reference_description,
|
|
290
|
+
"principles_applied": [p.to_dict() for p in _cached_distillation.principles[:5]],
|
|
291
|
+
"note": "Variants are shaped by reference principles, not surface imitation",
|
|
292
|
+
}
|
|
293
|
+
except Exception as e:
|
|
294
|
+
return {"error": f"Failed to generate reference-inspired variants: {e}"}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ── Helpers ───────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _get_song_brain_dict() -> dict:
|
|
301
|
+
try:
|
|
302
|
+
from ..song_brain.tools import _current_brain
|
|
303
|
+
if _current_brain is not None:
|
|
304
|
+
return _current_brain.to_dict()
|
|
305
|
+
except Exception as _e:
|
|
306
|
+
if __debug__:
|
|
307
|
+
import sys
|
|
308
|
+
print(f"LivePilot: SongBrain unavailable in creative_constraints: {_e}", file=sys.stderr)
|
|
309
|
+
return {}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _profile_from_description(description: str) -> dict:
|
|
313
|
+
"""Build a rough reference profile from text description."""
|
|
314
|
+
desc_lower = description.lower()
|
|
315
|
+
|
|
316
|
+
emotional_map = {
|
|
317
|
+
"dark": "tense",
|
|
318
|
+
"bright": "euphoric",
|
|
319
|
+
"sad": "melancholic",
|
|
320
|
+
"aggressive": "aggressive",
|
|
321
|
+
"dreamy": "dreamy",
|
|
322
|
+
"chill": "relaxed",
|
|
323
|
+
"intense": "aggressive",
|
|
324
|
+
"minimal": "restrained",
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
emotional = ""
|
|
328
|
+
for keyword, stance in emotional_map.items():
|
|
329
|
+
if keyword in desc_lower:
|
|
330
|
+
emotional = stance
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
"emotional_stance": emotional,
|
|
335
|
+
"density_arc": [],
|
|
336
|
+
"section_pacing": [],
|
|
337
|
+
"width_depth": {},
|
|
338
|
+
"spectral_contour": {},
|
|
339
|
+
"groove_posture": {},
|
|
340
|
+
"harmonic_character": "",
|
|
341
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Experiment engine — runs branches sequentially using Ableton's undo system.
|
|
2
|
+
|
|
3
|
+
The engine manages the lifecycle: create branches from semantic moves,
|
|
4
|
+
run each one (apply → capture → undo), evaluate, rank, and commit the winner.
|
|
5
|
+
|
|
6
|
+
Critical constraint: Ableton has linear undo. Experiments MUST run sequentially:
|
|
7
|
+
1. Capture before state
|
|
8
|
+
2. Apply semantic move (compiled plan)
|
|
9
|
+
3. Capture after state
|
|
10
|
+
4. Undo all changes back to the checkpoint
|
|
11
|
+
5. Repeat for next branch
|
|
12
|
+
6. When winner is chosen, re-apply that branch's moves permanently
|
|
13
|
+
|
|
14
|
+
All I/O happens through the AbletonConnection passed to run methods.
|
|
15
|
+
The engine itself is pure orchestration logic.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import time
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from .models import ExperimentSet, ExperimentBranch, BranchSnapshot
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── In-memory experiment store ───────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
_EXPERIMENTS: dict[str, ExperimentSet] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _gen_id(prefix: str, seed: str) -> str:
|
|
33
|
+
"""Generate a short deterministic ID."""
|
|
34
|
+
h = hashlib.sha256(f"{prefix}:{seed}:{time.time()}".encode()).hexdigest()[:8]
|
|
35
|
+
return f"{prefix}_{h}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Create experiments ───────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def create_experiment(
|
|
41
|
+
request_text: str,
|
|
42
|
+
move_ids: list[str],
|
|
43
|
+
kernel_id: str = "",
|
|
44
|
+
) -> ExperimentSet:
|
|
45
|
+
"""Create an experiment set with branches for each semantic move.
|
|
46
|
+
|
|
47
|
+
Does NOT execute anything — just creates the branch structures.
|
|
48
|
+
Call run_experiment() to actually trial each branch.
|
|
49
|
+
"""
|
|
50
|
+
exp_id = _gen_id("exp", request_text)
|
|
51
|
+
now = int(time.time() * 1000)
|
|
52
|
+
|
|
53
|
+
branches = []
|
|
54
|
+
for i, move_id in enumerate(move_ids):
|
|
55
|
+
branch = ExperimentBranch(
|
|
56
|
+
branch_id=_gen_id("br", f"{move_id}_{i}"),
|
|
57
|
+
name=f"Branch {i+1}: {move_id}",
|
|
58
|
+
move_id=move_id,
|
|
59
|
+
source_kernel_id=kernel_id,
|
|
60
|
+
status="pending",
|
|
61
|
+
created_at_ms=now,
|
|
62
|
+
)
|
|
63
|
+
branches.append(branch)
|
|
64
|
+
|
|
65
|
+
experiment = ExperimentSet(
|
|
66
|
+
experiment_id=exp_id,
|
|
67
|
+
request_text=request_text,
|
|
68
|
+
branches=branches,
|
|
69
|
+
status="open",
|
|
70
|
+
created_at_ms=now,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
_EXPERIMENTS[exp_id] = experiment
|
|
74
|
+
return experiment
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_experiment(experiment_id: str) -> Optional[ExperimentSet]:
|
|
78
|
+
"""Get an experiment by ID."""
|
|
79
|
+
return _EXPERIMENTS.get(experiment_id)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def list_experiments() -> list[dict]:
|
|
83
|
+
"""List all experiment sets."""
|
|
84
|
+
return [exp.to_dict() for exp in _EXPERIMENTS.values()]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Run experiments (requires Ableton connection) ────────────────────────────
|
|
88
|
+
|
|
89
|
+
def run_branch(
|
|
90
|
+
branch: ExperimentBranch,
|
|
91
|
+
ableton, # AbletonConnection
|
|
92
|
+
compiled_plan: dict,
|
|
93
|
+
capture_fn, # function() -> BranchSnapshot
|
|
94
|
+
) -> ExperimentBranch:
|
|
95
|
+
"""Run a single branch experiment.
|
|
96
|
+
|
|
97
|
+
1. Capture before state
|
|
98
|
+
2. Execute compiled plan steps
|
|
99
|
+
3. Capture after state
|
|
100
|
+
4. Undo all changes
|
|
101
|
+
|
|
102
|
+
The branch is updated in-place with snapshots and status.
|
|
103
|
+
"""
|
|
104
|
+
branch.status = "running"
|
|
105
|
+
branch.compiled_plan = compiled_plan
|
|
106
|
+
|
|
107
|
+
# 1. Capture before
|
|
108
|
+
branch.before_snapshot = capture_fn()
|
|
109
|
+
|
|
110
|
+
# 2. Execute plan steps
|
|
111
|
+
steps_executed = 0
|
|
112
|
+
for step in compiled_plan.get("steps", []):
|
|
113
|
+
tool = step.get("tool", "")
|
|
114
|
+
params = step.get("params", {})
|
|
115
|
+
if not tool:
|
|
116
|
+
continue
|
|
117
|
+
# Skip read-only verification steps
|
|
118
|
+
if tool in ("get_track_meters", "get_master_spectrum", "analyze_mix"):
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
ableton.send_command(tool, params)
|
|
122
|
+
steps_executed += 1
|
|
123
|
+
except Exception:
|
|
124
|
+
pass # Best effort — continue with remaining steps
|
|
125
|
+
|
|
126
|
+
branch.executed_at_ms = int(time.time() * 1000)
|
|
127
|
+
|
|
128
|
+
# 3. Capture after
|
|
129
|
+
branch.after_snapshot = capture_fn()
|
|
130
|
+
|
|
131
|
+
# 4. Undo all changes back to checkpoint
|
|
132
|
+
for _ in range(steps_executed):
|
|
133
|
+
try:
|
|
134
|
+
ableton.send_command("undo", {})
|
|
135
|
+
except Exception:
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
branch.status = "evaluated"
|
|
139
|
+
return branch
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def evaluate_branch(
|
|
143
|
+
branch: ExperimentBranch,
|
|
144
|
+
evaluate_fn, # function(before, after) -> dict with "score", "keep_change"
|
|
145
|
+
) -> ExperimentBranch:
|
|
146
|
+
"""Score a branch using the evaluation fabric."""
|
|
147
|
+
if not branch.before_snapshot or not branch.after_snapshot:
|
|
148
|
+
branch.evaluation = {"error": "Missing snapshots"}
|
|
149
|
+
branch.score = 0.0
|
|
150
|
+
return branch
|
|
151
|
+
|
|
152
|
+
result = evaluate_fn(
|
|
153
|
+
branch.before_snapshot.to_dict(),
|
|
154
|
+
branch.after_snapshot.to_dict(),
|
|
155
|
+
)
|
|
156
|
+
branch.evaluation = result
|
|
157
|
+
branch.score = result.get("score", 0.0)
|
|
158
|
+
return branch
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── Commit / discard ─────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def commit_branch(
|
|
164
|
+
experiment: ExperimentSet,
|
|
165
|
+
branch_id: str,
|
|
166
|
+
ableton,
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""Re-apply the winning branch's moves permanently."""
|
|
169
|
+
branch = experiment.get_branch(branch_id)
|
|
170
|
+
if not branch:
|
|
171
|
+
return {"error": f"Branch {branch_id} not found"}
|
|
172
|
+
|
|
173
|
+
if not branch.compiled_plan:
|
|
174
|
+
return {"error": "Branch has no compiled plan"}
|
|
175
|
+
|
|
176
|
+
# Re-execute the plan (this time without undoing)
|
|
177
|
+
executed = []
|
|
178
|
+
for step in branch.compiled_plan.get("steps", []):
|
|
179
|
+
tool = step.get("tool", "")
|
|
180
|
+
params = step.get("params", {})
|
|
181
|
+
if not tool or tool in ("get_track_meters", "get_master_spectrum", "analyze_mix"):
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
result = ableton.send_command(tool, params)
|
|
185
|
+
executed.append({"tool": tool, "ok": True})
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
executed.append({"tool": tool, "ok": False, "error": str(exc)})
|
|
188
|
+
|
|
189
|
+
branch.status = "committed"
|
|
190
|
+
experiment.winner_branch_id = branch_id
|
|
191
|
+
experiment.status = "committed"
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
"committed": True,
|
|
195
|
+
"branch_id": branch_id,
|
|
196
|
+
"branch_name": branch.name,
|
|
197
|
+
"steps_executed": len(executed),
|
|
198
|
+
"score": branch.score,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def discard_experiment(experiment_id: str) -> dict:
|
|
203
|
+
"""Discard an entire experiment set."""
|
|
204
|
+
exp = _EXPERIMENTS.get(experiment_id)
|
|
205
|
+
if not exp:
|
|
206
|
+
return {"error": f"Experiment {experiment_id} not found"}
|
|
207
|
+
|
|
208
|
+
for branch in exp.branches:
|
|
209
|
+
if branch.status not in ("committed", "discarded"):
|
|
210
|
+
branch.status = "discarded"
|
|
211
|
+
exp.status = "discarded"
|
|
212
|
+
|
|
213
|
+
return {"discarded": True, "experiment_id": experiment_id}
|