livepilot 1.27.2 → 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 (113) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +97 -39
  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 +1 -1
  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/framework/atlas_resolver.py +16 -1
  28. package/mcp_server/composer/full/apply.py +40 -34
  29. package/mcp_server/composer/full/engine.py +66 -32
  30. package/mcp_server/composer/tools.py +4 -3
  31. package/mcp_server/connection.py +67 -9
  32. package/mcp_server/creative_constraints/engine.py +10 -2
  33. package/mcp_server/creative_constraints/tools.py +24 -7
  34. package/mcp_server/curves.py +30 -7
  35. package/mcp_server/device_forge/builder.py +40 -11
  36. package/mcp_server/device_forge/models.py +9 -0
  37. package/mcp_server/device_forge/tools.py +30 -11
  38. package/mcp_server/experiment/engine.py +29 -22
  39. package/mcp_server/experiment/tools.py +15 -5
  40. package/mcp_server/m4l_bridge.py +69 -7
  41. package/mcp_server/memory/taste_graph.py +43 -0
  42. package/mcp_server/memory/technique_store.py +26 -3
  43. package/mcp_server/memory/tools.py +62 -4
  44. package/mcp_server/mix_engine/critics.py +187 -30
  45. package/mcp_server/mix_engine/models.py +21 -1
  46. package/mcp_server/mix_engine/state_builder.py +87 -8
  47. package/mcp_server/mix_engine/tools.py +16 -4
  48. package/mcp_server/performance_engine/tools.py +56 -8
  49. package/mcp_server/persistence/base_store.py +11 -0
  50. package/mcp_server/persistence/project_store.py +132 -8
  51. package/mcp_server/persistence/taste_store.py +90 -7
  52. package/mcp_server/preview_studio/engine.py +40 -10
  53. package/mcp_server/preview_studio/models.py +40 -0
  54. package/mcp_server/preview_studio/tools.py +56 -12
  55. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  56. package/mcp_server/project_brain/models.py +2 -0
  57. package/mcp_server/project_brain/role_graph.py +13 -7
  58. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  59. package/mcp_server/reference_engine/profile_builder.py +47 -4
  60. package/mcp_server/reference_engine/tools.py +6 -0
  61. package/mcp_server/runtime/execution_router.py +51 -1
  62. package/mcp_server/runtime/tools.py +0 -1
  63. package/mcp_server/sample_engine/sources.py +0 -2
  64. package/mcp_server/sample_engine/tools.py +19 -38
  65. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  66. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  67. package/mcp_server/semantic_moves/resolvers.py +45 -0
  68. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  69. package/mcp_server/semantic_moves/tools.py +80 -2
  70. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  71. package/mcp_server/server.py +26 -24
  72. package/mcp_server/session_continuity/tracker.py +51 -3
  73. package/mcp_server/song_brain/builder.py +47 -5
  74. package/mcp_server/song_brain/tools.py +21 -7
  75. package/mcp_server/sound_design/critics.py +1 -0
  76. package/mcp_server/splice_client/client.py +117 -33
  77. package/mcp_server/splice_client/http_bridge.py +15 -3
  78. package/mcp_server/splice_client/quota.py +28 -0
  79. package/mcp_server/stuckness_detector/detector.py +8 -5
  80. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  81. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  82. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  83. package/mcp_server/tools/_perception_engine.py +6 -0
  84. package/mcp_server/tools/agent_os.py +4 -1
  85. package/mcp_server/tools/analyzer.py +198 -209
  86. package/mcp_server/tools/arrangement.py +7 -4
  87. package/mcp_server/tools/automation.py +24 -4
  88. package/mcp_server/tools/browser.py +25 -11
  89. package/mcp_server/tools/clips.py +6 -0
  90. package/mcp_server/tools/composition.py +33 -2
  91. package/mcp_server/tools/devices.py +53 -53
  92. package/mcp_server/tools/generative.py +14 -14
  93. package/mcp_server/tools/harmony.py +7 -7
  94. package/mcp_server/tools/mixing.py +4 -4
  95. package/mcp_server/tools/planner.py +68 -6
  96. package/mcp_server/tools/research.py +20 -2
  97. package/mcp_server/tools/theory.py +10 -10
  98. package/mcp_server/tools/transport.py +7 -2
  99. package/mcp_server/transition_engine/critics.py +13 -1
  100. package/mcp_server/user_corpus/tools.py +30 -1
  101. package/mcp_server/wonder_mode/engine.py +82 -9
  102. package/mcp_server/wonder_mode/session.py +32 -10
  103. package/mcp_server/wonder_mode/tools.py +14 -1
  104. package/package.json +1 -1
  105. package/remote_script/LivePilot/__init__.py +1 -1
  106. package/remote_script/LivePilot/arrangement.py +93 -33
  107. package/remote_script/LivePilot/browser.py +60 -4
  108. package/remote_script/LivePilot/devices.py +132 -62
  109. package/remote_script/LivePilot/mixing.py +31 -5
  110. package/remote_script/LivePilot/server.py +94 -22
  111. package/remote_script/LivePilot/transport.py +11 -0
  112. package/requirements.txt +5 -5
  113. package/server.json +2 -2
@@ -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")
@@ -124,3 +124,48 @@ def adjust_volume(current: float, delta_percent: float) -> float:
124
124
  """Adjust a volume by a percentage. delta_percent=5 means +5%."""
125
125
  new = current + (delta_percent / 100.0)
126
126
  return clamp_volume(new)
127
+
128
+
129
+ def compile_relative_volume(
130
+ current: Optional[float],
131
+ delta_percent: float,
132
+ *,
133
+ cap: Optional[float] = None,
134
+ floor: Optional[float] = None,
135
+ fallback: float,
136
+ ) -> float:
137
+ """Compile a bounded RELATIVE volume nudge from a track's current level.
138
+
139
+ P2-21 fix: mix/sound-design/transition compilers used to write an
140
+ ABSOLUTE volume regardless of what was already on the track — e.g.
141
+ "make it punchier" could turn a hot drum bus DOWN by blindly writing
142
+ a flat 0.75. This computes current + delta_percent% instead (via
143
+ adjust_volume), then clamps to an optional cap/floor so the move's
144
+ musical intent (push toward louder / pull toward quieter) still holds
145
+ even when starting from an unusual level.
146
+
147
+ `current` is None when the connected Remote Script predates the
148
+ `volume` field in get_session_info (see transport.py get_session_info)
149
+ — in that case this falls back to the historical absolute value so
150
+ the compiler degrades gracefully instead of crashing or guessing.
151
+
152
+ The cap/floor bound the SIZE of the nudge — they must never flip its
153
+ DIRECTION. A track already louder than `cap` must not get pulled back
154
+ down to the cap when the move means to push it UP (that would
155
+ reintroduce the P2-21 bug in a smaller way); symmetrically a track
156
+ already quieter than `floor` must not get nudged back UP when the
157
+ move means to pull it DOWN. So after clamping to [floor, cap], the
158
+ result is re-clamped against `current` in the direction of travel.
159
+ """
160
+ if current is None:
161
+ return fallback
162
+ target = adjust_volume(current, delta_percent)
163
+ if cap is not None:
164
+ target = min(target, cap)
165
+ if floor is not None:
166
+ target = max(target, floor)
167
+ if delta_percent > 0:
168
+ target = max(target, current)
169
+ elif delta_percent < 0:
170
+ target = min(target, current)
171
+ return clamp_volume(target)
@@ -33,11 +33,15 @@ def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
33
33
  idx = t["index"]
34
34
  name = t["name"]
35
35
 
36
- # Boost volume slightly for perceived warmth
36
+ # Boost volume slightly for perceived warmth — RELATIVE nudge
37
+ # (P2-21), capped so an already-hot track isn't pushed toward clip.
38
+ target = resolvers.compile_relative_volume(
39
+ t.get("volume"), 6, cap=0.80, fallback=0.65
40
+ )
37
41
  steps.append(CompiledStep(
38
42
  tool="set_track_volume",
39
- params={"track_index": idx, "volume": 0.65},
40
- description=f"Boost {name} slightly for warmth",
43
+ params={"track_index": idx, "volume": target},
44
+ description=f"Boost {name} to {target:.2f} for warmth",
41
45
  ))
42
46
 
43
47
  # Add reverb send for depth/warmth perception
@@ -138,11 +142,15 @@ def _compile_shape_transients(move: SemanticMove, kernel: dict) -> CompiledPlan:
138
142
  idx = dt["index"]
139
143
  name = dt["name"]
140
144
 
141
- # Push volume for transient punch
145
+ # Push volume for transient punch — RELATIVE nudge (P2-21), capped
146
+ # so an already-hot drum bus isn't pushed toward clip.
147
+ target = resolvers.compile_relative_volume(
148
+ dt.get("volume"), 8, cap=0.85, fallback=0.75
149
+ )
142
150
  steps.append(CompiledStep(
143
151
  tool="set_track_volume",
144
- params={"track_index": idx, "volume": 0.75},
145
- description=f"Push {name} to 0.75 for transient punch",
152
+ params={"track_index": idx, "volume": target},
153
+ description=f"Push {name} to {target:.2f} for transient punch",
146
154
  ))
147
155
  descriptions.append(f"Push {name} for punch")
148
156