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
@@ -222,15 +222,13 @@ async def search_samples(
222
222
  ) -> dict:
223
223
  """Search for samples across Splice library, Ableton browser, and local filesystem.
224
224
 
225
- Searches all enabled sources in parallel and ranks results.
226
- Splice results include rich metadata (key, BPM, genre, tags, pack info,
227
- is_premium, price, is_free, preview_url).
225
+ Searches all enabled sources in parallel, ranked Splice-first, then
226
+ browser, then filesystem. Splice results carry key/BPM/genre/tags/
227
+ pack/is_premium/price/is_free/preview_url metadata.
228
228
 
229
- When the Splice desktop app is running AND grpcio is installed, this
230
- searches Splice's ONLINE catalog (19,690+ hits for a generic query)
231
- and returns un-downloaded items alongside local files. When gRPC is
232
- unavailable, it falls back to the local SQLite index and only returns
233
- already-downloaded samples.
229
+ With the Splice desktop app running + grpcio installed: searches
230
+ Splice's ONLINE catalog, returning un-downloaded items too. Without
231
+ gRPC: falls back to the local SQLite index (downloaded samples only).
234
232
 
235
233
  query: search text like "dark vocal", "breakbeat", "foley metal"
236
234
  q: alias for `query` (accepts either name for ergonomics)
@@ -776,24 +774,12 @@ async def get_splice_credits(ctx: Context) -> dict:
776
774
  Returns both pockets of the Splice subscription model:
777
775
  - `credits_remaining`: Splice.com credits for presets/MIDI/Instrument
778
776
  - `daily_quota`: sample-download counter (Ableton Live plan only)
777
+ - `download_gating`: "daily_quota" (Ableton Live plan) or
778
+ "credit_floor" (Sounds+/Creator/Creator+ — protects the last
779
+ CREDIT_HARD_FLOOR credits)
779
780
 
780
- Example (Ableton Live plan):
781
- {
782
- "connected": true,
783
- "username": "user-1367453956",
784
- "plan_raw": "subscribed",
785
- "plan_kind": "ableton_live",
786
- "sounds_plan_id": 12,
787
- "features": {"ableton_unmetered": true, ...},
788
- "credits_remaining": 80,
789
- "credit_floor": 5,
790
- "daily_quota": {
791
- "used_today": 3, "remaining_today": 97, "daily_limit": 100,
792
- "near_limit": false, "at_limit": false,
793
- },
794
- "can_download_sample": true,
795
- "download_gating": "daily_quota", # or "credit_floor"
796
- }
781
+ Full example response: livepilot-sample-engine references/
782
+ splice-tools-notes.md#get_splice_credits--full-response-example-ableton-live-plan.
797
783
 
798
784
  Returns connected=False (with zero credits) when the Splice desktop app
799
785
  isn't running or grpcio isn't installed.
@@ -1538,12 +1524,9 @@ async def splice_describe_sound(
1538
1524
  """Natural-language sample search — the Sounds Plugin's "Describe a Sound".
1539
1525
 
1540
1526
  Splice's AI matches free-form descriptions like "dark ambient pad with
1541
- shimmer" or "tight 90s house hi-hat" to catalog samples. Hits the
1542
- GraphQL `SamplesSearch` operation on `surfaces-graphql.splice.com`
1543
- with `semantic=1` + `rephrase=true` enabled.
1544
-
1545
- **Status: LIVE** as of 2026-04-22. Endpoint captured via mitmproxy
1546
- against Splice desktop 5.4.9 + Sounds Plugin.
1527
+ shimmer" or "tight 90s house hi-hat" to catalog samples. Endpoint
1528
+ history: livepilot-sample-engine references/splice-tools-notes.md
1529
+ #splice_describe_sound--splice_generate_variation--endpoint-history.
1547
1530
 
1548
1531
  description: free-text prompt ("warm analog bass under 80bpm")
1549
1532
  bpm: optional BPM filter
@@ -1590,13 +1573,11 @@ async def splice_generate_variation(
1590
1573
  """Find catalog samples similar to a given Splice sample — the "Variations" feature.
1591
1574
 
1592
1575
  Splice's right-click "Variations" menu item surfaces other catalog
1593
- samples with similar sonic character. The GraphQL operation name
1594
- is `AssetSimilarSoundsQuery`. Up to 10 results per call. No credit
1595
- cost (this is a recommender lookup, not AI audio synthesis — the
1596
- original naming in the handoff was aspirational).
1597
-
1598
- **Status: LIVE** as of 2026-04-22. Endpoint captured via mitmproxy
1599
- against Splice desktop v5.4.9.
1576
+ samples with similar sonic character. Up to 10 results per call. No
1577
+ credit cost a recommender lookup, not AI audio synthesis (the
1578
+ "generate" naming was aspirational). Endpoint history: livepilot-
1579
+ sample-engine references/splice-tools-notes.md
1580
+ #splice_describe_sound--splice_generate_variation--endpoint-history.
1600
1581
 
1601
1582
  uuid: source sample's catalog uuid (from `splice_describe_sound`
1602
1583
  results or any other Splice metadata call)
@@ -14,6 +14,53 @@ from .models import SemanticMove
14
14
  from . import resolvers
15
15
 
16
16
 
17
+ def _kernel_track(kernel: dict, track_index: int) -> dict:
18
+ """Return the raw kernel track dict (with its `devices`) for an index.
19
+
20
+ resolvers.find_tracks_by_role returns a slimmed dict without `devices`, so
21
+ device resolution must reach back into the raw session_info track to see the
22
+ chain. Returns {} when not found.
23
+ """
24
+ for t in kernel.get("session_info", {}).get("tracks", []) or []:
25
+ if isinstance(t, dict) and t.get("index") == track_index:
26
+ return t
27
+ return {}
28
+
29
+
30
+ def _find_eq_device_index(track: dict) -> int | None:
31
+ """Return the chain index of an EQ device on this track, or None.
32
+
33
+ Only inspects device data the kernel already carries — never assumes a
34
+ device that hasn't been confirmed present. Used so frequency-carve writes
35
+ target a resolved device rather than a blind device_index=0 (the
36
+ wrong-device hazard guarded by tests/test_compiler_safety_contract.py).
37
+ """
38
+ for i, dev in enumerate(track.get("devices", []) or []):
39
+ if not isinstance(dev, dict):
40
+ continue
41
+ name = str(dev.get("name", "")).lower()
42
+ class_name = str(dev.get("class_name", "")).lower()
43
+ if "eq" in name or "eq8" in class_name or "eqeight" in class_name:
44
+ return dev.get("index", i)
45
+ return None
46
+
47
+
48
+ def _find_compressor_device_index(track: dict) -> int | None:
49
+ """Return the chain index of a Compressor on this track, or None.
50
+
51
+ Same kernel-snapshot-only discipline as _find_eq_device_index — used so the
52
+ sidechain routing targets a resolved compressor rather than a blind index.
53
+ """
54
+ for i, dev in enumerate(track.get("devices", []) or []):
55
+ if not isinstance(dev, dict):
56
+ continue
57
+ name = str(dev.get("name", "")).lower()
58
+ class_name = str(dev.get("class_name", "")).lower()
59
+ if "compressor" in name or "compressor" in class_name:
60
+ return dev.get("index", i)
61
+ return None
62
+
63
+
17
64
  def _compile_make_punchier(move: SemanticMove, kernel: dict) -> CompiledPlan:
18
65
  """Compile 'make_punchier': push drums, pull pads, tighten master bus."""
19
66
  steps = []
@@ -39,25 +86,33 @@ def _compile_make_punchier(move: SemanticMove, kernel: dict) -> CompiledPlan:
39
86
  verify_after=False,
40
87
  ))
41
88
 
42
- # Step 2: Push drum volume
89
+ # Step 2: Push drum volume — RELATIVE nudge (P2-21), not an absolute
90
+ # overwrite. A hot drum bus must get louder, not slammed down to 0.75.
43
91
  for dt in drum_tracks[:1]: # Only first drum track
44
92
  idx = dt["index"]
93
+ target = resolvers.compile_relative_volume(
94
+ dt.get("volume"), 8, cap=0.85, fallback=0.75
95
+ )
45
96
  steps.append(CompiledStep(
46
97
  tool="set_track_volume",
47
- params={"track_index": idx, "volume": 0.75},
48
- description=f"Push {dt['name']} (track {idx}) to 0.75 for transient punch",
98
+ params={"track_index": idx, "volume": target},
99
+ description=f"Push {dt['name']} (track {idx}) to {target:.2f} for transient punch",
49
100
  ))
50
- descriptions.append(f"Push {dt['name']} volume to 0.75")
101
+ descriptions.append(f"Push {dt['name']} volume to {target:.2f}")
51
102
 
52
- # Step 3: Pull back pads
103
+ # Step 3: Pull back pads — RELATIVE nudge, floored so a quiet pad
104
+ # doesn't get muted outright.
53
105
  for pt in pad_tracks:
54
106
  idx = pt["index"]
107
+ target = resolvers.compile_relative_volume(
108
+ pt.get("volume"), -10, floor=0.15, fallback=0.25
109
+ )
55
110
  steps.append(CompiledStep(
56
111
  tool="set_track_volume",
57
- params={"track_index": idx, "volume": 0.25},
58
- description=f"Pull {pt['name']} (track {idx}) to 0.25 for contrast",
112
+ params={"track_index": idx, "volume": target},
113
+ description=f"Pull {pt['name']} (track {idx}) to {target:.2f} for contrast",
59
114
  ))
60
- descriptions.append(f"Pull {pt['name']} volume to 0.25")
115
+ descriptions.append(f"Pull {pt['name']} volume to {target:.2f}")
61
116
 
62
117
  # Step 4: Verify
63
118
  steps.append(CompiledStep(
@@ -114,13 +169,17 @@ def _compile_tighten_low_end(move: SemanticMove, kernel: dict) -> CompiledPlan:
114
169
  optional=True,
115
170
  ))
116
171
 
117
- # Step 2: Reduce bass volume slightly
172
+ # Step 2: Reduce bass volume slightly — RELATIVE nudge (P2-21), floored
173
+ # so a bass already sitting quiet doesn't get pulled toward silence.
174
+ target = resolvers.compile_relative_volume(
175
+ bass.get("volume"), -10, floor=0.35, fallback=0.58
176
+ )
118
177
  steps.append(CompiledStep(
119
178
  tool="set_track_volume",
120
- params={"track_index": idx, "volume": 0.58},
121
- description=f"Reduce {bass['name']} volume to 0.58 (tighten sub)",
179
+ params={"track_index": idx, "volume": target},
180
+ description=f"Reduce {bass['name']} volume to {target:.2f} (tighten sub)",
122
181
  ))
123
- descriptions.append(f"Reduce {bass['name']} volume to 0.58")
182
+ descriptions.append(f"Reduce {bass['name']} volume to {target:.2f}")
124
183
 
125
184
  # Step 3: Verify
126
185
  steps.append(CompiledStep(
@@ -146,7 +205,13 @@ def _compile_tighten_low_end(move: SemanticMove, kernel: dict) -> CompiledPlan:
146
205
 
147
206
 
148
207
  def _compile_widen_stereo(move: SemanticMove, kernel: dict) -> CompiledPlan:
149
- """Compile 'widen_stereo': pan harmonic elements wider, add depth."""
208
+ """Compile 'widen_stereo': pan harmonic elements wider, add depth.
209
+
210
+ Fallback: when no lead/harmonic tracks are role-classified (e.g., tracks
211
+ are named "Q-Call"/"A-Answer" which don't match role keywords), fall back
212
+ to widening prominent non-drum / non-bass tracks. This prevents the move
213
+ from no-opping on mixes with unusually-named tracks.
214
+ """
150
215
  steps = []
151
216
  warnings = []
152
217
  descriptions = []
@@ -155,35 +220,73 @@ def _compile_widen_stereo(move: SemanticMove, kernel: dict) -> CompiledPlan:
155
220
  lead_tracks = resolvers.find_tracks_by_role(kernel, ["lead"])
156
221
  perc_tracks = resolvers.find_tracks_by_role(kernel, ["percussion"])
157
222
 
223
+ # Fallback: if no harmonic/lead tracks found via role, use prominent
224
+ # non-drum/non-bass tracks (any role except drums/bass/fx/unknown=skip).
225
+ # This covers real-world mixes where melodic tracks are named after their
226
+ # musical function ("Q-Call", "A-Answer", "Melody 1") rather than role keywords.
227
+ using_fallback = False
158
228
  if not chord_tracks and not lead_tracks:
159
- warnings.append("No harmonic or lead tracks found for stereo widening")
160
-
161
- # Pan chords left
229
+ all_tracks = resolvers.find_tracks_by_role(
230
+ kernel, ["chords", "pad", "lead", "percussion", "fx", "unknown"]
231
+ )
232
+ # Exclude drums and bass by collecting everything non-drum/non-bass
233
+ exclude_roles = {"drums", "bass"}
234
+ all_raw_tracks = kernel.get("session_info", {}).get("tracks", [])
235
+ fallback_tracks = [
236
+ {
237
+ "index": t.get("index", 0),
238
+ "name": t.get("name", ""),
239
+ "role": resolvers.infer_role(t.get("name", "")),
240
+ "volume": t.get("volume"),
241
+ "pan": t.get("pan"),
242
+ "mute": t.get("mute", False),
243
+ "solo": t.get("solo", False),
244
+ }
245
+ for t in all_raw_tracks
246
+ if resolvers.infer_role(t.get("name", "")) not in exclude_roles
247
+ ]
248
+ if fallback_tracks:
249
+ # Use up to first 2 as a left/right pair
250
+ chord_tracks = fallback_tracks[:1]
251
+ lead_tracks = fallback_tracks[1:2]
252
+ using_fallback = True
253
+ warnings.append(
254
+ "No harmonic or lead tracks found by role — "
255
+ "falling back to widening prominent non-drum/non-bass tracks: "
256
+ + ", ".join(t["name"] for t in fallback_tracks[:2])
257
+ )
258
+ else:
259
+ warnings.append("No harmonic, lead, or wideable tracks found — no changes")
260
+
261
+ # Pan chords/first-fallback left
162
262
  for ct in chord_tracks[:1]:
163
263
  steps.append(CompiledStep(
164
264
  tool="set_track_pan",
165
265
  params={"track_index": ct["index"], "pan": -0.35},
166
- description=f"Pan {ct['name']} left (-35%) for width",
266
+ description=f"Pan {ct['name']} left (-35%) for width"
267
+ + (" [fallback]" if using_fallback else ""),
167
268
  ))
168
269
  descriptions.append(f"Pan {ct['name']} left 35%")
169
270
 
170
- # Pan lead right
271
+ # Pan lead/second-fallback right
171
272
  for lt in lead_tracks[:1]:
172
273
  steps.append(CompiledStep(
173
274
  tool="set_track_pan",
174
275
  params={"track_index": lt["index"], "pan": 0.30},
175
- description=f"Pan {lt['name']} right (+30%) for width",
276
+ description=f"Pan {lt['name']} right (+30%) for width"
277
+ + (" [fallback]" if using_fallback else ""),
176
278
  ))
177
279
  descriptions.append(f"Pan {lt['name']} right 30%")
178
280
 
179
- # Pan perc slightly
180
- for pt in perc_tracks[:1]:
181
- steps.append(CompiledStep(
182
- tool="set_track_pan",
183
- params={"track_index": pt["index"], "pan": 0.15},
184
- description=f"Pan {pt['name']} slightly right (+15%)",
185
- ))
186
- descriptions.append(f"Pan {pt['name']} slightly right")
281
+ # Pan perc slightly (only when using primary role classification)
282
+ if not using_fallback:
283
+ for pt in perc_tracks[:1]:
284
+ steps.append(CompiledStep(
285
+ tool="set_track_pan",
286
+ params={"track_index": pt["index"], "pan": 0.15},
287
+ description=f"Pan {pt['name']} slightly right (+15%)",
288
+ ))
289
+ descriptions.append(f"Pan {pt['name']} slightly right")
187
290
 
188
291
  # Verify
189
292
  steps.append(CompiledStep(
@@ -217,13 +320,17 @@ def _compile_darken_mix(move: SemanticMove, kernel: dict) -> CompiledPlan:
217
320
  bright_tracks = resolvers.find_tracks_by_role(kernel, ["lead", "chords", "percussion"])
218
321
 
219
322
  for bt in bright_tracks[:2]: # Max 2 tracks
220
- # Reduce volume slightly to darken
323
+ # Reduce volume slightly to darken — RELATIVE nudge (P2-21), floored
324
+ # so an already-quiet bright track doesn't get pulled to silence.
325
+ target = resolvers.compile_relative_volume(
326
+ bt.get("volume"), -10, floor=0.20, fallback=0.40
327
+ )
221
328
  steps.append(CompiledStep(
222
329
  tool="set_track_volume",
223
- params={"track_index": bt["index"], "volume": 0.40},
224
- description=f"Pull {bt['name']} to 0.40 for darker tone",
330
+ params={"track_index": bt["index"], "volume": target},
331
+ description=f"Pull {bt['name']} to {target:.2f} for darker tone",
225
332
  ))
226
- descriptions.append(f"Darken {bt['name']} to 0.40")
333
+ descriptions.append(f"Darken {bt['name']} to {target:.2f}")
227
334
 
228
335
  steps.append(CompiledStep(
229
336
  tool="get_track_meters",
@@ -288,12 +395,16 @@ def _compile_reduce_repetition(move: SemanticMove, kernel: dict) -> CompiledPlan
288
395
 
289
396
 
290
397
  def _compile_make_kick_bass_lock(move: SemanticMove, kernel: dict) -> CompiledPlan:
291
- """Compile 'make_kick_bass_lock': carve space between kick and bass.
292
-
293
- Strategy: reduce bass level slightly (clears sub for kick), verify both
294
- tracks remain active. Sidechain compressor insertion is left as a future
295
- step it requires device selection + parameter mapping that varies too
296
- much across projects to hardcode safely.
398
+ """Compile 'make_kick_bass_lock': carve frequency space between kick and bass.
399
+
400
+ Strategy (real frequency separation + timing):
401
+ 1. Read bass EQ/filter state first so we know what's already there.
402
+ 2. Insert an EQ Eight on the bass track (or use an existing one) and
403
+ apply a high-pass shelf dip in the kick's fundamental band (40-80 Hz)
404
+ to clear sub space for the kick.
405
+ 3. Set up a sidechain compressor on the bass keyed from the kick track
406
+ so bass ducks on every kick hit — the defining "lock" gesture.
407
+ 4. Optional: minor volume trim only if needed after the above two steps.
297
408
  """
298
409
  steps: list[CompiledStep] = []
299
410
  warnings: list[str] = []
@@ -319,18 +430,124 @@ def _compile_make_kick_bass_lock(move: SemanticMove, kernel: dict) -> CompiledPl
319
430
 
320
431
  if bass_tracks:
321
432
  bass = bass_tracks[0]
322
- idx = bass["index"]
433
+ bass_idx = bass["index"]
434
+
435
+ # Step: read bass device chain state before touching it
323
436
  steps.append(CompiledStep(
324
- tool="set_track_volume",
325
- params={"track_index": idx, "volume": 0.60},
326
- description=f"Pull {bass['name']} to 0.60 to clear sub for kick",
437
+ tool="get_device_parameters",
438
+ params={"track_index": bass_idx, "device_index": 0},
439
+ description=f"Read {bass['name']} device chain state before carving",
440
+ verify_after=False,
441
+ optional=True,
327
442
  ))
328
- descriptions.append(f"Pull {bass['name']} to 0.60")
443
+
444
+ # Frequency separation: carve a high-pass into an EQ on the bass so the
445
+ # kick's sub-fundamental has room. We only write EQ band parameters when
446
+ # the kernel already exposes a resolvable EQ device on this track — a
447
+ # device inserted in THIS plan has no known index at compile time, so a
448
+ # device_index=0 write would be a blind (wrong-device) target. See
449
+ # tests/test_compiler_safety_contract.py for the invariant this honors.
450
+ eq_idx = _find_eq_device_index(_kernel_track(kernel, bass_idx))
451
+ if eq_idx is not None:
452
+ # EQ confirmed present in the kernel snapshot → safe to carve it.
453
+ # EQ Eight band 1: "1 FilterType" (0 = Low Cut / High-pass),
454
+ # "1 Frequency" (Hz).
455
+ steps.append(CompiledStep(
456
+ tool="set_device_parameter",
457
+ params={
458
+ "track_index": bass_idx,
459
+ "device_index": eq_idx,
460
+ "parameter_name": "1 FilterType",
461
+ "value": 0.0, # Low Cut / High-pass
462
+ },
463
+ description=f"Set EQ band 1 to High-Pass on {bass['name']} (device {eq_idx})",
464
+ ))
465
+ steps.append(CompiledStep(
466
+ tool="set_device_parameter",
467
+ params={
468
+ "track_index": bass_idx,
469
+ "device_index": eq_idx,
470
+ "parameter_name": "1 Frequency",
471
+ "value": 60.0, # 60 Hz — clears kick sub without losing bass body
472
+ },
473
+ description=f"Set HP cutoff to 60 Hz on {bass['name']} EQ (device {eq_idx})",
474
+ ))
475
+ descriptions.append("HP carve @ 60 Hz on existing EQ")
476
+ else:
477
+ # No resolvable EQ → insert one as a scaffold. The HP-carve must be
478
+ # applied AFTER insertion (the new device's index isn't known until
479
+ # runtime); the sidechain below provides the timing lock regardless.
480
+ steps.append(CompiledStep(
481
+ tool="insert_device",
482
+ params={
483
+ "track_index": bass_idx,
484
+ "device_name": "EQ Eight",
485
+ "position": 0,
486
+ },
487
+ description=f"Insert EQ Eight on {bass['name']} for kick-sub carve",
488
+ ))
489
+ descriptions.append(f"EQ Eight on {bass['name']}")
490
+ warnings.append(
491
+ f"Apply a band-1 high-pass (~60 Hz) to the new EQ on "
492
+ f"{bass['name']} after insertion — device index is not "
493
+ "resolvable at plan time."
494
+ )
495
+
496
+ # Timing lock: sidechain a compressor on the bass, keyed from the kick,
497
+ # so the bass ducks on every kick hit. The real compressor_set_sidechain
498
+ # tool only ROUTES the detector input on an EXISTING compressor
499
+ # (signature: track_index, device_index, source_type, source_channel —
500
+ # it does NOT set threshold/ratio/attack/release). So resolve a
501
+ # compressor from the kernel snapshot or insert one as a scaffold (its
502
+ # post-insert index isn't known at plan time → no blind device writes).
503
+ if kick_tracks:
504
+ kick = kick_tracks[0]
505
+ comp_idx = _find_compressor_device_index(_kernel_track(kernel, bass_idx))
506
+ if comp_idx is not None:
507
+ steps.append(CompiledStep(
508
+ tool="set_device_parameter",
509
+ params={
510
+ "track_index": bass_idx,
511
+ "device_index": comp_idx,
512
+ "parameter_name": "S/C On",
513
+ "value": 1.0,
514
+ },
515
+ description=f"Enable sidechain on {bass['name']} compressor (device {comp_idx})",
516
+ ))
517
+ steps.append(CompiledStep(
518
+ tool="compressor_set_sidechain",
519
+ params={
520
+ "track_index": bass_idx,
521
+ "device_index": comp_idx,
522
+ "source_type": kick["name"],
523
+ "source_channel": "Post FX",
524
+ },
525
+ description=(
526
+ f"Route {kick['name']} into {bass['name']} compressor "
527
+ "sidechain — bass ducks on every kick hit"
528
+ ),
529
+ ))
530
+ descriptions.append(f"Sidechain {bass['name']} ← {kick['name']}")
531
+ else:
532
+ steps.append(CompiledStep(
533
+ tool="insert_device",
534
+ params={
535
+ "track_index": bass_idx,
536
+ "device_name": "Compressor",
537
+ },
538
+ description=f"Insert Compressor on {bass['name']} for kick sidechain",
539
+ ))
540
+ descriptions.append(f"Compressor on {bass['name']}")
541
+ warnings.append(
542
+ f"After inserting the Compressor on {bass['name']}, enable "
543
+ f"'S/C On' and route {kick['name']} (Post FX) into its "
544
+ "sidechain — device index is not resolvable at plan time."
545
+ )
329
546
 
330
547
  steps.append(CompiledStep(
331
548
  tool="get_track_meters",
332
549
  params={"include_stereo": True},
333
- description="Verify kick and bass both still producing audio",
550
+ description="Verify kick and bass both still producing audio after carve",
334
551
  ))
335
552
 
336
553
  return CompiledPlan(
@@ -367,23 +584,31 @@ def _compile_create_buildup_tension(move: SemanticMove, kernel: dict) -> Compile
367
584
  if not perc_tracks and not harmony_tracks:
368
585
  warnings.append("No percussion or harmony tracks found — cannot build tension")
369
586
 
370
- # Raise perc for energy
587
+ # Raise perc for energy — RELATIVE nudge (P2-21), capped so an already
588
+ # hot perc bus doesn't clip.
371
589
  for pt in perc_tracks[:1]:
590
+ target = resolvers.compile_relative_volume(
591
+ pt.get("volume"), 10, cap=0.90, fallback=0.78
592
+ )
372
593
  steps.append(CompiledStep(
373
594
  tool="set_track_volume",
374
- params={"track_index": pt["index"], "volume": 0.78},
375
- description=f"Push {pt['name']} to 0.78 for rising energy",
595
+ params={"track_index": pt["index"], "volume": target},
596
+ description=f"Push {pt['name']} to {target:.2f} for rising energy",
376
597
  ))
377
- descriptions.append(f"Push {pt['name']} to 0.78")
598
+ descriptions.append(f"Push {pt['name']} to {target:.2f}")
378
599
 
379
- # Pull harmony slightly to amplify perc contrast
600
+ # Pull harmony slightly to amplify perc contrast — RELATIVE nudge,
601
+ # floored so a quiet harmony bed doesn't get pulled to silence.
380
602
  for ht in harmony_tracks[:1]:
603
+ target = resolvers.compile_relative_volume(
604
+ ht.get("volume"), -12, floor=0.20, fallback=0.35
605
+ )
381
606
  steps.append(CompiledStep(
382
607
  tool="set_track_volume",
383
- params={"track_index": ht["index"], "volume": 0.35},
384
- description=f"Pull {ht['name']} to 0.35 to create harmonic vacuum before drop",
608
+ params={"track_index": ht["index"], "volume": target},
609
+ description=f"Pull {ht['name']} to {target:.2f} to create harmonic vacuum before drop",
385
610
  ))
386
- descriptions.append(f"Pull {ht['name']} to 0.35")
611
+ descriptions.append(f"Pull {ht['name']} to {target:.2f}")
387
612
 
388
613
  steps.append(CompiledStep(
389
614
  tool="get_track_meters",
@@ -19,21 +19,33 @@ def _compile_recover_energy(move: SemanticMove, kernel: dict) -> CompiledPlan:
19
19
  drum_tracks = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
20
20
  bass_tracks = resolvers.find_tracks_by_role(kernel, ["bass"])
21
21
 
22
+ # RELATIVE nudge (P2-21 pattern, ported from resolvers.compile_relative_volume
23
+ # / mix_compilers.py): "recover" means push UP from wherever the track
24
+ # already sits, not overwrite it with a flat absolute level — a drum bus
25
+ # already sitting hot must not get pulled DOWN to 0.70. Live-set-safe:
26
+ # small +12% nudge, capped just above the historical fallback so a
27
+ # recovering track lands in the same ballpark it always did.
22
28
  for dt in drum_tracks[:1]:
29
+ target = resolvers.compile_relative_volume(
30
+ dt.get("volume"), 12, cap=0.78, fallback=0.70
31
+ )
23
32
  steps.append(CompiledStep(
24
33
  tool="set_track_volume",
25
- params={"track_index": dt["index"], "volume": 0.70},
26
- description=f"Restore {dt['name']} to 0.70 for energy recovery",
34
+ params={"track_index": dt["index"], "volume": target},
35
+ description=f"Restore {dt['name']} to {target:.2f} for energy recovery",
27
36
  ))
28
- descriptions.append(f"Restore {dt['name']} to 0.70")
37
+ descriptions.append(f"Restore {dt['name']} to {target:.2f}")
29
38
 
30
39
  for bt in bass_tracks[:1]:
40
+ target = resolvers.compile_relative_volume(
41
+ bt.get("volume"), 12, cap=0.68, fallback=0.60
42
+ )
31
43
  steps.append(CompiledStep(
32
44
  tool="set_track_volume",
33
- params={"track_index": bt["index"], "volume": 0.60},
34
- description=f"Restore {bt['name']} to 0.60",
45
+ params={"track_index": bt["index"], "volume": target},
46
+ description=f"Restore {bt['name']} to {target:.2f}",
35
47
  ))
36
- descriptions.append(f"Restore {bt['name']} to 0.60")
48
+ descriptions.append(f"Restore {bt['name']} to {target:.2f}")
37
49
 
38
50
  # Pull reverb back to tighten
39
51
  pad_tracks = resolvers.find_tracks_by_role(kernel, ["pad", "chords"])
@@ -69,13 +81,19 @@ def _compile_decompress_tension(move: SemanticMove, kernel: dict) -> CompiledPla
69
81
  lead_tracks = resolvers.find_tracks_by_role(kernel, ["lead", "chords"])
70
82
  pad_tracks = resolvers.find_tracks_by_role(kernel, ["pad"])
71
83
 
84
+ # RELATIVE nudge (P2-21 pattern) — "decompress" means pull DOWN from the
85
+ # current level, floored so an already-quiet lead doesn't get pulled
86
+ # toward silence. Small -12% nudge, live-set-safe.
72
87
  for lt in lead_tracks[:2]:
88
+ target = resolvers.compile_relative_volume(
89
+ lt.get("volume"), -12, floor=0.20, fallback=0.35
90
+ )
73
91
  steps.append(CompiledStep(
74
92
  tool="set_track_volume",
75
- params={"track_index": lt["index"], "volume": 0.35},
76
- description=f"Pull {lt['name']} to 0.35 for decompression",
93
+ params={"track_index": lt["index"], "volume": target},
94
+ description=f"Pull {lt['name']} to {target:.2f} for decompression",
77
95
  ))
78
- descriptions.append(f"Pull {lt['name']} to 0.35")
96
+ descriptions.append(f"Pull {lt['name']} to {target:.2f}")
79
97
 
80
98
  for pt in pad_tracks[:1]:
81
99
  steps.append(CompiledStep(
@@ -121,7 +139,9 @@ def _compile_safe_spotlight(move: SemanticMove, kernel: dict) -> CompiledPlan:
121
139
  spotlight_idx = spotlight.get("index", 0)
122
140
  spotlight_name = spotlight.get("name", f"Track {spotlight_idx}")
123
141
 
124
- # Pull non-spotlight audio tracks
142
+ # Pull non-spotlight audio tracks — RELATIVE nudge (P2-21 pattern),
143
+ # floored so an already-quiet background track doesn't get pulled
144
+ # toward silence.
125
145
  for track in all_tracks:
126
146
  idx = track.get("index", 0)
127
147
  name = track.get("name", "")
@@ -129,17 +149,24 @@ def _compile_safe_spotlight(move: SemanticMove, kernel: dict) -> CompiledPlan:
129
149
  continue
130
150
  if track.get("type") in ("return", "master"):
131
151
  continue
152
+ target = resolvers.compile_relative_volume(
153
+ track.get("volume"), -15, floor=0.15, fallback=0.30
154
+ )
132
155
  steps.append(CompiledStep(
133
156
  tool="set_track_volume",
134
- params={"track_index": idx, "volume": 0.30},
135
- description=f"Pull {name} to 0.30 (background)",
157
+ params={"track_index": idx, "volume": target},
158
+ description=f"Pull {name} to {target:.2f} (background)",
136
159
  ))
137
160
 
138
- # Push spotlight
161
+ # Push spotlight — RELATIVE nudge, capped just above the historical
162
+ # fallback so the spotlight lands in the same ballpark it always did.
163
+ spotlight_target = resolvers.compile_relative_volume(
164
+ spotlight.get("volume"), 10, cap=0.85, fallback=0.82
165
+ )
139
166
  steps.append(CompiledStep(
140
167
  tool="set_track_volume",
141
- params={"track_index": spotlight_idx, "volume": 0.82},
142
- description=f"Push spotlight {spotlight_name} to 0.82",
168
+ params={"track_index": spotlight_idx, "volume": spotlight_target},
169
+ description=f"Push spotlight {spotlight_name} to {spotlight_target:.2f}",
143
170
  ))
144
171
  descriptions.append(f"Spotlight {spotlight_name}")
145
172
 
@@ -176,10 +203,17 @@ def _compile_emergency_simplify(move: SemanticMove, kernel: dict) -> CompiledPla
176
203
  continue
177
204
  if idx in keep_indices:
178
205
  continue
206
+ # RELATIVE nudge (P2-21 pattern) — "emergency simplify" genuinely
207
+ # means strip aggressively, so the delta is intentionally large
208
+ # (-25%), but still bounded (floor=0.05) and direction-safe: a
209
+ # track already quieter than 0.05 doesn't get nudged back UP.
210
+ target = resolvers.compile_relative_volume(
211
+ track.get("volume"), -25, floor=0.05, fallback=0.10
212
+ )
179
213
  steps.append(CompiledStep(
180
214
  tool="set_track_volume",
181
- params={"track_index": idx, "volume": 0.10},
182
- description=f"Strip {name} to 0.10 (emergency simplify)",
215
+ params={"track_index": idx, "volume": target},
216
+ description=f"Strip {name} to {target:.2f} (emergency simplify)",
183
217
  ))
184
218
 
185
219
  descriptions.append("Strip to drums+bass only")