livepilot 1.10.5 → 1.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/execution_router.py +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +13 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -17,6 +17,9 @@ from fastmcp import Context
|
|
|
17
17
|
|
|
18
18
|
from ..server import mcp
|
|
19
19
|
from . import analyzer
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
def _get_ableton(ctx: Context):
|
|
@@ -39,8 +42,8 @@ def _fetch_tracks_and_scenes(ctx: Context) -> tuple[list[dict], list[dict], dict
|
|
|
39
42
|
try:
|
|
40
43
|
session = ableton.send_command("get_session_info", {})
|
|
41
44
|
tracks = session.get("tracks", [])
|
|
42
|
-
except Exception:
|
|
43
|
-
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
logger.debug("_fetch_tracks_and_scenes failed: %s", exc)
|
|
44
47
|
|
|
45
48
|
try:
|
|
46
49
|
matrix = ableton.send_command("get_scene_matrix")
|
|
@@ -50,15 +53,16 @@ def _fetch_tracks_and_scenes(ctx: Context) -> tuple[list[dict], list[dict], dict
|
|
|
50
53
|
zip(matrix.get("scenes", []), matrix.get("matrix", []))
|
|
51
54
|
)
|
|
52
55
|
]
|
|
53
|
-
except Exception:
|
|
54
|
-
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
logger.debug("_fetch_tracks_and_scenes failed: %s", exc)
|
|
55
58
|
|
|
56
59
|
# Fetch motif data — via shared motif service
|
|
57
60
|
try:
|
|
58
61
|
from ..services.motif_service import get_motif_data, fetch_notes_from_ableton
|
|
59
62
|
notes_by_track = fetch_notes_from_ableton(ableton, tracks)
|
|
60
63
|
motif_data = get_motif_data(notes_by_track)
|
|
61
|
-
except Exception:
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
logger.debug("_fetch_tracks_and_scenes failed: %s", exc)
|
|
62
66
|
pass # Motif graph requires notes in clips; empty dict is valid fallback
|
|
63
67
|
|
|
64
68
|
return tracks, scenes, motif_data
|
|
@@ -131,7 +135,18 @@ def develop_hook(
|
|
|
131
135
|
# Look up the actual hook to adapt strategies by type
|
|
132
136
|
hook_type = "melodic" # default
|
|
133
137
|
hook_description = "the hook"
|
|
134
|
-
|
|
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:
|
|
135
150
|
tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
|
|
136
151
|
candidates = analyzer.find_hook_candidates(tracks, motif_data, scenes)
|
|
137
152
|
match = [c for c in candidates if c.hook_id == hook_id]
|
|
@@ -325,12 +340,18 @@ def suggest_payoff_repair(ctx: Context) -> dict:
|
|
|
325
340
|
"repair_count": len(repairs),
|
|
326
341
|
}
|
|
327
342
|
|
|
328
|
-
|
|
329
343
|
# ── Helpers ───────────────────────────────────────────────────────
|
|
330
344
|
|
|
331
345
|
|
|
332
346
|
def _get_section_data(ableton) -> list[dict]:
|
|
333
|
-
"""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
|
+
"""
|
|
334
355
|
sections: list[dict] = []
|
|
335
356
|
try:
|
|
336
357
|
matrix = ableton.send_command("get_scene_matrix")
|
|
@@ -339,11 +360,6 @@ def _get_section_data(ableton) -> list[dict]:
|
|
|
339
360
|
|
|
340
361
|
# Detect drum track indices by name
|
|
341
362
|
drum_keywords = {"drum", "beat", "kick", "hat", "perc", "snare"}
|
|
342
|
-
track_names = []
|
|
343
|
-
# tracks may be in matrix metadata or session_info
|
|
344
|
-
for ti, row_entry in enumerate(matrix_rows[0] if matrix_rows else []):
|
|
345
|
-
track_names.append("") # placeholder — we'll use scenes_list tracks if available
|
|
346
|
-
# Use scene matrix track info if available
|
|
347
363
|
track_info = matrix.get("tracks", [])
|
|
348
364
|
drum_indices = set()
|
|
349
365
|
for ti, track in enumerate(track_info):
|
|
@@ -358,16 +374,49 @@ def _get_section_data(ableton) -> list[dict]:
|
|
|
358
374
|
clip_count = sum(1 for c in row if c)
|
|
359
375
|
total_tracks = max(len(row), 1)
|
|
360
376
|
|
|
361
|
-
# has_drums: check if any drum track has a clip in this scene
|
|
362
377
|
has_drums = any(
|
|
363
378
|
di < len(row) and row[di]
|
|
364
379
|
for di in drum_indices
|
|
365
380
|
) if drum_indices else False
|
|
366
381
|
|
|
367
382
|
density = min(1.0, clip_count / total_tracks)
|
|
368
|
-
# energy: density + drum bonus
|
|
369
383
|
energy = min(1.0, density + (0.1 if has_drums else 0.0))
|
|
370
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
|
+
|
|
371
420
|
sections.append({
|
|
372
421
|
"id": f"scene_{i}",
|
|
373
422
|
"name": scene.get("name", f"Scene {i}"),
|
|
@@ -375,9 +424,14 @@ def _get_section_data(ableton) -> list[dict]:
|
|
|
375
424
|
"energy": round(energy, 3),
|
|
376
425
|
"density": round(density, 3),
|
|
377
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),
|
|
378
432
|
})
|
|
379
|
-
except Exception:
|
|
380
|
-
|
|
433
|
+
except Exception as exc:
|
|
434
|
+
logger.debug("_get_section_data failed: %s", exc)
|
|
381
435
|
|
|
382
436
|
return sections
|
|
383
437
|
|
|
@@ -391,6 +445,7 @@ def _get_song_brain_dict() -> dict:
|
|
|
391
445
|
except Exception as _e:
|
|
392
446
|
if __debug__:
|
|
393
447
|
import sys
|
|
448
|
+
|
|
394
449
|
print(f"LivePilot: SongBrain unavailable in hook_hunter: {_e}", file=sys.stderr)
|
|
395
450
|
return {}
|
|
396
451
|
|
|
@@ -421,7 +476,8 @@ def detect_hook_neglect(ctx: Context) -> dict:
|
|
|
421
476
|
|
|
422
477
|
try:
|
|
423
478
|
matrix = ableton.send_command("get_scene_matrix")
|
|
424
|
-
except Exception:
|
|
479
|
+
except Exception as exc:
|
|
480
|
+
logger.debug("detect_hook_neglect failed: %s", exc)
|
|
425
481
|
return {
|
|
426
482
|
"neglected": False,
|
|
427
483
|
"hook": hook.to_dict(),
|
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -7,6 +7,17 @@ the M4L device on the master track. Sends commands back for deep LOM access.
|
|
|
7
7
|
Architecture:
|
|
8
8
|
M4L → UDP:9880 → SpectralReceiver → SpectralCache → MCP tools
|
|
9
9
|
MCP tools → M4LBridge → UDP:9881 → M4L device
|
|
10
|
+
|
|
11
|
+
OSC address convention:
|
|
12
|
+
- OUTGOING (this side → M4L): address string is sent WITHOUT a leading
|
|
13
|
+
slash because Max's `udpreceive` treats a literal '/' as part of the
|
|
14
|
+
selector. The JS side (livepilot_bridge.js) routes on bare selectors
|
|
15
|
+
like "cmd" / "ping".
|
|
16
|
+
- INCOMING (M4L → this side): the M4L side uses Max's `udpsend`, whose
|
|
17
|
+
outlet messages include the leading slash (e.g. "/response"). The
|
|
18
|
+
`_parse_osc` helper normalizes with `rest = "/" + rest.lstrip("/\\")`
|
|
19
|
+
so both forms are tolerated — keep that normalization; both sides
|
|
20
|
+
bend toward leniency but the outgoing convention here is slash-less.
|
|
10
21
|
"""
|
|
11
22
|
|
|
12
23
|
from __future__ import annotations
|
|
@@ -267,14 +278,26 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
267
278
|
self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
|
|
268
279
|
|
|
269
280
|
def _handle_response(self, encoded: str) -> None:
|
|
270
|
-
"""Decode a single-packet base64 response.
|
|
281
|
+
"""Decode a single-packet base64 response.
|
|
282
|
+
|
|
283
|
+
Resolves _response_callback exactly once, then clears it. Without the
|
|
284
|
+
clear, a second late packet could overwrite a future belonging to a
|
|
285
|
+
different in-flight command. The protocol has no request id yet
|
|
286
|
+
(livepilot_bridge.js:666 emits bare /response), so correlation relies
|
|
287
|
+
on the single-command-in-flight invariant enforced by M4LBridge._cmd_lock
|
|
288
|
+
plus this one-shot clear.
|
|
289
|
+
"""
|
|
271
290
|
try:
|
|
272
291
|
# URL-safe base64 decode (- and _ instead of + and /)
|
|
273
292
|
padded = encoded + "=" * (-len(encoded) % 4)
|
|
274
293
|
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
|
|
275
294
|
result = _normalize_bridge_payload(json.loads(decoded))
|
|
276
|
-
|
|
277
|
-
|
|
295
|
+
cb = self._response_callback
|
|
296
|
+
if cb and not cb.done():
|
|
297
|
+
cb.set_result(result)
|
|
298
|
+
# Clear regardless — either we consumed it, or it was already
|
|
299
|
+
# done/abandoned. Future packets with no owner get dropped.
|
|
300
|
+
self._response_callback = None
|
|
278
301
|
except Exception as exc:
|
|
279
302
|
import sys
|
|
280
303
|
print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
|
|
@@ -365,11 +388,14 @@ class M4LBridge:
|
|
|
365
388
|
result = await asyncio.wait_for(future, timeout=timeout)
|
|
366
389
|
return result
|
|
367
390
|
except asyncio.TimeoutError:
|
|
368
|
-
|
|
369
|
-
|
|
391
|
+
return {"error": "M4L bridge timeout — device may be busy or removed"}
|
|
392
|
+
finally:
|
|
393
|
+
# Always clear the future — on success the receiver has already
|
|
394
|
+
# cleared it inside _handle_response, but calling again is a
|
|
395
|
+
# no-op. On timeout this is what prevents a delayed packet from
|
|
396
|
+
# resolving a future belonging to the next command.
|
|
370
397
|
if self.receiver:
|
|
371
398
|
self.receiver.set_response_future(None)
|
|
372
|
-
return {"error": "M4L bridge timeout — device may be busy or removed"}
|
|
373
399
|
|
|
374
400
|
async def send_capture(self, command: str, *args: Any, timeout: float = 35.0) -> dict:
|
|
375
401
|
"""Send a capture command to the M4L device and wait for /capture_complete."""
|
|
@@ -17,6 +17,9 @@ from __future__ import annotations
|
|
|
17
17
|
import time
|
|
18
18
|
from dataclasses import dataclass, field
|
|
19
19
|
from typing import Optional
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
@dataclass
|
|
@@ -127,7 +130,8 @@ class TasteGraph:
|
|
|
127
130
|
if self._persistent_store is not None:
|
|
128
131
|
try:
|
|
129
132
|
self._persistent_store.record_move_outcome(move_id, family, kept, score)
|
|
130
|
-
except Exception:
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
logger.debug("record_move_outcome failed: %s", exc)
|
|
131
135
|
pass # persistence is best-effort
|
|
132
136
|
|
|
133
137
|
def record_device_use(self, device_name: str, positive: bool = True) -> None:
|
|
@@ -249,9 +253,9 @@ class TasteGraph:
|
|
|
249
253
|
"explanations": explanations,
|
|
250
254
|
}
|
|
251
255
|
|
|
252
|
-
|
|
253
256
|
# ── Builder ──────────────────────────────────────────────────────────────────
|
|
254
257
|
|
|
258
|
+
|
|
255
259
|
def build_taste_graph(
|
|
256
260
|
taste_store=None, # TasteMemoryStore
|
|
257
261
|
anti_store=None, # AntiMemoryStore
|
|
@@ -299,6 +303,7 @@ def build_taste_graph(
|
|
|
299
303
|
# Device affinities
|
|
300
304
|
for dev_name, dev_data in persisted.get("device_affinities", {}).items():
|
|
301
305
|
from .taste_graph import DeviceAffinity
|
|
306
|
+
|
|
302
307
|
graph.device_affinities[dev_name] = DeviceAffinity(
|
|
303
308
|
device_name=dev_name,
|
|
304
309
|
affinity=dev_data.get("affinity", 0.0),
|
|
@@ -15,6 +15,10 @@ from ..evaluation.fabric import evaluate_sonic_move
|
|
|
15
15
|
from .state_builder import build_mix_state
|
|
16
16
|
from .critics import run_all_mix_critics
|
|
17
17
|
from .planner import plan_mix_moves
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
18
22
|
|
|
19
23
|
|
|
20
24
|
# ── Helpers ─────────────────────────────────────────────────────────
|
|
@@ -32,7 +36,8 @@ def _fetch_mix_data(ctx: Context) -> dict:
|
|
|
32
36
|
try:
|
|
33
37
|
info = ableton.send_command("get_track_info", {"track_index": i})
|
|
34
38
|
track_infos.append(info)
|
|
35
|
-
except Exception:
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
logger.debug("_fetch_mix_data failed: %s", exc)
|
|
36
41
|
continue
|
|
37
42
|
|
|
38
43
|
# Get spectrum and RMS data directly from SpectralCache (not TCP)
|
|
@@ -51,8 +56,8 @@ def _fetch_mix_data(ctx: Context) -> dict:
|
|
|
51
56
|
rms_snap = spectral.get("rms")
|
|
52
57
|
if rms_snap:
|
|
53
58
|
rms_data = rms_snap["value"] if isinstance(rms_snap["value"], dict) else rms_snap["value"]
|
|
54
|
-
except Exception:
|
|
55
|
-
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
logger.debug("_fetch_mix_data failed: %s", exc)
|
|
56
61
|
|
|
57
62
|
return {
|
|
58
63
|
"session_info": session_info,
|
|
@@ -208,11 +208,43 @@ def detect_role_conflicts(
|
|
|
208
208
|
"Layer drum parts into one Drum Rack or pan them apart"),
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
# BUG-B1 fix: intentional drum + percussion layering is the core
|
|
212
|
+
# aesthetic in hip-hop / Dilla / lo-fi / beat-scene music, not a
|
|
213
|
+
# conflict. Heuristic to demote drum-role conflicts when the track
|
|
214
|
+
# names make that layering obvious (one "DRUMS" + one "PERC/CONGA/
|
|
215
|
+
# SHAKER" is distinct instruments, not a fight for the same role).
|
|
216
|
+
_PERC_NAMES = {
|
|
217
|
+
"perc", "percussion", "conga", "congas", "shaker",
|
|
218
|
+
"tambourine", "cowbell", "triangle", "bongo",
|
|
219
|
+
"djembe", "claves", "hi-hat", "hihat", "hat",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def _looks_like_layering(group: list[dict]) -> bool:
|
|
223
|
+
"""True if at least one of the tracks has a percussion-specific
|
|
224
|
+
name (distinct from the main drum kit)."""
|
|
225
|
+
if len(group) < 2:
|
|
226
|
+
return False
|
|
227
|
+
perc_track_count = 0
|
|
228
|
+
for track in group:
|
|
229
|
+
name = str(track.get("name", "")).lower()
|
|
230
|
+
if any(tok in name for tok in _PERC_NAMES):
|
|
231
|
+
perc_track_count += 1
|
|
232
|
+
# Needs at least one main "drums" track AND one perc track
|
|
233
|
+
return 1 <= perc_track_count < len(group)
|
|
234
|
+
|
|
211
235
|
conflicts = []
|
|
212
236
|
for role, (desc, rec) in UNIQUE_ROLES.items():
|
|
213
237
|
group = role_groups.get(role, [])
|
|
214
238
|
if len(group) > 1:
|
|
215
239
|
severity = min(0.9, 0.3 + (len(group) - 1) * 0.2)
|
|
240
|
+
if role == "drums" and _looks_like_layering(group):
|
|
241
|
+
# Demote severity — this looks intentional, not a conflict
|
|
242
|
+
severity = max(0.1, severity - 0.4)
|
|
243
|
+
rec = (
|
|
244
|
+
"Drum + percussion layering detected — if this is "
|
|
245
|
+
"intentional (hip-hop / Dilla / lo-fi), ignore. "
|
|
246
|
+
"Otherwise: " + rec
|
|
247
|
+
)
|
|
216
248
|
conflicts.append(RoleConflict(
|
|
217
249
|
role=role,
|
|
218
250
|
tracks=group,
|
|
@@ -13,6 +13,9 @@ from fastmcp import Context
|
|
|
13
13
|
|
|
14
14
|
from ..server import mcp
|
|
15
15
|
from . import detectors
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
def _get_ableton(ctx: Context):
|
|
@@ -34,7 +37,8 @@ def detect_repetition_fatigue(ctx: Context) -> dict:
|
|
|
34
37
|
# Get scene matrix for clip reuse analysis
|
|
35
38
|
try:
|
|
36
39
|
matrix = ableton.send_command("get_scene_matrix")
|
|
37
|
-
except Exception:
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
logger.debug("detect_repetition_fatigue failed: %s", exc)
|
|
38
42
|
matrix = {}
|
|
39
43
|
|
|
40
44
|
scenes = []
|
|
@@ -53,8 +57,8 @@ def detect_repetition_fatigue(ctx: Context) -> dict:
|
|
|
53
57
|
track_list = session_info.get("tracks", [])
|
|
54
58
|
notes_by_track = fetch_notes_from_ableton(ableton, track_list)
|
|
55
59
|
motif_graph = get_motif_data(notes_by_track)
|
|
56
|
-
except Exception:
|
|
57
|
-
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
logger.debug("detect_repetition_fatigue failed: %s", exc)
|
|
58
62
|
|
|
59
63
|
report = detectors.detect_repetition_fatigue(scenes, motif_graph)
|
|
60
64
|
return report.to_dict()
|
|
@@ -97,7 +101,8 @@ def infer_section_purposes(ctx: Context) -> dict:
|
|
|
97
101
|
# Get scene matrix for density analysis
|
|
98
102
|
try:
|
|
99
103
|
matrix = ableton.send_command("get_scene_matrix")
|
|
100
|
-
except Exception:
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
logger.debug("infer_section_purposes failed: %s", exc)
|
|
101
106
|
matrix = {}
|
|
102
107
|
|
|
103
108
|
scenes = []
|
|
@@ -132,7 +137,8 @@ def score_emotional_arc(ctx: Context) -> dict:
|
|
|
132
137
|
|
|
133
138
|
try:
|
|
134
139
|
matrix = ableton.send_command("get_scene_matrix")
|
|
135
|
-
except Exception:
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
logger.debug("score_emotional_arc failed: %s", exc)
|
|
136
142
|
matrix = {}
|
|
137
143
|
|
|
138
144
|
scenes = []
|
|
@@ -147,7 +153,6 @@ def score_emotional_arc(ctx: Context) -> dict:
|
|
|
147
153
|
arc = detectors.score_emotional_arc(purposes)
|
|
148
154
|
return arc.to_dict()
|
|
149
155
|
|
|
150
|
-
|
|
151
156
|
# ── Phrase Evaluation ────────────────────────────────────────────────
|
|
152
157
|
|
|
153
158
|
|
|
@@ -179,14 +184,14 @@ def analyze_phrase_arc(
|
|
|
179
184
|
try:
|
|
180
185
|
from ..tools._perception_engine import compute_loudness
|
|
181
186
|
loudness_data = compute_loudness(file_path, detail="full")
|
|
182
|
-
except Exception:
|
|
183
|
-
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
logger.debug("analyze_phrase_arc failed: %s", exc)
|
|
184
189
|
|
|
185
190
|
try:
|
|
186
191
|
from ..tools._perception_engine import compute_spectral
|
|
187
192
|
spectrum_data = compute_spectral(file_path)
|
|
188
|
-
except Exception:
|
|
189
|
-
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
logger.debug("analyze_phrase_arc failed: %s", exc)
|
|
190
195
|
|
|
191
196
|
critique = phrase_critic.analyze_phrase(loudness_data, spectrum_data, target)
|
|
192
197
|
critique.render_id = file_path.split("/")[-1] if "/" in file_path else file_path
|
|
@@ -12,39 +12,68 @@ from ..server import mcp
|
|
|
12
12
|
from .models import EnergyWindow, SceneRole
|
|
13
13
|
from .planner import build_performance_state, plan_scene_transition, suggest_energy_moves
|
|
14
14
|
from .safety import classify_move_safety, get_blocked_moves, get_safe_moves
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
# ── Helpers ─────────────────────────────────────────────────────────
|
|
18
22
|
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
# BUG-E4 / E5 fix: performance_engine used to have its own _infer_role() keyword
|
|
25
|
+
# list and _infer_energy() static {role → number} table. Those diverged from
|
|
26
|
+
# _composition_engine's richer section classifier, which caused
|
|
27
|
+
# get_performance_state and analyze_composition to label the same scenes
|
|
28
|
+
# differently (Deep Flow: drop vs verse, Sun Peak: drop vs chorus) and to
|
|
29
|
+
# report dissimilar energies (composition derived from active-track density,
|
|
30
|
+
# performance looked up a hard-coded 0.2/0.4/0.7 table). Now performance
|
|
31
|
+
# consumes composition's section graph as the source of truth and only keeps
|
|
32
|
+
# a positional fallback for scenes without enough data.
|
|
33
|
+
_POSITIONAL_FALLBACK_ROLES = {
|
|
34
|
+
"first": "intro",
|
|
35
|
+
"last": "outro",
|
|
36
|
+
"early": "intro",
|
|
37
|
+
"middle_low": "verse",
|
|
38
|
+
"middle_high": "chorus",
|
|
39
|
+
"late": "outro",
|
|
40
|
+
"default": "verse",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _positional_fallback_role(index: int, scene_count: int) -> str:
|
|
45
|
+
"""Map a scene index to a role when no composition data is available.
|
|
46
|
+
|
|
47
|
+
Kept only as a last-resort so we still produce a sensible answer for
|
|
48
|
+
unnamed scenes or when build_section_graph_from_scenes returns empty.
|
|
49
|
+
Callers should prefer the composition-engine result when it exists.
|
|
50
|
+
"""
|
|
51
|
+
if scene_count <= 0:
|
|
52
|
+
return _POSITIONAL_FALLBACK_ROLES["default"]
|
|
27
53
|
if index == 0:
|
|
28
|
-
return "
|
|
54
|
+
return _POSITIONAL_FALLBACK_ROLES["first"]
|
|
29
55
|
if index == scene_count - 1:
|
|
30
|
-
return "
|
|
56
|
+
return _POSITIONAL_FALLBACK_ROLES["last"]
|
|
31
57
|
if scene_count > 4:
|
|
32
|
-
quarter = scene_count / 4
|
|
58
|
+
quarter = scene_count / 4.0
|
|
33
59
|
if index < quarter:
|
|
34
|
-
return "
|
|
35
|
-
|
|
36
|
-
return "
|
|
37
|
-
|
|
38
|
-
return "
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
60
|
+
return _POSITIONAL_FALLBACK_ROLES["early"]
|
|
61
|
+
if index < quarter * 2:
|
|
62
|
+
return _POSITIONAL_FALLBACK_ROLES["middle_low"]
|
|
63
|
+
if index < quarter * 3:
|
|
64
|
+
return _POSITIONAL_FALLBACK_ROLES["middle_high"]
|
|
65
|
+
return _POSITIONAL_FALLBACK_ROLES["late"]
|
|
66
|
+
return _POSITIONAL_FALLBACK_ROLES["default"]
|
|
67
|
+
|
|
42
68
|
|
|
69
|
+
def _positional_fallback_energy(role: str) -> float:
|
|
70
|
+
"""Static energy map used only when density is unavailable.
|
|
43
71
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
72
|
+
Kept tiny and explicit so the fallback path is obvious — the primary
|
|
73
|
+
source of energy is _composition_engine's density-based value.
|
|
74
|
+
"""
|
|
75
|
+
return {
|
|
76
|
+
"intro": 0.3,
|
|
48
77
|
"verse": 0.4,
|
|
49
78
|
"build": 0.6,
|
|
50
79
|
"chorus": 0.7,
|
|
@@ -52,23 +81,82 @@ def _infer_energy(role: str) -> float:
|
|
|
52
81
|
"breakdown": 0.3,
|
|
53
82
|
"transition": 0.5,
|
|
54
83
|
"outro": 0.2,
|
|
55
|
-
}
|
|
56
|
-
return energy_map.get(role, 0.5)
|
|
84
|
+
}.get(role, 0.5)
|
|
57
85
|
|
|
58
86
|
|
|
59
87
|
def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
|
|
60
|
-
"""Fetch scene info from Ableton and build SceneRole list.
|
|
88
|
+
"""Fetch scene info + composition graph from Ableton and build SceneRole list.
|
|
89
|
+
|
|
90
|
+
BUG-E4 / E5 fix: roles + energies now flow from composition_engine's
|
|
91
|
+
build_section_graph_from_scenes, which uses keyword matching + active-
|
|
92
|
+
track density for energy. Unnamed scenes fall back to the positional
|
|
93
|
+
heuristic. This keeps get_performance_state in sync with
|
|
94
|
+
get_section_graph / analyze_composition.
|
|
95
|
+
"""
|
|
96
|
+
from ..tools._composition_engine import (
|
|
97
|
+
build_section_graph_from_scenes,
|
|
98
|
+
SectionNode as CESectionNode,
|
|
99
|
+
)
|
|
100
|
+
|
|
61
101
|
ableton = ctx.lifespan_context["ableton"]
|
|
62
102
|
|
|
63
103
|
scenes_info = ableton.send_command("get_scenes_info", {})
|
|
64
104
|
scenes_list = scenes_info.get("scenes", [])
|
|
65
105
|
scene_count = len(scenes_list)
|
|
66
106
|
|
|
107
|
+
# Pull session topology + clip matrix so composition engine can compute
|
|
108
|
+
# active-track density. If any of these fails we fall back to the
|
|
109
|
+
# positional heuristic — preserving the old behavior as a safety net.
|
|
110
|
+
track_count = 0
|
|
111
|
+
clip_matrix: list[list[dict]] = []
|
|
112
|
+
try:
|
|
113
|
+
session_info = ableton.send_command("get_session_info", {})
|
|
114
|
+
track_count = int(session_info.get("track_count", 0))
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
logger.debug("_fetch_scene_data session_info failed: %s", exc)
|
|
117
|
+
try:
|
|
118
|
+
mtx = ableton.send_command("get_scene_matrix", {})
|
|
119
|
+
if isinstance(mtx, dict):
|
|
120
|
+
clip_matrix = mtx.get("matrix", []) or []
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
logger.debug("_fetch_scene_data scene_matrix failed: %s", exc)
|
|
123
|
+
|
|
124
|
+
# Build the composition section graph. Each SectionNode has
|
|
125
|
+
# section_id = f"sec_{raw_enumerate_index:02d}" per BUG-E1 fix, so we
|
|
126
|
+
# can index by scene position directly.
|
|
127
|
+
ce_sections: list[CESectionNode] = []
|
|
128
|
+
try:
|
|
129
|
+
if scenes_list and clip_matrix and track_count > 0:
|
|
130
|
+
ce_sections = build_section_graph_from_scenes(
|
|
131
|
+
scenes_list, clip_matrix, track_count,
|
|
132
|
+
)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
logger.debug("_fetch_scene_data section graph failed: %s", exc)
|
|
135
|
+
|
|
136
|
+
ce_by_scene_idx: dict[int, CESectionNode] = {}
|
|
137
|
+
for sec in ce_sections:
|
|
138
|
+
# section_id format "sec_02" → scene index 2 (raw enumerate index)
|
|
139
|
+
sid = str(sec.section_id)
|
|
140
|
+
if sid.startswith("sec_"):
|
|
141
|
+
try:
|
|
142
|
+
ce_by_scene_idx[int(sid[4:])] = sec
|
|
143
|
+
except ValueError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
67
146
|
scene_roles: list[SceneRole] = []
|
|
68
147
|
for i, scene_data in enumerate(scenes_list):
|
|
69
148
|
name = scene_data.get("name", f"Scene {i}")
|
|
70
|
-
|
|
71
|
-
|
|
149
|
+
ce_sec = ce_by_scene_idx.get(i)
|
|
150
|
+
if ce_sec is not None:
|
|
151
|
+
# SectionType is an enum; .value gives the string vocabulary
|
|
152
|
+
stype = ce_sec.section_type
|
|
153
|
+
role = stype.value if hasattr(stype, "value") else str(stype)
|
|
154
|
+
energy = float(ce_sec.energy)
|
|
155
|
+
else:
|
|
156
|
+
# Unnamed scene or build failed — positional fallback
|
|
157
|
+
role = _positional_fallback_role(i, scene_count)
|
|
158
|
+
energy = _positional_fallback_energy(role)
|
|
159
|
+
|
|
72
160
|
scene_roles.append(SceneRole(
|
|
73
161
|
scene_index=i,
|
|
74
162
|
name=name,
|
|
@@ -81,14 +169,13 @@ def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
|
|
|
81
169
|
current_scene = 0
|
|
82
170
|
try:
|
|
83
171
|
session_info = ableton.send_command("get_session_info", {})
|
|
84
|
-
# Check if any scene is marked as triggered/playing
|
|
85
172
|
session_scenes = session_info.get("scenes", [])
|
|
86
173
|
for i, s in enumerate(session_scenes):
|
|
87
174
|
if s.get("is_triggered", False):
|
|
88
175
|
current_scene = i
|
|
89
176
|
break
|
|
90
|
-
except Exception:
|
|
91
|
-
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
logger.debug("_fetch_scene_data current_scene failed: %s", exc)
|
|
92
179
|
|
|
93
180
|
return scene_roles, current_scene
|
|
94
181
|
|