livepilot 1.10.6 → 1.10.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +168 -0
- package/README.md +12 -10
- package/bin/livepilot.js +168 -30
- package/installer/install.js +117 -11
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +215 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +132 -33
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- package/mcp_server/creative_constraints/tools.py +206 -33
- package/mcp_server/experiment/engine.py +7 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +60 -9
- package/mcp_server/m4l_bridge.py +68 -12
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/performance_engine/tools.py +112 -29
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +22 -6
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +55 -5
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +47 -6
- package/mcp_server/runtime/execution_router.py +66 -2
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +10 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +42 -4
- package/mcp_server/sample_engine/tools.py +48 -14
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +15 -4
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +75 -5
- package/mcp_server/services/singletons.py +68 -0
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tracker.py +14 -1
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +77 -13
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +29 -8
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +41 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
- package/mcp_server/tools/_composition_engine/__init__.py +2 -2
- package/mcp_server/tools/_composition_engine/harmony.py +90 -0
- package/mcp_server/tools/_composition_engine/sections.py +47 -4
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +20 -3
- package/mcp_server/tools/analyzer.py +105 -6
- package/mcp_server/tools/clips.py +46 -1
- package/mcp_server/tools/composition.py +66 -23
- package/mcp_server/tools/devices.py +22 -1
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +23 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +49 -3
- package/mcp_server/tools/research.py +24 -0
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +77 -2
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/router.py +13 -1
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcpbignore +0 -57
- package/AGENTS.md +0 -46
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -131
- package/scripts/sync_metadata.py +0 -132
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -278,26 +278,68 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
278
278
|
self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
|
|
279
279
|
|
|
280
280
|
def _handle_response(self, encoded: str) -> None:
|
|
281
|
-
"""Decode a single-packet base64 response.
|
|
281
|
+
"""Decode a single-packet base64 response.
|
|
282
|
+
|
|
283
|
+
Resolves _response_callback exactly once, then clears it. Without the
|
|
284
|
+
clear, a second late packet could overwrite a future belonging to a
|
|
285
|
+
different in-flight command. The protocol has no request id yet
|
|
286
|
+
(livepilot_bridge.js:666 emits bare /response), so correlation relies
|
|
287
|
+
on the single-command-in-flight invariant enforced by M4LBridge._cmd_lock
|
|
288
|
+
plus this one-shot clear.
|
|
289
|
+
"""
|
|
282
290
|
try:
|
|
283
291
|
# URL-safe base64 decode (- and _ instead of + and /)
|
|
284
292
|
padded = encoded + "=" * (-len(encoded) % 4)
|
|
285
293
|
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
|
|
286
294
|
result = _normalize_bridge_payload(json.loads(decoded))
|
|
287
|
-
|
|
288
|
-
|
|
295
|
+
cb = self._response_callback
|
|
296
|
+
if cb and not cb.done():
|
|
297
|
+
cb.set_result(result)
|
|
298
|
+
# Clear regardless — either we consumed it, or it was already
|
|
299
|
+
# done/abandoned. Future packets with no owner get dropped.
|
|
300
|
+
self._response_callback = None
|
|
289
301
|
except Exception as exc:
|
|
290
302
|
import sys
|
|
291
303
|
print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
|
|
292
304
|
|
|
293
305
|
def _handle_chunk(self, index: int, total: int, encoded: str) -> None:
|
|
294
|
-
"""Reassemble chunked responses.
|
|
306
|
+
"""Reassemble chunked responses.
|
|
307
|
+
|
|
308
|
+
The previous implementation incremented ``_chunk_id`` only when
|
|
309
|
+
``index == 0`` and assumed the first chunk always arrived first.
|
|
310
|
+
Under UDP reordering (rare on loopback but possible under system
|
|
311
|
+
load), a chunk with ``index > 0`` arriving before ``index 0`` would
|
|
312
|
+
be dropped into the PREVIOUS sequence's bucket — silently corrupting
|
|
313
|
+
that earlier response's payload.
|
|
314
|
+
|
|
315
|
+
Until the wire protocol adds an explicit sequence id, the safer
|
|
316
|
+
behavior is: if we see an out-of-order first-chunk (``index > 0``
|
|
317
|
+
with no open bucket), start a fresh bucket but log a warning. That
|
|
318
|
+
way we never poison a prior sequence, and the problem surfaces in
|
|
319
|
+
logs if it happens.
|
|
320
|
+
"""
|
|
295
321
|
if index == 0:
|
|
296
322
|
self._chunk_id += 1
|
|
297
|
-
|
|
298
|
-
if key not in self._chunks:
|
|
323
|
+
key = str(self._chunk_id)
|
|
299
324
|
self._chunks[key] = {"parts": {}, "total": total}
|
|
300
325
|
self._chunk_times[key] = time.monotonic()
|
|
326
|
+
else:
|
|
327
|
+
key = str(self._chunk_id)
|
|
328
|
+
if key not in self._chunks:
|
|
329
|
+
# Out-of-order arrival. Start a new bucket rather than append
|
|
330
|
+
# to the previous sequence's parts — that's the corruption
|
|
331
|
+
# path. Log once so it's diagnosable.
|
|
332
|
+
import sys
|
|
333
|
+
print(
|
|
334
|
+
f"LivePilot: chunk index={index}/{total} arrived before "
|
|
335
|
+
f"index=0 — starting fresh bucket. UDP reordering on "
|
|
336
|
+
f"loopback suggests system load.",
|
|
337
|
+
file=sys.stderr,
|
|
338
|
+
)
|
|
339
|
+
self._chunk_id += 1
|
|
340
|
+
key = str(self._chunk_id)
|
|
341
|
+
self._chunks[key] = {"parts": {}, "total": total}
|
|
342
|
+
self._chunk_times[key] = time.monotonic()
|
|
301
343
|
|
|
302
344
|
self._chunks[key]["parts"][index] = encoded
|
|
303
345
|
|
|
@@ -357,14 +399,26 @@ class M4LBridge:
|
|
|
357
399
|
if not self.cache.is_connected:
|
|
358
400
|
return {"error": "LivePilot Analyzer not connected. Drop it on the master track."}
|
|
359
401
|
|
|
402
|
+
# Fail fast if there is no receiver to correlate the response. The
|
|
403
|
+
# previous version sent the OSC packet anyway, dropped the reply
|
|
404
|
+
# inside _handle_response (no future registered), and waited out
|
|
405
|
+
# the full 5s timeout before returning a misleading "device may be
|
|
406
|
+
# busy or removed" error. The real cause was "no receiver wired",
|
|
407
|
+
# which the caller should see immediately.
|
|
408
|
+
if self.receiver is None:
|
|
409
|
+
return {
|
|
410
|
+
"error": "M4L bridge has no active receiver — the UDP 9880 "
|
|
411
|
+
"listener did not start. Check server startup logs "
|
|
412
|
+
"for a bind failure on port 9880."
|
|
413
|
+
}
|
|
414
|
+
|
|
360
415
|
if self._cmd_lock is None:
|
|
361
416
|
self._cmd_lock = asyncio.Lock()
|
|
362
417
|
async with self._cmd_lock:
|
|
363
418
|
# Create a future for the response
|
|
364
419
|
loop = asyncio.get_running_loop()
|
|
365
420
|
future = loop.create_future()
|
|
366
|
-
|
|
367
|
-
self.receiver.set_response_future(future)
|
|
421
|
+
self.receiver.set_response_future(future)
|
|
368
422
|
|
|
369
423
|
# Build and send OSC message (no leading / — Max udpreceive
|
|
370
424
|
# passes messagename with / intact to JS, breaking dispatch)
|
|
@@ -376,11 +430,13 @@ class M4LBridge:
|
|
|
376
430
|
result = await asyncio.wait_for(future, timeout=timeout)
|
|
377
431
|
return result
|
|
378
432
|
except asyncio.TimeoutError:
|
|
379
|
-
# Clear the stale future so a delayed response doesn't resolve
|
|
380
|
-
# a future that no caller is waiting on
|
|
381
|
-
if self.receiver:
|
|
382
|
-
self.receiver.set_response_future(None)
|
|
383
433
|
return {"error": "M4L bridge timeout — device may be busy or removed"}
|
|
434
|
+
finally:
|
|
435
|
+
# Always clear the future — on success the receiver has already
|
|
436
|
+
# cleared it inside _handle_response, but calling again is a
|
|
437
|
+
# no-op. On timeout this is what prevents a delayed packet from
|
|
438
|
+
# resolving a future belonging to the next command.
|
|
439
|
+
self.receiver.set_response_future(None)
|
|
384
440
|
|
|
385
441
|
async def send_capture(self, command: str, *args: Any, timeout: float = 35.0) -> dict:
|
|
386
442
|
"""Send a capture command to the M4L device and wait for /capture_complete."""
|
|
@@ -208,11 +208,43 @@ def detect_role_conflicts(
|
|
|
208
208
|
"Layer drum parts into one Drum Rack or pan them apart"),
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
# BUG-B1 fix: intentional drum + percussion layering is the core
|
|
212
|
+
# aesthetic in hip-hop / Dilla / lo-fi / beat-scene music, not a
|
|
213
|
+
# conflict. Heuristic to demote drum-role conflicts when the track
|
|
214
|
+
# names make that layering obvious (one "DRUMS" + one "PERC/CONGA/
|
|
215
|
+
# SHAKER" is distinct instruments, not a fight for the same role).
|
|
216
|
+
_PERC_NAMES = {
|
|
217
|
+
"perc", "percussion", "conga", "congas", "shaker",
|
|
218
|
+
"tambourine", "cowbell", "triangle", "bongo",
|
|
219
|
+
"djembe", "claves", "hi-hat", "hihat", "hat",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def _looks_like_layering(group: list[dict]) -> bool:
|
|
223
|
+
"""True if at least one of the tracks has a percussion-specific
|
|
224
|
+
name (distinct from the main drum kit)."""
|
|
225
|
+
if len(group) < 2:
|
|
226
|
+
return False
|
|
227
|
+
perc_track_count = 0
|
|
228
|
+
for track in group:
|
|
229
|
+
name = str(track.get("name", "")).lower()
|
|
230
|
+
if any(tok in name for tok in _PERC_NAMES):
|
|
231
|
+
perc_track_count += 1
|
|
232
|
+
# Needs at least one main "drums" track AND one perc track
|
|
233
|
+
return 1 <= perc_track_count < len(group)
|
|
234
|
+
|
|
211
235
|
conflicts = []
|
|
212
236
|
for role, (desc, rec) in UNIQUE_ROLES.items():
|
|
213
237
|
group = role_groups.get(role, [])
|
|
214
238
|
if len(group) > 1:
|
|
215
239
|
severity = min(0.9, 0.3 + (len(group) - 1) * 0.2)
|
|
240
|
+
if role == "drums" and _looks_like_layering(group):
|
|
241
|
+
# Demote severity — this looks intentional, not a conflict
|
|
242
|
+
severity = max(0.1, severity - 0.4)
|
|
243
|
+
rec = (
|
|
244
|
+
"Drum + percussion layering detected — if this is "
|
|
245
|
+
"intentional (hip-hop / Dilla / lo-fi), ignore. "
|
|
246
|
+
"Otherwise: " + rec
|
|
247
|
+
)
|
|
216
248
|
conflicts.append(RoleConflict(
|
|
217
249
|
role=role,
|
|
218
250
|
tracks=group,
|
|
@@ -21,34 +21,59 @@ logger = logging.getLogger(__name__)
|
|
|
21
21
|
# ── Helpers ─────────────────────────────────────────────────────────
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
# BUG-E4 / E5 fix: performance_engine used to have its own _infer_role() keyword
|
|
25
|
+
# list and _infer_energy() static {role → number} table. Those diverged from
|
|
26
|
+
# _composition_engine's richer section classifier, which caused
|
|
27
|
+
# get_performance_state and analyze_composition to label the same scenes
|
|
28
|
+
# differently (Deep Flow: drop vs verse, Sun Peak: drop vs chorus) and to
|
|
29
|
+
# report dissimilar energies (composition derived from active-track density,
|
|
30
|
+
# performance looked up a hard-coded 0.2/0.4/0.7 table). Now performance
|
|
31
|
+
# consumes composition's section graph as the source of truth and only keeps
|
|
32
|
+
# a positional fallback for scenes without enough data.
|
|
33
|
+
_POSITIONAL_FALLBACK_ROLES = {
|
|
34
|
+
"first": "intro",
|
|
35
|
+
"last": "outro",
|
|
36
|
+
"early": "intro",
|
|
37
|
+
"middle_low": "verse",
|
|
38
|
+
"middle_high": "chorus",
|
|
39
|
+
"late": "outro",
|
|
40
|
+
"default": "verse",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _positional_fallback_role(index: int, scene_count: int) -> str:
|
|
45
|
+
"""Map a scene index to a role when no composition data is available.
|
|
46
|
+
|
|
47
|
+
Kept only as a last-resort so we still produce a sensible answer for
|
|
48
|
+
unnamed scenes or when build_section_graph_from_scenes returns empty.
|
|
49
|
+
Callers should prefer the composition-engine result when it exists.
|
|
50
|
+
"""
|
|
51
|
+
if scene_count <= 0:
|
|
52
|
+
return _POSITIONAL_FALLBACK_ROLES["default"]
|
|
31
53
|
if index == 0:
|
|
32
|
-
return "
|
|
54
|
+
return _POSITIONAL_FALLBACK_ROLES["first"]
|
|
33
55
|
if index == scene_count - 1:
|
|
34
|
-
return "
|
|
56
|
+
return _POSITIONAL_FALLBACK_ROLES["last"]
|
|
35
57
|
if scene_count > 4:
|
|
36
|
-
quarter = scene_count / 4
|
|
58
|
+
quarter = scene_count / 4.0
|
|
37
59
|
if index < quarter:
|
|
38
|
-
return "
|
|
39
|
-
|
|
40
|
-
return "
|
|
41
|
-
|
|
42
|
-
return "
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
return _POSITIONAL_FALLBACK_ROLES["early"]
|
|
61
|
+
if index < quarter * 2:
|
|
62
|
+
return _POSITIONAL_FALLBACK_ROLES["middle_low"]
|
|
63
|
+
if index < quarter * 3:
|
|
64
|
+
return _POSITIONAL_FALLBACK_ROLES["middle_high"]
|
|
65
|
+
return _POSITIONAL_FALLBACK_ROLES["late"]
|
|
66
|
+
return _POSITIONAL_FALLBACK_ROLES["default"]
|
|
67
|
+
|
|
46
68
|
|
|
69
|
+
def _positional_fallback_energy(role: str) -> float:
|
|
70
|
+
"""Static energy map used only when density is unavailable.
|
|
47
71
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
Kept tiny and explicit so the fallback path is obvious — the primary
|
|
73
|
+
source of energy is _composition_engine's density-based value.
|
|
74
|
+
"""
|
|
75
|
+
return {
|
|
76
|
+
"intro": 0.3,
|
|
52
77
|
"verse": 0.4,
|
|
53
78
|
"build": 0.6,
|
|
54
79
|
"chorus": 0.7,
|
|
@@ -56,23 +81,82 @@ def _infer_energy(role: str) -> float:
|
|
|
56
81
|
"breakdown": 0.3,
|
|
57
82
|
"transition": 0.5,
|
|
58
83
|
"outro": 0.2,
|
|
59
|
-
}
|
|
60
|
-
return energy_map.get(role, 0.5)
|
|
84
|
+
}.get(role, 0.5)
|
|
61
85
|
|
|
62
86
|
|
|
63
87
|
def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
|
|
64
|
-
"""Fetch scene info from Ableton and build SceneRole list.
|
|
88
|
+
"""Fetch scene info + composition graph from Ableton and build SceneRole list.
|
|
89
|
+
|
|
90
|
+
BUG-E4 / E5 fix: roles + energies now flow from composition_engine's
|
|
91
|
+
build_section_graph_from_scenes, which uses keyword matching + active-
|
|
92
|
+
track density for energy. Unnamed scenes fall back to the positional
|
|
93
|
+
heuristic. This keeps get_performance_state in sync with
|
|
94
|
+
get_section_graph / analyze_composition.
|
|
95
|
+
"""
|
|
96
|
+
from ..tools._composition_engine import (
|
|
97
|
+
build_section_graph_from_scenes,
|
|
98
|
+
SectionNode as CESectionNode,
|
|
99
|
+
)
|
|
100
|
+
|
|
65
101
|
ableton = ctx.lifespan_context["ableton"]
|
|
66
102
|
|
|
67
103
|
scenes_info = ableton.send_command("get_scenes_info", {})
|
|
68
104
|
scenes_list = scenes_info.get("scenes", [])
|
|
69
105
|
scene_count = len(scenes_list)
|
|
70
106
|
|
|
107
|
+
# Pull session topology + clip matrix so composition engine can compute
|
|
108
|
+
# active-track density. If any of these fails we fall back to the
|
|
109
|
+
# positional heuristic — preserving the old behavior as a safety net.
|
|
110
|
+
track_count = 0
|
|
111
|
+
clip_matrix: list[list[dict]] = []
|
|
112
|
+
try:
|
|
113
|
+
session_info = ableton.send_command("get_session_info", {})
|
|
114
|
+
track_count = int(session_info.get("track_count", 0))
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
logger.debug("_fetch_scene_data session_info failed: %s", exc)
|
|
117
|
+
try:
|
|
118
|
+
mtx = ableton.send_command("get_scene_matrix", {})
|
|
119
|
+
if isinstance(mtx, dict):
|
|
120
|
+
clip_matrix = mtx.get("matrix", []) or []
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
logger.debug("_fetch_scene_data scene_matrix failed: %s", exc)
|
|
123
|
+
|
|
124
|
+
# Build the composition section graph. Each SectionNode has
|
|
125
|
+
# section_id = f"sec_{raw_enumerate_index:02d}" per BUG-E1 fix, so we
|
|
126
|
+
# can index by scene position directly.
|
|
127
|
+
ce_sections: list[CESectionNode] = []
|
|
128
|
+
try:
|
|
129
|
+
if scenes_list and clip_matrix and track_count > 0:
|
|
130
|
+
ce_sections = build_section_graph_from_scenes(
|
|
131
|
+
scenes_list, clip_matrix, track_count,
|
|
132
|
+
)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
logger.debug("_fetch_scene_data section graph failed: %s", exc)
|
|
135
|
+
|
|
136
|
+
ce_by_scene_idx: dict[int, CESectionNode] = {}
|
|
137
|
+
for sec in ce_sections:
|
|
138
|
+
# section_id format "sec_02" → scene index 2 (raw enumerate index)
|
|
139
|
+
sid = str(sec.section_id)
|
|
140
|
+
if sid.startswith("sec_"):
|
|
141
|
+
try:
|
|
142
|
+
ce_by_scene_idx[int(sid[4:])] = sec
|
|
143
|
+
except ValueError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
71
146
|
scene_roles: list[SceneRole] = []
|
|
72
147
|
for i, scene_data in enumerate(scenes_list):
|
|
73
148
|
name = scene_data.get("name", f"Scene {i}")
|
|
74
|
-
|
|
75
|
-
|
|
149
|
+
ce_sec = ce_by_scene_idx.get(i)
|
|
150
|
+
if ce_sec is not None:
|
|
151
|
+
# SectionType is an enum; .value gives the string vocabulary
|
|
152
|
+
stype = ce_sec.section_type
|
|
153
|
+
role = stype.value if hasattr(stype, "value") else str(stype)
|
|
154
|
+
energy = float(ce_sec.energy)
|
|
155
|
+
else:
|
|
156
|
+
# Unnamed scene or build failed — positional fallback
|
|
157
|
+
role = _positional_fallback_role(i, scene_count)
|
|
158
|
+
energy = _positional_fallback_energy(role)
|
|
159
|
+
|
|
76
160
|
scene_roles.append(SceneRole(
|
|
77
161
|
scene_index=i,
|
|
78
162
|
name=name,
|
|
@@ -85,14 +169,13 @@ def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
|
|
|
85
169
|
current_scene = 0
|
|
86
170
|
try:
|
|
87
171
|
session_info = ableton.send_command("get_session_info", {})
|
|
88
|
-
# Check if any scene is marked as triggered/playing
|
|
89
172
|
session_scenes = session_info.get("scenes", [])
|
|
90
173
|
for i, s in enumerate(session_scenes):
|
|
91
174
|
if s.get("is_triggered", False):
|
|
92
175
|
current_scene = i
|
|
93
176
|
break
|
|
94
177
|
except Exception as exc:
|
|
95
|
-
logger.debug("_fetch_scene_data failed: %s", exc)
|
|
178
|
+
logger.debug("_fetch_scene_data current_scene failed: %s", exc)
|
|
96
179
|
|
|
97
180
|
return scene_roles, current_scene
|
|
98
181
|
|
|
@@ -42,11 +42,17 @@ def create_preview_set(
|
|
|
42
42
|
available_moves: Optional[list[dict]] = None,
|
|
43
43
|
song_brain: Optional[dict] = None,
|
|
44
44
|
taste_graph: Optional[dict] = None,
|
|
45
|
+
kernel: Optional[dict] = None,
|
|
45
46
|
) -> PreviewSet:
|
|
46
47
|
"""Create a preview set with variant slots.
|
|
47
48
|
|
|
48
49
|
For creative_triptych, generates 3 variants: safe, strong, unexpected.
|
|
49
50
|
Each variant gets a move_id from available_moves ranked by novelty.
|
|
51
|
+
|
|
52
|
+
kernel: the live session kernel (track topology + device chains). Compilers
|
|
53
|
+
resolve targets from it — without it, variants degrade into no-ops or
|
|
54
|
+
generic reads. Callers that have a `ctx` should fetch a real kernel
|
|
55
|
+
via runtime.tools.get_session_kernel(ctx).
|
|
50
56
|
"""
|
|
51
57
|
set_id = _compute_set_id(request_text, kernel_id)
|
|
52
58
|
now = int(time.time() * 1000)
|
|
@@ -56,11 +62,15 @@ def create_preview_set(
|
|
|
56
62
|
taste_graph = taste_graph or {}
|
|
57
63
|
|
|
58
64
|
if strategy == "creative_triptych":
|
|
59
|
-
variants = _build_triptych(
|
|
65
|
+
variants = _build_triptych(
|
|
66
|
+
request_text, moves, song_brain, taste_graph, set_id, now, kernel,
|
|
67
|
+
)
|
|
60
68
|
elif strategy == "binary":
|
|
61
69
|
variants = _build_binary(request_text, moves, song_brain, set_id, now)
|
|
62
70
|
else:
|
|
63
|
-
variants = _build_triptych(
|
|
71
|
+
variants = _build_triptych(
|
|
72
|
+
request_text, moves, song_brain, taste_graph, set_id, now, kernel,
|
|
73
|
+
)
|
|
64
74
|
|
|
65
75
|
ps = PreviewSet(
|
|
66
76
|
set_id=set_id,
|
|
@@ -81,6 +91,7 @@ def _build_triptych(
|
|
|
81
91
|
taste_graph: dict,
|
|
82
92
|
set_id: str,
|
|
83
93
|
now: int,
|
|
94
|
+
kernel: Optional[dict] = None,
|
|
84
95
|
) -> list[PreviewVariant]:
|
|
85
96
|
"""Build safe / strong / unexpected variants."""
|
|
86
97
|
identity = song_brain.get("identity_core", "")
|
|
@@ -114,20 +125,34 @@ def _build_triptych(
|
|
|
114
125
|
},
|
|
115
126
|
]
|
|
116
127
|
|
|
128
|
+
# Normalize kernel for the compiler. If the caller supplied a real kernel
|
|
129
|
+
# use it; otherwise fall back to an empty-but-valid shape so compilers
|
|
130
|
+
# degrade to no-op steps and emit warnings instead of crashing.
|
|
131
|
+
compile_kernel = kernel if kernel else {
|
|
132
|
+
"session_info": {"tempo": 120, "tracks": []},
|
|
133
|
+
"mode": "improve",
|
|
134
|
+
}
|
|
135
|
+
|
|
117
136
|
variants = []
|
|
118
137
|
for i, profile in enumerate(profiles):
|
|
119
138
|
# Pick a move if available
|
|
120
139
|
move_id = ""
|
|
121
140
|
compiled_plan = None
|
|
122
|
-
if moves and i < len(moves)
|
|
123
|
-
|
|
141
|
+
move = moves[i] if moves and i < len(moves) else None
|
|
142
|
+
if move is not None:
|
|
143
|
+
move_id = move.get("move_id", "")
|
|
124
144
|
# Compile through the semantic compiler — single source of truth
|
|
125
145
|
from ..wonder_mode.engine import _compile_variant_plan
|
|
126
|
-
|
|
127
|
-
compiled_plan = _compile_variant_plan(moves[i], kernel)
|
|
146
|
+
compiled_plan = _compile_variant_plan(move, compile_kernel)
|
|
128
147
|
# No fallback to plan_template — uncompilable moves stay analytical
|
|
129
148
|
|
|
130
|
-
|
|
149
|
+
# BUG-B44 / B45: populate user-facing description fields and flag
|
|
150
|
+
# variants that lack a compiled_plan as not-executable (so callers
|
|
151
|
+
# don't commit shells).
|
|
152
|
+
description = _describe_variant(move, compiled_plan, profile)
|
|
153
|
+
executable = compiled_plan is not None and bool(move_id)
|
|
154
|
+
|
|
155
|
+
variant = PreviewVariant(
|
|
131
156
|
variant_id=f"{set_id}_{profile['label']}",
|
|
132
157
|
label=profile["label"],
|
|
133
158
|
intent=profile["intent"],
|
|
@@ -139,11 +164,67 @@ def _build_triptych(
|
|
|
139
164
|
compiled_plan=compiled_plan,
|
|
140
165
|
taste_fit=_estimate_taste_fit(profile["novelty"], taste_graph),
|
|
141
166
|
created_at_ms=now,
|
|
142
|
-
|
|
167
|
+
what_changed=description["what_changed"],
|
|
168
|
+
summary=description["summary"],
|
|
169
|
+
)
|
|
170
|
+
# Non-executable variants get status='blocked' so callers know to
|
|
171
|
+
# skip preview/commit. Stored as status since executable/blocked_reason
|
|
172
|
+
# aren't modeled yet.
|
|
173
|
+
if not executable:
|
|
174
|
+
variant.status = "blocked"
|
|
175
|
+
variants.append(variant)
|
|
143
176
|
|
|
144
177
|
return variants
|
|
145
178
|
|
|
146
179
|
|
|
180
|
+
def _describe_variant(
|
|
181
|
+
move: Optional[dict],
|
|
182
|
+
compiled_plan: Optional[dict],
|
|
183
|
+
profile: dict,
|
|
184
|
+
) -> dict:
|
|
185
|
+
"""Build user-facing description fields for a variant (BUG-B45).
|
|
186
|
+
|
|
187
|
+
Priority order:
|
|
188
|
+
1. Move's `intent` or `description` — the authored one-liner
|
|
189
|
+
2. Compiled plan's step descriptions joined with " → "
|
|
190
|
+
3. The profile label + novelty level as a last-resort fallback
|
|
191
|
+
|
|
192
|
+
Returns {"what_changed": str, "summary": str}.
|
|
193
|
+
"""
|
|
194
|
+
what_changed = ""
|
|
195
|
+
summary = ""
|
|
196
|
+
if move:
|
|
197
|
+
# Move-level narrative beats plan-level — captures intent, not execution
|
|
198
|
+
move_intent = str(move.get("intent") or move.get("description") or "")
|
|
199
|
+
if move_intent:
|
|
200
|
+
what_changed = move_intent
|
|
201
|
+
summary = move_intent[:120]
|
|
202
|
+
|
|
203
|
+
if not what_changed and compiled_plan:
|
|
204
|
+
steps = compiled_plan.get("steps") or []
|
|
205
|
+
step_descriptions = [
|
|
206
|
+
str(s.get("description") or s.get("summary") or s.get("intent") or "")
|
|
207
|
+
for s in steps
|
|
208
|
+
]
|
|
209
|
+
step_descriptions = [d for d in step_descriptions if d]
|
|
210
|
+
if step_descriptions:
|
|
211
|
+
what_changed = " → ".join(step_descriptions[:4])
|
|
212
|
+
summary = (
|
|
213
|
+
step_descriptions[0][:120]
|
|
214
|
+
if step_descriptions else ""
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if not what_changed:
|
|
218
|
+
# Final fallback — describe the profile so the UI has something
|
|
219
|
+
what_changed = (
|
|
220
|
+
f"{profile['label'].title()} variant at novelty "
|
|
221
|
+
f"{profile['novelty']:.1f} (no executable plan available)"
|
|
222
|
+
)
|
|
223
|
+
summary = what_changed
|
|
224
|
+
|
|
225
|
+
return {"what_changed": what_changed, "summary": summary}
|
|
226
|
+
|
|
227
|
+
|
|
147
228
|
def _build_binary(
|
|
148
229
|
request_text: str,
|
|
149
230
|
moves: list[dict],
|
|
@@ -182,6 +182,16 @@ def create_preview_set(
|
|
|
182
182
|
except Exception as exc:
|
|
183
183
|
logger.debug("create_preview_set failed: %s", exc)
|
|
184
184
|
|
|
185
|
+
# Fetch a real session kernel so compilers resolve targets from the live
|
|
186
|
+
# set instead of an empty placeholder. Degrades gracefully when Ableton
|
|
187
|
+
# is unreachable (unit tests, no-connection environments).
|
|
188
|
+
live_kernel: dict = {}
|
|
189
|
+
try:
|
|
190
|
+
from ..runtime.tools import get_session_kernel
|
|
191
|
+
live_kernel = get_session_kernel(ctx, request_text=request_text) or {}
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
logger.debug("create_preview_set: could not fetch session kernel: %s", exc)
|
|
194
|
+
|
|
185
195
|
ps = engine.create_preview_set(
|
|
186
196
|
request_text=request_text,
|
|
187
197
|
kernel_id=kernel_id,
|
|
@@ -189,6 +199,7 @@ def create_preview_set(
|
|
|
189
199
|
available_moves=available_moves,
|
|
190
200
|
song_brain=song_brain,
|
|
191
201
|
taste_graph=taste_graph,
|
|
202
|
+
kernel=live_kernel,
|
|
192
203
|
)
|
|
193
204
|
|
|
194
205
|
return ps.to_dict()
|
|
@@ -436,7 +447,12 @@ async def render_preview_variant(
|
|
|
436
447
|
plan = variant.compiled_plan
|
|
437
448
|
steps = plan if isinstance(plan, list) else plan.get("steps", [])
|
|
438
449
|
|
|
439
|
-
from ..runtime.execution_router import execute_plan_steps_async
|
|
450
|
+
from ..runtime.execution_router import execute_plan_steps_async, filter_apply_steps
|
|
451
|
+
|
|
452
|
+
# Read-only verification steps (meters/spectrum/info) don't create undo
|
|
453
|
+
# points in Ableton — counting them and then undoing walks back earlier
|
|
454
|
+
# user edits. Separate writes from reads before the apply pass.
|
|
455
|
+
apply_steps = filter_apply_steps(steps)
|
|
440
456
|
|
|
441
457
|
applied_count = 0
|
|
442
458
|
playback_started = False
|
|
@@ -453,16 +469,16 @@ async def render_preview_variant(
|
|
|
453
469
|
# ── 1. Capture BEFORE metadata ──
|
|
454
470
|
before_info = ableton.send_command("get_session_info", {}) or {}
|
|
455
471
|
|
|
456
|
-
# ── 2. Apply the variant ──
|
|
472
|
+
# ── 2. Apply the variant (write steps only) ──
|
|
457
473
|
exec_results = await execute_plan_steps_async(
|
|
458
|
-
|
|
474
|
+
apply_steps,
|
|
459
475
|
ableton=ableton,
|
|
460
476
|
bridge=bridge,
|
|
461
477
|
mcp_registry=mcp_registry,
|
|
462
478
|
ctx=ctx,
|
|
463
479
|
)
|
|
464
480
|
applied_count = sum(1 for r in exec_results if r.ok)
|
|
465
|
-
if applied_count == 0 and
|
|
481
|
+
if applied_count == 0 and apply_steps:
|
|
466
482
|
return {
|
|
467
483
|
"error": "Variant failed to apply any steps",
|
|
468
484
|
"variant_id": variant_id,
|
|
@@ -489,9 +505,9 @@ async def render_preview_variant(
|
|
|
489
505
|
ableton.send_command("start_playback", {})
|
|
490
506
|
playback_started = True
|
|
491
507
|
|
|
492
|
-
import
|
|
508
|
+
import asyncio as _asyncio
|
|
493
509
|
|
|
494
|
-
|
|
510
|
+
await _asyncio.sleep(play_seconds)
|
|
495
511
|
|
|
496
512
|
spectral_after = cache.get_all()
|
|
497
513
|
|