livepilot 1.23.6 → 1.24.0

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +59 -13
  3. package/mcp_server/__init__.py +1 -1
  4. package/mcp_server/atlas/__init__.py +17 -3
  5. package/mcp_server/audit/__init__.py +6 -0
  6. package/mcp_server/audit/checks.py +618 -0
  7. package/mcp_server/audit/tools.py +232 -0
  8. package/mcp_server/composer/branch_producer.py +5 -2
  9. package/mcp_server/composer/develop/__init__.py +19 -0
  10. package/mcp_server/composer/develop/apply.py +217 -0
  11. package/mcp_server/composer/develop/brief_builder.py +269 -0
  12. package/mcp_server/composer/develop/seed_introspector.py +195 -0
  13. package/mcp_server/composer/engine.py +15 -521
  14. package/mcp_server/composer/fast/__init__.py +62 -0
  15. package/mcp_server/composer/fast/apply.py +533 -0
  16. package/mcp_server/composer/fast/brief_builder.py +1479 -0
  17. package/mcp_server/composer/fast/tier_classification.py +159 -0
  18. package/mcp_server/composer/framework/__init__.py +0 -0
  19. package/mcp_server/composer/framework/applier.py +179 -0
  20. package/mcp_server/composer/framework/artist_loader.py +63 -0
  21. package/mcp_server/composer/framework/brief.py +79 -0
  22. package/mcp_server/composer/framework/event_lexicon.py +71 -0
  23. package/mcp_server/composer/framework/genre_loader.py +77 -0
  24. package/mcp_server/composer/framework/intent_source.py +137 -0
  25. package/mcp_server/composer/framework/knowledge_pack.py +49 -0
  26. package/mcp_server/composer/framework/plan_compiler.py +10 -0
  27. package/mcp_server/composer/full/__init__.py +10 -0
  28. package/mcp_server/composer/full/apply.py +1139 -0
  29. package/mcp_server/composer/full/brief_builder.py +144 -0
  30. package/mcp_server/composer/full/engine.py +541 -0
  31. package/mcp_server/composer/full/layer_planner.py +491 -0
  32. package/mcp_server/composer/layer_planner.py +19 -465
  33. package/mcp_server/composer/sample_resolver.py +80 -7
  34. package/mcp_server/composer/tools.py +626 -28
  35. package/mcp_server/server.py +1 -0
  36. package/mcp_server/splice_client/client.py +7 -0
  37. package/mcp_server/tools/_analyzer_engine/sample.py +162 -6
  38. package/mcp_server/tools/_planner_engine.py +25 -63
  39. package/mcp_server/tools/analyzer.py +10 -4
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/server.json +3 -3
@@ -0,0 +1,232 @@
1
+ """audit_layer — single-tool replacement for the §5 layer-precision checklist.
2
+
3
+ Fetches once, runs 8 server-side checks, returns a structured report with
4
+ ranked fixes. Replaces the manual sequence:
5
+
6
+ solo + get_master_spectrum
7
+ get_notes (per clip) + sequence critique
8
+ get_track_info (pan/width)
9
+ get_masking_report (filter for this track)
10
+ get_device_parameters (modulation routings)
11
+ get_device_parameters (default-detection)
12
+ get_simpler_slices + classify_simpler_slices
13
+ track_info.devices (effects coverage)
14
+
15
+ …which was 8+ tool calls of LLM-driven ceremony. Now: one call.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import time
22
+ from typing import Optional
23
+
24
+ from fastmcp import Context
25
+
26
+ from ..server import mcp
27
+ from . import checks
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def _get_ableton(ctx: Context):
33
+ return ctx.lifespan_context["ableton"]
34
+
35
+
36
+ def _safe_call(ableton, command: str, params: dict | None = None) -> dict | None:
37
+ try:
38
+ return ableton.send_command(command, params or {})
39
+ except Exception as exc:
40
+ logger.debug("audit_layer: %s failed: %s", command, exc)
41
+ return None
42
+
43
+
44
+ def _fetch_notes_for_clips(ableton, track_index: int, clip_slots: list[dict]) -> list[list[dict]]:
45
+ """Pull notes for every populated clip slot. Skips empty/audio clips."""
46
+ out: list[list[dict]] = []
47
+ for slot in clip_slots or []:
48
+ if not slot.get("has_clip"):
49
+ continue
50
+ clip_index = slot.get("index")
51
+ if clip_index is None:
52
+ continue
53
+ result = _safe_call(ableton, "get_notes", {
54
+ "track_index": track_index,
55
+ "clip_index": clip_index,
56
+ "from_pitch": 0,
57
+ "pitch_span": 128,
58
+ "from_time": 0.0,
59
+ })
60
+ if result and "notes" in result:
61
+ out.append(result["notes"])
62
+ return out
63
+
64
+
65
+ def _count_wavetable_routings(ableton, track_index: int, devices: list[dict]) -> int:
66
+ """Sum non-zero mod-matrix routings across any Wavetable on the track."""
67
+ total = 0
68
+ for i, dev in enumerate(devices or []):
69
+ if dev.get("class_name") != "Wavetable":
70
+ continue
71
+ mod = _safe_call(ableton, "get_wavetable_mod_matrix", {
72
+ "track_index": track_index,
73
+ "device_index": i,
74
+ })
75
+ if not mod:
76
+ continue
77
+ # Response shape: {"matrix": [{"source": ..., "target": ..., "amount": ...}, ...]}
78
+ entries = mod.get("matrix") or mod.get("entries") or []
79
+ for e in entries:
80
+ if abs(float(e.get("amount", 0.0))) > 0.001:
81
+ total += 1
82
+ return total
83
+
84
+
85
+ def _has_clip_automation(ableton, track_index: int, clip_slots: list[dict]) -> bool:
86
+ """Check if any clip on this track has automation envelopes."""
87
+ for slot in clip_slots or []:
88
+ if not slot.get("has_clip"):
89
+ continue
90
+ clip_index = slot.get("index")
91
+ if clip_index is None:
92
+ continue
93
+ result = _safe_call(ableton, "get_clip_automation", {
94
+ "track_index": track_index,
95
+ "clip_index": clip_index,
96
+ })
97
+ if not result:
98
+ continue
99
+ envelopes = result.get("envelopes") or result.get("automation") or []
100
+ if envelopes:
101
+ return True
102
+ return False
103
+
104
+
105
+ def _maybe_get_timbre_fingerprint(ctx: Context, track_index: int) -> dict | None:
106
+ """Pull a per-track timbre fingerprint via the synthesis_brain timbre helper.
107
+
108
+ Returns None when the M4L bridge is offline or no fingerprint is available.
109
+ Does NOT solo the track — uses cached spectral data only.
110
+ """
111
+ try:
112
+ from ..synthesis_brain.timbre import extract_timbre_fingerprint # type: ignore
113
+ except Exception:
114
+ return None
115
+ try:
116
+ result = extract_timbre_fingerprint(ctx, track_index) # type: ignore[arg-type]
117
+ if isinstance(result, dict) and result.get("bands"):
118
+ return result
119
+ except Exception as exc:
120
+ logger.debug("audit_layer timbre fetch failed: %s", exc)
121
+ return None
122
+
123
+
124
+ @mcp.tool()
125
+ def audit_layer(
126
+ ctx: Context,
127
+ track_index: int,
128
+ role: Optional[str] = None,
129
+ include_masking: bool = True,
130
+ include_timbre: bool = False,
131
+ ) -> dict:
132
+ """Run the §5 layer-precision audit on a single track in one call.
133
+
134
+ Replaces 8 manual checks (timbre, sequence, stereo, masking, modulation,
135
+ params, samples, effects) with one server-side aggregation. Returns
136
+ structured report with PASS/WARN/FAIL per check + ranked fixes.
137
+
138
+ Args:
139
+ track_index: Track to audit.
140
+ role: Optional role override ("kick"/"snare"/"hat"/"perc"/"bass"/
141
+ "pad"/"lead"/"atmos"/"vox"/"fx"). If omitted, inferred from
142
+ track name + first instrument class.
143
+ include_masking: If True (default), pulls cross-track masking report
144
+ and filters for this track. Adds ~200-600ms.
145
+ include_timbre: If True, pulls per-track timbre fingerprint via the
146
+ M4L bridge. Costs an extra spectral read; default False so the
147
+ tool stays fast on bridge-less sessions.
148
+
149
+ Returns one structured report — no follow-up calls needed.
150
+ """
151
+ started = time.time()
152
+ ableton = _get_ableton(ctx)
153
+
154
+ track_info = _safe_call(ableton, "get_track_info", {"track_index": track_index})
155
+ if not track_info:
156
+ return {
157
+ "track_index": track_index,
158
+ "error": "get_track_info failed",
159
+ "checks": {},
160
+ "recommended_fixes": [],
161
+ }
162
+
163
+ track_name = track_info.get("name", f"track_{track_index}")
164
+ devices = track_info.get("devices", []) or []
165
+ clip_slots = track_info.get("clip_slots", []) or []
166
+
167
+ # Role: caller-given or inferred
168
+ inferred_role = role or checks.infer_role(track_name, devices)
169
+
170
+ # Pull per-clip notes only when MIDI clips exist (audio tracks return [])
171
+ notes_per_clip = _fetch_notes_for_clips(ableton, track_index, clip_slots)
172
+
173
+ # Modulation signals: clip automation + wavetable mod matrix routings
174
+ automation_present = _has_clip_automation(ableton, track_index, clip_slots)
175
+ wt_routings = _count_wavetable_routings(ableton, track_index, devices)
176
+
177
+ # Optional masking — note that get_masking_report is an MCP-server-side
178
+ # tool (mix_engine.tools), NOT a Remote Script command. We import and
179
+ # call its python function directly. Going through ableton.send_command
180
+ # would dispatch to the LOM bridge which has no such handler and silently
181
+ # fails (BUG-D, caught by 4-way parallel live test 2026-05-01).
182
+ masking_report = None
183
+ if include_masking:
184
+ try:
185
+ from ..mix_engine.tools import get_masking_report as _get_masking_report_impl
186
+ masking_report = _get_masking_report_impl(ctx) # type: ignore[arg-type]
187
+ except Exception as exc:
188
+ logger.debug("audit_layer: masking fetch failed: %s", exc)
189
+ fingerprint = _maybe_get_timbre_fingerprint(ctx, track_index) if include_timbre else None
190
+
191
+ # Slice classifications only if Simpler with slices is present
192
+ slice_classes = None
193
+ if any(d.get("class_name") == "Simpler" for d in devices):
194
+ result = _safe_call(ableton, "classify_simpler_slices", {"track_index": track_index})
195
+ if result:
196
+ slice_classes = result.get("slices") or result.get("classifications")
197
+
198
+ # Run the 8 checks
199
+ check_results = {
200
+ "timbre": checks.check_timbre(inferred_role, fingerprint),
201
+ "sequence": checks.check_sequence(inferred_role, notes_per_clip),
202
+ "stereo": checks.check_stereo(inferred_role, track_info),
203
+ "masking": checks.check_masking(track_index, masking_report),
204
+ "modulation": checks.check_modulation(
205
+ inferred_role, devices, automation_present, wt_routings
206
+ ),
207
+ "params": checks.check_params(inferred_role, devices),
208
+ "samples": checks.check_samples(inferred_role, devices, slice_classes),
209
+ "effects": checks.check_effects(inferred_role, devices),
210
+ }
211
+
212
+ overall = checks.rollup_severity(check_results)
213
+ fixes = checks.rank_fixes(check_results)
214
+
215
+ duration_ms = int((time.time() - started) * 1000)
216
+
217
+ return {
218
+ "track_index": track_index,
219
+ "track_name": track_name,
220
+ "role": inferred_role,
221
+ "role_inferred": role is None,
222
+ "overall_severity": overall,
223
+ "checks": check_results,
224
+ "recommended_fixes": fixes,
225
+ "metadata": {
226
+ "duration_ms": duration_ms,
227
+ "clip_count": len([s for s in clip_slots if s.get("has_clip")]),
228
+ "device_count": len(devices),
229
+ "timbre_source": "fresh" if fingerprint else "skipped" if not include_timbre else "unavailable",
230
+ "masking_source": "fresh" if masking_report else "skipped" if not include_masking else "unavailable",
231
+ },
232
+ }
@@ -28,8 +28,8 @@ from typing import Optional
28
28
 
29
29
  from ..branches import BranchSeed, freeform_seed
30
30
  from .prompt_parser import parse_prompt, CompositionIntent
31
- from .layer_planner import plan_layers, plan_sections
32
- from .engine import ComposerEngine, CompositionResult
31
+ from .full.layer_planner import plan_layers, plan_sections
32
+ from .full.engine import ComposerEngine, CompositionResult
33
33
 
34
34
 
35
35
  # Strategy registry — each function takes an intent and returns (modified
@@ -302,6 +302,9 @@ def _build_section_hypothesis_plan(intent: CompositionIntent, strategy_name: str
302
302
 
303
303
  Returns a dict with {"steps", "step_count", "summary"}.
304
304
  """
305
+ # v1.24: SECTION_TEMPLATES removed per vocabulary-not-form principle (Task 12).
306
+ # plan_layers and plan_sections will raise until Task 14 rewires this.
307
+ # DEPRECATED in v1.24.
305
308
  layers = plan_layers(intent)
306
309
  sections = plan_sections(intent)
307
310
 
@@ -0,0 +1,19 @@
1
+ """Develop compose mode — extends an existing seed loop into a full track.
2
+
3
+ Two-phase LLM-creative flow (mirrors fast/full): Phase 1 returns a brief
4
+ with seed identity + vocabulary, Phase 2 (agent) designs variants, Phase 3
5
+ applies them to the live session.
6
+ """
7
+ from .seed_introspector import classify_track, infer_role_from_name, introspect_seed
8
+ from .brief_builder import build_develop_brief, extract_artist_refs, detect_research_hooks
9
+ from .apply import apply_develop_plan
10
+
11
+ __all__ = [
12
+ "classify_track",
13
+ "infer_role_from_name",
14
+ "introspect_seed",
15
+ "build_develop_brief",
16
+ "extract_artist_refs",
17
+ "detect_research_hooks",
18
+ "apply_develop_plan",
19
+ ]
@@ -0,0 +1,217 @@
1
+ """Develop compose Phase-3 executor — applies agent-designed variant plan to session.
2
+
3
+ Receives a plan dict from the agent (free-form variant set: agent decides
4
+ count, names, scenes, MIDI), then materializes each variant as a session
5
+ clip. Uses the shared Applier for pre-flight (analyzer/bridge) and
6
+ post-flight (back_to_arranger) so develop benefits from the same fixes
7
+ as fast and full modes.
8
+
9
+ Develop mode does NOT create new tracks — it only writes session clips
10
+ to existing tracks. So postflight passes an empty applied_track_indices
11
+ list (skips per-track monitoring set, but still calls back_to_arranger).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import time
18
+ from typing import Any
19
+
20
+ from fastmcp import Context
21
+
22
+ from ..framework.applier import Applier
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ── Applier wiring helpers ─────────────────────────────────────────
28
+
29
+ async def _ensure_analyzer_stub(ctx: Any) -> dict:
30
+ """Develop mode is read-mostly; analyzer is nice-to-have, not required.
31
+
32
+ We still call ensure_analyzer_on_master if available, but failures are
33
+ non-fatal (unlike full mode where the analyzer is integral).
34
+ """
35
+ try:
36
+ from ...tools.analyzer import ensure_analyzer_on_master # type: ignore
37
+ result = ensure_analyzer_on_master(ctx)
38
+ if isinstance(result, dict):
39
+ return result
40
+ return {"status": "unknown"}
41
+ except Exception as exc:
42
+ logger.debug("apply_develop: ensure_analyzer non-fatal failure: %s", exc)
43
+ return {"status": "skipped"}
44
+
45
+
46
+ async def _reconnect_bridge_stub(ctx: Any) -> dict:
47
+ """Reconnect bridge if available; non-fatal failure for develop."""
48
+ try:
49
+ from ...tools.analyzer import reconnect_bridge # type: ignore
50
+ result = reconnect_bridge(ctx)
51
+ if isinstance(result, dict):
52
+ connected = bool(result.get("connected") or result.get("ok"))
53
+ return {"connected": connected}
54
+ return {"connected": False}
55
+ except Exception as exc:
56
+ logger.debug("apply_develop: reconnect_bridge non-fatal failure: %s", exc)
57
+ return {"connected": False}
58
+
59
+
60
+ async def _bridge_ping_stub(ctx: Any) -> dict:
61
+ """Lightweight bridge ping — develop doesn't need bridge for clip writes."""
62
+ bridge = None
63
+ if hasattr(ctx, "lifespan_context"):
64
+ bridge = ctx.lifespan_context.get("m4l_bridge")
65
+ if bridge is None:
66
+ raise RuntimeError("bridge not available")
67
+ return await bridge.send_command("ping", {"timeout": 0.5})
68
+
69
+
70
+ async def _back_to_arranger(ctx: Any) -> dict:
71
+ """Call the back_to_arranger MCP primitive."""
72
+ ableton = ctx.lifespan_context.get("ableton") if hasattr(ctx, "lifespan_context") else None
73
+ if ableton is None:
74
+ return {"ok": False}
75
+ try:
76
+ return ableton.send_command("back_to_arranger", {})
77
+ except Exception as exc:
78
+ logger.warning("apply_develop: back_to_arranger failed: %s", exc)
79
+ return {"ok": False}
80
+
81
+
82
+ def _build_applier() -> Applier:
83
+ return Applier(
84
+ ensure_analyzer_fn=_ensure_analyzer_stub,
85
+ reconnect_bridge_fn=_reconnect_bridge_stub,
86
+ bridge_ping_fn=_bridge_ping_stub,
87
+ set_track_input_monitoring_fn=None, # develop creates no new tracks
88
+ back_to_arranger_fn=_back_to_arranger,
89
+ handshake_max_attempts=2, # develop is bridge-non-critical; short retry
90
+ handshake_gap_seconds=0.1,
91
+ )
92
+
93
+
94
+ # ── plan validation ────────────────────────────────────────────────
95
+
96
+ def _validate_plan(plan: dict) -> str | None:
97
+ """Return error message if plan is invalid, else None."""
98
+ if not isinstance(plan, dict):
99
+ return "plan must be a dict"
100
+ if plan.get("scope") not in (None, "develop"):
101
+ return f"plan scope must be 'develop' (got {plan.get('scope')!r})"
102
+ variants = plan.get("variants")
103
+ if not isinstance(variants, list) or len(variants) == 0:
104
+ return "plan.variants must be a non-empty list"
105
+ for i, v in enumerate(variants):
106
+ if not isinstance(v, dict):
107
+ return f"variants[{i}] must be a dict"
108
+ for required in ("track_index", "scene_index"):
109
+ if required not in v:
110
+ return f"variants[{i}] missing required field '{required}'"
111
+ if "notes" in v and not isinstance(v["notes"], list):
112
+ return f"variants[{i}].notes must be a list (or omitted)"
113
+ return None
114
+
115
+
116
+ # ── main entry point ───────────────────────────────────────────────
117
+
118
+ async def apply_develop_plan(ctx: Context, plan: dict) -> dict:
119
+ """Apply an agent-designed develop plan to the live session.
120
+
121
+ See module docstring for plan shape. Returns:
122
+ {
123
+ "status": "ok" | "partial" | "error",
124
+ "clips_created": int,
125
+ "scenes_populated": list[int],
126
+ "sample_swaps": int,
127
+ "preflight": dict, # Applier.preflight() result
128
+ "postflight": dict, # Applier.postflight() result
129
+ "errors": list[dict], # per-variant failures (variant index + reason)
130
+ "duration_ms": int,
131
+ }
132
+ """
133
+ started = time.time()
134
+
135
+ err = _validate_plan(plan)
136
+ if err:
137
+ return {"status": "error", "error": err, "phase": "validate"}
138
+
139
+ ableton = ctx.lifespan_context.get("ableton") if hasattr(ctx, "lifespan_context") else None
140
+ if ableton is None:
141
+ return {"status": "error", "error": "ableton client not available", "phase": "setup"}
142
+
143
+ applier = _build_applier()
144
+ preflight_result = await applier.preflight(ctx)
145
+ # Develop is bridge-non-critical; do NOT abort on bridge failure
146
+
147
+ # Tempo override (only if plan specifies a different tempo)
148
+ plan_tempo = plan.get("tempo")
149
+ if plan_tempo is not None:
150
+ try:
151
+ session = ableton.send_command("get_session_info", {})
152
+ current_tempo = float(session.get("tempo", 0.0))
153
+ if abs(current_tempo - float(plan_tempo)) > 0.01:
154
+ ableton.send_command("set_tempo", {"tempo": float(plan_tempo)})
155
+ except Exception as exc:
156
+ logger.warning("apply_develop: tempo set failed: %s", exc)
157
+
158
+ clip_length = float(plan.get("clip_length_beats", 4.0))
159
+ clips_created = 0
160
+ sample_swaps = 0
161
+ scenes_populated: set[int] = set()
162
+ errors: list[dict] = []
163
+
164
+ for i, v in enumerate(plan["variants"]):
165
+ track_index = int(v["track_index"])
166
+ scene_index = int(v["scene_index"])
167
+ name = v.get("name") or f"v{i}"
168
+ notes = v.get("notes", [])
169
+ sample_uri = v.get("sample_uri")
170
+
171
+ try:
172
+ # Optional sample swap (sample-trigger layers only)
173
+ if sample_uri:
174
+ ableton.send_command(
175
+ "load_browser_item",
176
+ {"track_index": track_index, "uri": sample_uri},
177
+ )
178
+ sample_swaps += 1
179
+
180
+ # Create clip
181
+ ableton.send_command(
182
+ "create_clip",
183
+ {"track_index": track_index, "clip_index": scene_index, "length": clip_length},
184
+ )
185
+
186
+ # Add notes (skip if empty — empty clip is a valid drum-dropout pattern)
187
+ if notes:
188
+ ableton.send_command(
189
+ "add_notes",
190
+ {"track_index": track_index, "clip_index": scene_index, "notes": notes},
191
+ )
192
+
193
+ # Name the clip
194
+ ableton.send_command(
195
+ "set_clip_name",
196
+ {"track_index": track_index, "clip_index": scene_index, "name": name},
197
+ )
198
+
199
+ clips_created += 1
200
+ scenes_populated.add(scene_index)
201
+ except Exception as exc:
202
+ logger.warning("apply_develop: variant[%d] failed: %s", i, exc)
203
+ errors.append({"variant_index": i, "reason": str(exc)})
204
+
205
+ # Postflight — develop creates no tracks, but still call back_to_arranger
206
+ postflight_result = await applier.postflight(ctx, applied_track_indices=[])
207
+
208
+ return {
209
+ "status": "ok" if not errors else "partial",
210
+ "clips_created": clips_created,
211
+ "scenes_populated": sorted(scenes_populated),
212
+ "sample_swaps": sample_swaps,
213
+ "preflight": preflight_result,
214
+ "postflight": postflight_result,
215
+ "errors": errors,
216
+ "duration_ms": int((time.time() - started) * 1000),
217
+ }