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.
Files changed (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. 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 execute_plan_steps_async, filter_apply_steps
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.send_command("get_session_info", {}) or {}
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.send_command("get_session_info", {}) or {}
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.send_command("start_playback", {})
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.send_command("stop_playback", {})
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.send_command("stop_playback", {})
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
- for _ in range(applied_count):
590
- try:
591
- ableton.send_command("undo")
592
- except Exception as exc:
593
- logger.debug("render_preview_variant failed: %s", exc)
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
- # If notes_map has data for this section, those tracks are active
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
- active_tracks = []
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
- active_tracks.append(t_idx)
54
+ active_set.add(int(t_idx))
52
55
 
53
- # Also include all tracks if no notes data (assume all active)
54
- if not active_tracks and not notes_map:
55
- active_tracks = [t.get("index", 0) for t in track_data]
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
- proj_loudness = project_snapshot.get("loudness", 0.0)
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-like distance across all relevant gap deltas."""
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 = sum(g.delta ** 2 for g in relevant)
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
- # Reconstruct approximate reference spectral contour from band deltas.
28
- # The deltas are (mix - ref), so ref bands are conceptually the baseline.
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": band_deltas,
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 = _derive_loudness_posture(style_tactics)
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
- result = ableton.send_command(tool, params)
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
- "get_simpler_file_path", # v1.23.3closes the v1.12 follow-up that
143
- # left classify_simpler_slices unable to
144
- # auto-resolve the Simpler's sample path
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
- "compressor_set_sidechain",
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):