livepilot 1.27.1 → 1.27.3
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 +86 -0
- package/README.md +10 -7
- package/bin/livepilot.js +156 -37
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +48 -11
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/fast/brief_builder.py +6 -2
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +16 -9
- package/mcp_server/connection.py +69 -10
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +217 -62
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +57 -1
- package/mcp_server/runtime/mcp_dispatch.py +22 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +27 -25
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +21 -8
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/tracks.py +11 -5
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- package/server.json +2 -2
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
5
7
|
import time
|
|
6
8
|
from dataclasses import asdict, dataclass, field
|
|
7
9
|
from typing import Optional
|
|
@@ -9,6 +11,38 @@ from typing import Optional
|
|
|
9
11
|
from ..runtime.degradation import DegradationInfo
|
|
10
12
|
|
|
11
13
|
|
|
14
|
+
def compute_session_fingerprint(session_info: Optional[dict]) -> str:
|
|
15
|
+
"""Lightweight fingerprint of session topology (track_count + ordered
|
|
16
|
+
track names).
|
|
17
|
+
|
|
18
|
+
PreviewSet and WonderSession cache compiled plans that reference
|
|
19
|
+
positional track/device indices. Those indices are only valid against
|
|
20
|
+
the session topology that existed when the plan was compiled — if the
|
|
21
|
+
user adds/removes/reorders tracks before the plan is committed, replaying
|
|
22
|
+
those indices can silently hit the wrong track.
|
|
23
|
+
|
|
24
|
+
This helper is the single source of truth for that fingerprint, shared
|
|
25
|
+
by preview_studio and wonder_mode so both stamp/verify it the same way.
|
|
26
|
+
Callers stamp it at creation time from session_info they already fetched
|
|
27
|
+
(never triggers its own Ableton round-trip). At commit/replay time they
|
|
28
|
+
fetch a fresh session_info and compare fingerprints.
|
|
29
|
+
|
|
30
|
+
Returns "" when session_info is missing/malformed — an empty fingerprint
|
|
31
|
+
means "no signal available" and callers must skip the staleness check
|
|
32
|
+
(this keeps older cached/persisted objects, built before this field
|
|
33
|
+
existed, committable).
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(session_info, dict) or not session_info:
|
|
36
|
+
return ""
|
|
37
|
+
tracks = session_info.get("tracks")
|
|
38
|
+
if not isinstance(tracks, list):
|
|
39
|
+
tracks = []
|
|
40
|
+
names = [str(t.get("name", "")) for t in tracks if isinstance(t, dict)]
|
|
41
|
+
track_count = session_info.get("track_count", len(names))
|
|
42
|
+
seed = json.dumps({"track_count": track_count, "track_names": names}, sort_keys=True)
|
|
43
|
+
return hashlib.sha256(seed.encode()).hexdigest()[:16]
|
|
44
|
+
|
|
45
|
+
|
|
12
46
|
@dataclass
|
|
13
47
|
class PreviewVariant:
|
|
14
48
|
"""One creative option in a preview set."""
|
|
@@ -66,6 +100,11 @@ class PreviewSet:
|
|
|
66
100
|
# can inspect .degradation.is_degraded to tell synthesized preview
|
|
67
101
|
# topology apart from a real kernel-backed compile.
|
|
68
102
|
degradation: DegradationInfo = field(default_factory=DegradationInfo)
|
|
103
|
+
# Session-identity guard — see compute_session_fingerprint(). Stamped at
|
|
104
|
+
# creation time from the session_info the creator already fetched. Empty
|
|
105
|
+
# string means "no signal" (older objects, or created without a live
|
|
106
|
+
# session) and callers must skip the staleness check.
|
|
107
|
+
session_fingerprint: str = ""
|
|
69
108
|
|
|
70
109
|
def to_dict(self) -> dict:
|
|
71
110
|
return {
|
|
@@ -79,4 +118,5 @@ class PreviewSet:
|
|
|
79
118
|
"status": self.status,
|
|
80
119
|
"variant_count": len(self.variants),
|
|
81
120
|
"degradation": self.degradation.to_dict(),
|
|
121
|
+
"session_fingerprint": self.session_fingerprint,
|
|
82
122
|
}
|
|
@@ -15,6 +15,7 @@ from fastmcp import Context
|
|
|
15
15
|
|
|
16
16
|
from ..server import mcp
|
|
17
17
|
from . import engine
|
|
18
|
+
from .models import compute_session_fingerprint
|
|
18
19
|
import logging
|
|
19
20
|
|
|
20
21
|
logger = logging.getLogger(__name__)
|
|
@@ -114,6 +115,11 @@ def create_preview_set(
|
|
|
114
115
|
source_kernel_id=kernel_id,
|
|
115
116
|
variants=preview_variants,
|
|
116
117
|
created_at_ms=now,
|
|
118
|
+
# Carry forward the fingerprint the WonderSession stamped at
|
|
119
|
+
# enter_wonder_mode time so commit_preview_variant can still
|
|
120
|
+
# detect a topology change even though this PreviewSet was
|
|
121
|
+
# built from ws.variants rather than a fresh kernel fetch.
|
|
122
|
+
session_fingerprint=getattr(ws, "session_fingerprint", ""),
|
|
117
123
|
)
|
|
118
124
|
engine.store_preview_set(ps)
|
|
119
125
|
|
|
@@ -286,6 +292,40 @@ async def commit_preview_variant(
|
|
|
286
292
|
"available_variants": available,
|
|
287
293
|
}
|
|
288
294
|
|
|
295
|
+
# ── Session-identity guard (state-layer hardening) ──
|
|
296
|
+
# PreviewSet.session_fingerprint is stamped at creation time from the
|
|
297
|
+
# session_info the creator already had in hand (see engine.py /
|
|
298
|
+
# create_preview_set). The compiled_plan cached on each variant carries
|
|
299
|
+
# positional track/device indices that are only valid against that same
|
|
300
|
+
# topology. If the fingerprint is set, re-check it against a fresh
|
|
301
|
+
# get_session_info before replaying those indices — a track added/
|
|
302
|
+
# removed/reordered since the preview was built means "commit" would
|
|
303
|
+
# silently apply to the wrong track. Absent fingerprint (older/degraded
|
|
304
|
+
# objects predating this field) skips the check entirely — no new
|
|
305
|
+
# round-trip, no behavior change for them.
|
|
306
|
+
if ps.session_fingerprint:
|
|
307
|
+
ableton = _get_ableton(ctx)
|
|
308
|
+
fresh_session_info: dict = {}
|
|
309
|
+
try:
|
|
310
|
+
fresh_session_info = await ableton.send_command_async("get_session_info", {}) or {}
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
logger.debug("commit_preview_variant: session refresh failed: %s", exc)
|
|
313
|
+
fresh_session_info = {}
|
|
314
|
+
if isinstance(fresh_session_info, dict) and "error" not in fresh_session_info:
|
|
315
|
+
current_fingerprint = compute_session_fingerprint(fresh_session_info)
|
|
316
|
+
if current_fingerprint and current_fingerprint != ps.session_fingerprint:
|
|
317
|
+
return {
|
|
318
|
+
"error": (
|
|
319
|
+
"session changed since preview was created — "
|
|
320
|
+
"rebuild the preview set"
|
|
321
|
+
),
|
|
322
|
+
"code": "STATE_ERROR",
|
|
323
|
+
"set_id": set_id,
|
|
324
|
+
"variant_id": variant_id,
|
|
325
|
+
}
|
|
326
|
+
# else: couldn't refresh (Ableton unreachable) — fail open rather
|
|
327
|
+
# than blocking a legitimate commit on a transient fetch error.
|
|
328
|
+
|
|
289
329
|
# ── Truth-gap guard: refuse to "commit" a variant that can't execute ──
|
|
290
330
|
# If the variant was flagged blocked/failed upstream or lacks a
|
|
291
331
|
# compiled plan, the old code still marked preview_set.status='committed'
|
|
@@ -503,7 +543,11 @@ async def render_preview_variant(
|
|
|
503
543
|
plan = variant.compiled_plan
|
|
504
544
|
steps = plan if isinstance(plan, list) else plan.get("steps", [])
|
|
505
545
|
|
|
506
|
-
from ..runtime.execution_router import
|
|
546
|
+
from ..runtime.execution_router import (
|
|
547
|
+
execute_plan_steps_async,
|
|
548
|
+
filter_apply_steps,
|
|
549
|
+
undo_remote_steps,
|
|
550
|
+
)
|
|
507
551
|
|
|
508
552
|
# Read-only verification steps (meters/spectrum/info) don't create undo
|
|
509
553
|
# points in Ableton — counting them and then undoing walks back earlier
|
|
@@ -511,6 +555,7 @@ async def render_preview_variant(
|
|
|
511
555
|
apply_steps = filter_apply_steps(steps)
|
|
512
556
|
|
|
513
557
|
applied_count = 0
|
|
558
|
+
exec_results: list = []
|
|
514
559
|
playback_started = False
|
|
515
560
|
preview_mode = "metadata_only_preview"
|
|
516
561
|
spectral_before: Optional[dict] = None
|
|
@@ -523,7 +568,7 @@ async def render_preview_variant(
|
|
|
523
568
|
|
|
524
569
|
try:
|
|
525
570
|
# ── 1. Capture BEFORE metadata ──
|
|
526
|
-
before_info = ableton.
|
|
571
|
+
before_info = await ableton.send_command_async("get_session_info", {}) or {}
|
|
527
572
|
|
|
528
573
|
# ── 2. Apply the variant (write steps only) ──
|
|
529
574
|
exec_results = await execute_plan_steps_async(
|
|
@@ -542,7 +587,7 @@ async def render_preview_variant(
|
|
|
542
587
|
}
|
|
543
588
|
|
|
544
589
|
# ── 3. Capture AFTER metadata (variant is live) ──
|
|
545
|
-
after_info = ableton.
|
|
590
|
+
after_info = await ableton.send_command_async("get_session_info", {}) or {}
|
|
546
591
|
|
|
547
592
|
# ── 4. Audible capture WHILE variant is still applied ──
|
|
548
593
|
# This is the critical ordering fix: previously this block ran AFTER
|
|
@@ -558,7 +603,7 @@ async def render_preview_variant(
|
|
|
558
603
|
tempo = before_info.get("tempo", 120) or 120
|
|
559
604
|
play_seconds = min(bars * (60.0 / tempo) * 4, 8.0)
|
|
560
605
|
|
|
561
|
-
ableton.
|
|
606
|
+
await ableton.send_command_async("start_playback", {})
|
|
562
607
|
playback_started = True
|
|
563
608
|
|
|
564
609
|
import asyncio as _asyncio
|
|
@@ -567,7 +612,7 @@ async def render_preview_variant(
|
|
|
567
612
|
|
|
568
613
|
spectral_after = cache.get_all()
|
|
569
614
|
|
|
570
|
-
ableton.
|
|
615
|
+
await ableton.send_command_async("stop_playback", {})
|
|
571
616
|
playback_started = False
|
|
572
617
|
|
|
573
618
|
preview_mode = "audible_preview"
|
|
@@ -582,16 +627,15 @@ async def render_preview_variant(
|
|
|
582
627
|
# ── 5. Cleanup: stop playback if still running, then undo everything ──
|
|
583
628
|
if playback_started:
|
|
584
629
|
try:
|
|
585
|
-
ableton.
|
|
630
|
+
await ableton.send_command_async("stop_playback", {})
|
|
586
631
|
except Exception as exc:
|
|
587
632
|
logger.debug("render_preview_variant failed: %s", exc)
|
|
588
633
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
break
|
|
634
|
+
# Only remote_command steps land on Ableton's linear undo stack.
|
|
635
|
+
# Bridge (M4L/OSC) and mcp_tool steps do NOT, so counting them and
|
|
636
|
+
# then issuing that many `undo`s walks back unrelated prior user
|
|
637
|
+
# edits — undo_remote_steps filters to remote_command successes.
|
|
638
|
+
await undo_remote_steps(ableton, exec_results)
|
|
595
639
|
|
|
596
640
|
variant.status = "rendered"
|
|
597
641
|
variant.preview_mode = preview_mode
|
|
@@ -48,6 +48,10 @@ def build_arrangement_graph(
|
|
|
48
48
|
section_type=ce_sec.section_type.value,
|
|
49
49
|
energy=ce_sec.energy,
|
|
50
50
|
density=ce_sec.density,
|
|
51
|
+
# Clip-presence active tracks — includes audio tracks that carry
|
|
52
|
+
# no MIDI notes. Without this, role inference would only see
|
|
53
|
+
# tracks that appear in notes_map and drop every audio track.
|
|
54
|
+
tracks_active=list(getattr(ce_sec, "tracks_active", []) or []),
|
|
51
55
|
))
|
|
52
56
|
|
|
53
57
|
# Build boundary list (transitions between adjacent sections)
|
|
@@ -128,6 +128,7 @@ class SectionNode:
|
|
|
128
128
|
section_type: str = "unknown"
|
|
129
129
|
energy: float = 0.0
|
|
130
130
|
density: float = 0.0
|
|
131
|
+
tracks_active: list[int] = field(default_factory=list)
|
|
131
132
|
|
|
132
133
|
def to_dict(self) -> dict:
|
|
133
134
|
return {
|
|
@@ -137,6 +138,7 @@ class SectionNode:
|
|
|
137
138
|
"section_type": self.section_type,
|
|
138
139
|
"energy": self.energy,
|
|
139
140
|
"density": self.density,
|
|
141
|
+
"tracks_active": list(self.tracks_active),
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
|
|
@@ -41,18 +41,24 @@ def build_role_graph(
|
|
|
41
41
|
# Convert brain section dicts to composition-engine SectionNodes
|
|
42
42
|
ce_sections = []
|
|
43
43
|
for sec in sections:
|
|
44
|
-
# Determine which tracks are active in this section
|
|
45
|
-
#
|
|
44
|
+
# Determine which tracks are active in this section.
|
|
45
|
+
# Primary source is the clip-presence matrix (`tracks_active`),
|
|
46
|
+
# which includes AUDIO tracks that carry no MIDI notes. Union in
|
|
47
|
+
# any note-bearing tracks as a fallback so MIDI-only data (legacy
|
|
48
|
+
# callers that pass no tracks_active) still produces roles.
|
|
46
49
|
section_id = sec.get("section_id", sec.get("id", ""))
|
|
47
|
-
|
|
50
|
+
active_set = {int(t) for t in (sec.get("tracks_active") or [])}
|
|
48
51
|
section_notes = notes_map.get(section_id, {})
|
|
49
52
|
for t_idx, notes in section_notes.items():
|
|
50
53
|
if notes:
|
|
51
|
-
|
|
54
|
+
active_set.add(int(t_idx))
|
|
52
55
|
|
|
53
|
-
# Also include all tracks if no
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
# Also include all tracks if no clip-presence and no notes data
|
|
57
|
+
# (assume all active — e.g. a bare section with no matrix info).
|
|
58
|
+
if not active_set and not notes_map:
|
|
59
|
+
active_set = {int(t.get("index", 0)) for t in track_data}
|
|
60
|
+
|
|
61
|
+
active_tracks = sorted(active_set)
|
|
56
62
|
|
|
57
63
|
try:
|
|
58
64
|
stype = CESectionType(sec.get("section_type", "unknown"))
|
|
@@ -22,10 +22,49 @@ _RELEVANCE_THRESHOLDS: dict[str, float] = {
|
|
|
22
22
|
"harmonic": 0.0, # always relevant if different
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
# Per-domain normalization scales for _compute_overall_distance. Raw deltas
|
|
26
|
+
# live in incompatible units (loudness in LU, width/spectral/density in 0-1
|
|
27
|
+
# fractions, pacing in section COUNT), so summing their squares directly lets
|
|
28
|
+
# the large-magnitude domains (loudness, pacing) dominate while sub-unity
|
|
29
|
+
# spectral/width deltas contribute nothing (P2-37). Dividing each delta by a
|
|
30
|
+
# characteristic "one notable step" for its domain converts every term into a
|
|
31
|
+
# comparable, dimensionless number before the Euclidean sum.
|
|
32
|
+
_NORMALIZATION_SCALES: dict[str, float] = {
|
|
33
|
+
"loudness": 6.0, # ~6 LU is a clearly audible loudness gap
|
|
34
|
+
"spectral": 0.1, # a 0.1 shift in a band's energy fraction is large
|
|
35
|
+
"width": 0.2, # 0.2 of the 0-1 stereo-width range is substantial
|
|
36
|
+
"density": 0.3, # 0.3 of the 0-1 density range is a big arrangement move
|
|
37
|
+
"pacing": 2.0, # a 2-section structural difference is significant
|
|
38
|
+
"harmonic": 1.0, # harmonic delta is already a 0/1 indicator
|
|
39
|
+
}
|
|
40
|
+
_DEFAULT_NORMALIZATION_SCALE = 1.0
|
|
41
|
+
|
|
25
42
|
# When a gap exceeds this fraction of the project value, closing it
|
|
26
43
|
# risks flattening identity.
|
|
27
44
|
_IDENTITY_WARNING_THRESHOLD = 0.6
|
|
28
45
|
|
|
46
|
+
# ReferenceProfile.loudness_posture is always integrated LUFS (see models.py).
|
|
47
|
+
# The project snapshot may report loudness in a different scale, so we tag it
|
|
48
|
+
# with "loudness_unit" and convert to LUFS here before differencing. The ITU
|
|
49
|
+
# BS.1770 K-weighting has near-unity broadband gain plus a fixed -0.691 dB
|
|
50
|
+
# absolute-calibration offset, so a full-program RMS dBFS ≈ LUFS - 0.691. This
|
|
51
|
+
# is an approximation (it ignores K-weighting's frequency tilt and gating), but
|
|
52
|
+
# it keeps both sides of the loudness delta on the SAME axis — far better than
|
|
53
|
+
# subtracting a plain dBFS RMS from an integrated LUFS (P2-36).
|
|
54
|
+
_RMS_DBFS_TO_LUFS_OFFSET = -0.691
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _project_loudness_in_lufs(loudness: float, unit: str) -> float:
|
|
58
|
+
"""Convert a project-snapshot loudness reading to the integrated-LUFS
|
|
59
|
+
scale used by ReferenceProfile.loudness_posture."""
|
|
60
|
+
if unit in ("lufs", "integrated_lufs"):
|
|
61
|
+
return loudness
|
|
62
|
+
if unit in ("rms_dbfs", "dbfs"):
|
|
63
|
+
return loudness + _RMS_DBFS_TO_LUFS_OFFSET
|
|
64
|
+
# Unknown unit: assume it is already on the LUFS axis rather than
|
|
65
|
+
# corrupting it with an offset we can't justify.
|
|
66
|
+
return loudness
|
|
67
|
+
|
|
29
68
|
|
|
30
69
|
# ── Main analysis ──────────────────────────────────────────────────
|
|
31
70
|
|
|
@@ -50,7 +89,12 @@ def analyze_gaps(
|
|
|
50
89
|
ref_id = f"{reference.source_type}"
|
|
51
90
|
|
|
52
91
|
# 1. Loudness gap
|
|
53
|
-
|
|
92
|
+
# Both sides must be on the integrated-LUFS axis. The reference always is;
|
|
93
|
+
# the project snapshot declares its unit so we can convert (P2-36).
|
|
94
|
+
raw_loudness = project_snapshot.get("loudness", 0.0)
|
|
95
|
+
proj_loudness = _project_loudness_in_lufs(
|
|
96
|
+
raw_loudness, project_snapshot.get("loudness_unit", "lufs")
|
|
97
|
+
)
|
|
54
98
|
if reference.loudness_posture != 0.0 or proj_loudness != 0.0:
|
|
55
99
|
delta = proj_loudness - reference.loudness_posture
|
|
56
100
|
gaps.append(GapEntry(
|
|
@@ -200,11 +244,22 @@ def _is_identity_risk(project_value: float, delta: float) -> bool:
|
|
|
200
244
|
|
|
201
245
|
|
|
202
246
|
def _compute_overall_distance(gaps: list[GapEntry]) -> float:
|
|
203
|
-
"""Euclidean
|
|
247
|
+
"""Euclidean distance across relevant gap deltas, normalized per-domain.
|
|
248
|
+
|
|
249
|
+
Each delta is divided by a characteristic scale for its domain so that
|
|
250
|
+
deltas measured in incompatible units (LU, 0-1 fractions, section counts)
|
|
251
|
+
become dimensionless and comparable before the Euclidean sum (P2-37).
|
|
252
|
+
"""
|
|
204
253
|
relevant = [g for g in gaps if g.relevant]
|
|
205
254
|
if not relevant:
|
|
206
255
|
return 0.0
|
|
207
|
-
sum_sq =
|
|
256
|
+
sum_sq = 0.0
|
|
257
|
+
for g in relevant:
|
|
258
|
+
scale = _NORMALIZATION_SCALES.get(g.domain, _DEFAULT_NORMALIZATION_SCALE)
|
|
259
|
+
if scale <= 0:
|
|
260
|
+
scale = _DEFAULT_NORMALIZATION_SCALE
|
|
261
|
+
normalized = g.delta / scale
|
|
262
|
+
sum_sq += normalized ** 2
|
|
208
263
|
return math.sqrt(sum_sq)
|
|
209
264
|
|
|
210
265
|
|
|
@@ -24,10 +24,30 @@ def build_audio_reference_profile(comparison_data: dict) -> ReferenceProfile:
|
|
|
24
24
|
"""
|
|
25
25
|
band_deltas = comparison_data.get("band_deltas", {})
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
27
|
+
# The reference profile's band_balance MUST be the reference's OWN
|
|
28
|
+
# absolute per-band energy — NOT band_deltas (mix - ref), which is a
|
|
29
|
+
# different quantity entirely (a signed difference, not a level). Using
|
|
30
|
+
# the deltas here made analyze_gaps compute (proj_band - delta), so an
|
|
31
|
+
# identical mix produced a spurious gap equal to the project's own band
|
|
32
|
+
# energy instead of ~0.
|
|
33
|
+
#
|
|
34
|
+
# Prefer the absolute reference_band_balance emitted by
|
|
35
|
+
# compare_to_reference. If only mix bands + deltas are present,
|
|
36
|
+
# reconstruct ref = mix - delta. Fall back to {} (no spectral contour)
|
|
37
|
+
# rather than silently treating deltas as levels.
|
|
38
|
+
ref_band_balance = comparison_data.get("reference_band_balance")
|
|
39
|
+
if not ref_band_balance:
|
|
40
|
+
mix_band_balance = comparison_data.get("mix_band_balance")
|
|
41
|
+
if mix_band_balance and band_deltas:
|
|
42
|
+
ref_band_balance = {
|
|
43
|
+
band: round(mix_band_balance.get(band, 0.0) - delta, 6)
|
|
44
|
+
for band, delta in band_deltas.items()
|
|
45
|
+
}
|
|
46
|
+
else:
|
|
47
|
+
ref_band_balance = {}
|
|
48
|
+
|
|
29
49
|
spectral_contour: dict = {
|
|
30
|
-
"band_balance":
|
|
50
|
+
"band_balance": ref_band_balance,
|
|
31
51
|
"centroid_delta_hz": comparison_data.get("centroid_delta_hz", 0.0),
|
|
32
52
|
}
|
|
33
53
|
|
|
@@ -103,7 +123,9 @@ def build_style_reference_profile(style_tactics: list[dict]) -> ReferenceProfile
|
|
|
103
123
|
# shape — e.g., Auto Filter at 800Hz low-pass = darker spectrum,
|
|
104
124
|
# Utility Width > 0.6 = wider stereo). Rough but non-empty values
|
|
105
125
|
# are better than zeros for downstream reference-gap analysis.
|
|
106
|
-
loudness_posture
|
|
126
|
+
# loudness_posture is contracted to be integrated LUFS, so the [-1,+1]
|
|
127
|
+
# device-chain posture is mapped onto the LUFS axis (_style_posture_to_lufs).
|
|
128
|
+
loudness_posture = _style_posture_to_lufs(_derive_loudness_posture(style_tactics))
|
|
107
129
|
spectral_contour = _derive_spectral_contour(style_tactics)
|
|
108
130
|
width_depth = _derive_width_depth(style_tactics)
|
|
109
131
|
|
|
@@ -163,6 +185,27 @@ def _estimate_density_from_patterns(style_tactics: list[dict]) -> list[float]:
|
|
|
163
185
|
# ── BUG-B50 derivations — style → loudness/spectral/width heuristics ──────
|
|
164
186
|
|
|
165
187
|
|
|
188
|
+
# Style profiles infer a dimensionless loudness POSTURE in [-1, +1] from the
|
|
189
|
+
# device chain (limiters/glue → louder, reverb-heavy → quieter). But
|
|
190
|
+
# ReferenceProfile.loudness_posture is contracted to be integrated LUFS — the
|
|
191
|
+
# gap analyzer differences it directly against the project's LUFS (gap_analyzer
|
|
192
|
+
# lines 92-99). So the posture is mapped onto the LUFS axis: -14 LUFS is the
|
|
193
|
+
# streaming-normalization neutral; ±6 LU spans a hot/limited master
|
|
194
|
+
# (+1 → -8 LUFS) to a dynamic/quiet mix (-1 → -20 LUFS). A no-signal posture of
|
|
195
|
+
# 0 lands on the -14 neutral, yielding ~no spurious loudness gap against a
|
|
196
|
+
# typical project — whereas a raw 0.0 would read as a bogus ~14 LU gap.
|
|
197
|
+
_STYLE_LOUDNESS_NEUTRAL_LUFS = -14.0
|
|
198
|
+
_STYLE_LOUDNESS_RANGE_LU = 6.0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _style_posture_to_lufs(posture: float) -> float:
|
|
202
|
+
"""Map a [-1, +1] device-chain loudness posture onto the integrated-LUFS
|
|
203
|
+
axis that ReferenceProfile.loudness_posture is contracted to carry."""
|
|
204
|
+
return round(
|
|
205
|
+
_STYLE_LOUDNESS_NEUTRAL_LUFS + posture * _STYLE_LOUDNESS_RANGE_LU, 2
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
166
209
|
def _derive_loudness_posture(style_tactics: list[dict]) -> float:
|
|
167
210
|
"""Heuristic: compression / saturation / limiter devices imply a
|
|
168
211
|
specific loudness posture. Glue / Ratio>3 → loud, saturator drive
|
|
@@ -50,6 +50,12 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
|
|
|
50
50
|
|
|
51
51
|
snapshot: dict = {
|
|
52
52
|
"loudness": 0.0,
|
|
53
|
+
# Unit of snapshot["loudness"]. The live SpectralCache only exposes a
|
|
54
|
+
# plain-RMS value, so the best we can derive here is RMS dBFS — NOT
|
|
55
|
+
# the K-weighted integrated LUFS that ReferenceProfile.loudness_posture
|
|
56
|
+
# carries. analyze_gaps reads this tag to convert to a common scale
|
|
57
|
+
# instead of blindly subtracting dBFS from LUFS (P2-36).
|
|
58
|
+
"loudness_unit": "rms_dbfs",
|
|
53
59
|
"spectral": {},
|
|
54
60
|
"width": 0.0,
|
|
55
61
|
"density": 0.0,
|
|
@@ -22,12 +22,16 @@ Step-result binding:
|
|
|
22
22
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
|
+
import asyncio
|
|
25
26
|
import inspect
|
|
27
|
+
import logging
|
|
26
28
|
from dataclasses import dataclass
|
|
27
29
|
from typing import Any, Awaitable, Callable, Optional
|
|
28
30
|
|
|
29
31
|
from .remote_commands import BRIDGE_COMMANDS, REMOTE_COMMANDS
|
|
30
32
|
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
31
35
|
|
|
32
36
|
# MCP-only tools that exist as Python functions but NOT as TCP handlers.
|
|
33
37
|
# These must be called through direct import, not ableton.send_command().
|
|
@@ -66,6 +70,12 @@ MCP_TOOLS: frozenset[str] = frozenset({
|
|
|
66
70
|
# set_drum_chain_note + insert_device + replace_sample_native. Used by
|
|
67
71
|
# the create_drum_rack_pad semantic move.
|
|
68
72
|
"add_drum_rack_pad",
|
|
73
|
+
# Routing-correctness (v1.27.2): these have @mcp.tool wrappers that call
|
|
74
|
+
# the TCP Remote Script / read the SpectralCache in-process. They must
|
|
75
|
+
# classify as mcp_tool so plan steps take the SAME path as direct callers
|
|
76
|
+
# — not the M4L JS bridge, and not the "unknown" dead-end.
|
|
77
|
+
"compressor_set_sidechain", # was mis-listed in BRIDGE_COMMANDS → JS bridge
|
|
78
|
+
"get_master_rms", # was READ_ONLY but unclassified → plan failure
|
|
69
79
|
})
|
|
70
80
|
|
|
71
81
|
|
|
@@ -227,7 +237,17 @@ async def _execute_step_async(
|
|
|
227
237
|
error="Ableton connection unavailable",
|
|
228
238
|
)
|
|
229
239
|
try:
|
|
230
|
-
|
|
240
|
+
# Offload the blocking TCP round-trip to a worker thread so this
|
|
241
|
+
# shared executor never stalls the event loop (and therefore the
|
|
242
|
+
# concurrent UDP analyzer bridge) for the duration of the call.
|
|
243
|
+
# Prefer the real AbletonConnection's async wrapper when present;
|
|
244
|
+
# fall back to asyncio.to_thread directly for lightweight test
|
|
245
|
+
# doubles that only implement send_command.
|
|
246
|
+
send_command_async = getattr(ableton, "send_command_async", None)
|
|
247
|
+
if callable(send_command_async):
|
|
248
|
+
result = await send_command_async(tool, params)
|
|
249
|
+
else:
|
|
250
|
+
result = await asyncio.to_thread(ableton.send_command, tool, params)
|
|
231
251
|
if isinstance(result, dict) and "error" in result:
|
|
232
252
|
return ExecutionResult(ok=False, backend=backend, tool=tool, error=result["error"])
|
|
233
253
|
return ExecutionResult(ok=True, backend=backend, tool=tool, result=result)
|
|
@@ -385,3 +405,39 @@ async def execute_plan_steps_async(
|
|
|
385
405
|
break
|
|
386
406
|
|
|
387
407
|
return results
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ── Undo helper ──────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
async def undo_remote_steps(ableton: Any, exec_results: list[ExecutionResult]) -> int:
|
|
413
|
+
"""Undo the remote_command steps from a list of ExecutionResults.
|
|
414
|
+
|
|
415
|
+
Only remote_command steps land on Ableton's linear undo stack — bridge
|
|
416
|
+
(M4L/OSC) and mcp_tool mutations do NOT, so issuing one `undo` per
|
|
417
|
+
successful step regardless of backend over-undoes and walks back
|
|
418
|
+
unrelated prior user edits. This counts successful remote_command
|
|
419
|
+
steps and issues exactly that many `undo` calls via
|
|
420
|
+
``ableton.send_command_async``, stopping early if an undo call raises
|
|
421
|
+
(leaving the rest un-undone rather than risking a cascading error).
|
|
422
|
+
|
|
423
|
+
Extracted from the duplicated experiment/engine.py + preview_studio
|
|
424
|
+
/tools.py undo-count logic (both had drifted into copy-pasted
|
|
425
|
+
near-identical blocks) so the "count remote_command successes, then
|
|
426
|
+
undo that many times" rule lives in exactly one place.
|
|
427
|
+
|
|
428
|
+
Returns the number of undo calls actually issued (<= the number of
|
|
429
|
+
successful remote_command steps; less if an undo call raised partway
|
|
430
|
+
through).
|
|
431
|
+
"""
|
|
432
|
+
undo_count = sum(
|
|
433
|
+
1 for r in exec_results if r.ok and r.backend == "remote_command"
|
|
434
|
+
)
|
|
435
|
+
issued = 0
|
|
436
|
+
for _ in range(undo_count):
|
|
437
|
+
try:
|
|
438
|
+
await ableton.send_command_async("undo", {})
|
|
439
|
+
issued += 1
|
|
440
|
+
except Exception as exc:
|
|
441
|
+
logger.debug("undo_remote_steps: undo call failed: %s", exc)
|
|
442
|
+
break
|
|
443
|
+
return issued
|
|
@@ -153,6 +153,25 @@ async def _add_drum_rack_pad(params: dict, ctx: Any = None) -> dict:
|
|
|
153
153
|
return await _call(add_drum_rack_pad, ctx, params)
|
|
154
154
|
|
|
155
155
|
|
|
156
|
+
# ── Routing-correctness (v1.27.2) ─────────────────────────────────────────
|
|
157
|
+
#
|
|
158
|
+
# Both have @mcp.tool wrappers in tools/analyzer.py. compressor_set_sidechain
|
|
159
|
+
# was mis-listed in BRIDGE_COMMANDS, so plan steps silently routed to the M4L
|
|
160
|
+
# JS bridge while direct callers used the TCP Remote Script — divergent paths
|
|
161
|
+
# with different error handling. get_master_rms was tagged READ_ONLY but never
|
|
162
|
+
# classified (classify_step returned "unknown"), so plans could not use it at
|
|
163
|
+
# all. Both now dispatch in-process here, matching their direct @mcp.tool path.
|
|
164
|
+
|
|
165
|
+
async def _compressor_set_sidechain(params: dict, ctx: Any = None) -> dict:
|
|
166
|
+
from ..tools.analyzer import compressor_set_sidechain
|
|
167
|
+
return await _call(compressor_set_sidechain, ctx, params)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _get_master_rms(params: dict, ctx: Any = None) -> dict:
|
|
171
|
+
from ..tools.analyzer import get_master_rms
|
|
172
|
+
return await _call(get_master_rms, ctx, params)
|
|
173
|
+
|
|
174
|
+
|
|
156
175
|
def build_mcp_dispatch_registry() -> dict[str, Callable]:
|
|
157
176
|
"""Return the canonical registry of MCP-only tools for plan execution.
|
|
158
177
|
|
|
@@ -186,4 +205,7 @@ def build_mcp_dispatch_registry() -> dict[str, Callable]:
|
|
|
186
205
|
"add_session_memory": _add_session_memory,
|
|
187
206
|
# v1.20 — drum rack pad construction (async orchestrator).
|
|
188
207
|
"add_drum_rack_pad": _add_drum_rack_pad,
|
|
208
|
+
# v1.27.2 — routing-correctness (see adapters above).
|
|
209
|
+
"compressor_set_sidechain": _compressor_set_sidechain,
|
|
210
|
+
"get_master_rms": _get_master_rms,
|
|
189
211
|
}
|
|
@@ -139,9 +139,11 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
|
|
|
139
139
|
"get_params", "get_hidden_params", "get_auto_state", "walk_rack",
|
|
140
140
|
"get_chains_deep", "get_track_cpu", "get_selected", "get_key",
|
|
141
141
|
"get_clip_file_path", "replace_simpler_sample", "get_simpler_slices",
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
# NOTE: get_simpler_file_path is NOT here — it lives in REMOTE_COMMANDS
|
|
143
|
+
# (the primary Python LOM path since v1.23.3). classify_step checks
|
|
144
|
+
# REMOTE first, so listing it here too was dead for dispatch. The
|
|
145
|
+
# backwards-compat bridge call still exists, but analyzer.py invokes it
|
|
146
|
+
# directly via bridge.send_command(), bypassing classify_step.
|
|
145
147
|
"crop_simpler", "reverse_simpler", "warp_simpler",
|
|
146
148
|
"get_warp_markers", "add_warp_marker", "move_warp_marker",
|
|
147
149
|
"remove_warp_marker", "capture_audio", "capture_stop",
|
|
@@ -152,7 +154,10 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
|
|
|
152
154
|
# only Max JS LiveAPI exposes). See mcp_server/tools/analyzer.py for
|
|
153
155
|
# the matching MCP tools that route through bridge.send_command.
|
|
154
156
|
"simpler_set_warp",
|
|
155
|
-
|
|
157
|
+
# NOTE: compressor_set_sidechain is NOT here — its @mcp.tool wrapper calls
|
|
158
|
+
# the TCP Remote Script ("set_compressor_sidechain", the BUG-A3 Python
|
|
159
|
+
# path), so it belongs in MCP_TOOLS. Listing it here routed plan steps to
|
|
160
|
+
# the M4L JS bridge, a divergent path. Moved to execution_router.MCP_TOOLS.
|
|
156
161
|
# NOTE: load_sample_to_simpler used to live here, but it's actually an
|
|
157
162
|
# async Python MCP tool in mcp_server/tools/analyzer.py, not a bridge
|
|
158
163
|
# command. It has no case in livepilot_bridge.js and no @register handler
|
|
@@ -89,7 +89,6 @@ def _probe_flucoma_package() -> bool:
|
|
|
89
89
|
externals = package_dir / "externals"
|
|
90
90
|
if externals.exists() and any(externals.glob("fluid.*")):
|
|
91
91
|
return True
|
|
92
|
-
return True
|
|
93
92
|
return False
|
|
94
93
|
except Exception as exc: # noqa: BLE001
|
|
95
94
|
logger.debug("_probe_flucoma_package failed: %s", exc)
|
|
@@ -207,8 +207,6 @@ class SpliceSource:
|
|
|
207
207
|
if key:
|
|
208
208
|
# Normalize: user might pass "Cm" or "C#", Splice stores "c" or "c#"
|
|
209
209
|
# Strip minor suffix for comparison — Splice uses chord_type column
|
|
210
|
-
key_normalized = key.lower().rstrip("m").rstrip("inor")
|
|
211
|
-
# But proper suffix removal:
|
|
212
210
|
k = key.lower()
|
|
213
211
|
for suffix in ("minor", "min"):
|
|
214
212
|
if k.endswith(suffix):
|