livepilot 1.27.0 → 1.27.1
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 +48 -0
- package/README.md +25 -6
- package/bin/livepilot.js +4 -2
- package/installer/install.js +21 -12
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +8 -8
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +20 -1
- package/mcp_server/atlas/tools.py +83 -22
- package/mcp_server/composer/full/apply.py +9 -0
- package/mcp_server/composer/full/layer_planner.py +16 -6
- package/mcp_server/creative_constraints/engine.py +46 -0
- package/mcp_server/experiment/engine.py +10 -3
- package/mcp_server/grader/client.py +30 -2
- package/mcp_server/grader/tools.py +8 -2
- package/mcp_server/hook_hunter/analyzer.py +15 -2
- package/mcp_server/m4l_bridge.py +19 -21
- package/mcp_server/memory/taste_graph.py +16 -0
- package/mcp_server/memory/technique_store.py +23 -3
- package/mcp_server/mix_engine/state_builder.py +4 -2
- package/mcp_server/musical_intelligence/detectors.py +11 -2
- package/mcp_server/musical_intelligence/tools.py +15 -2
- package/mcp_server/preview_studio/engine.py +30 -1
- package/mcp_server/project_brain/tools.py +56 -52
- package/mcp_server/reference_engine/tools.py +22 -2
- package/mcp_server/runtime/live_version.py +27 -8
- package/mcp_server/runtime/safety_kernel.py +11 -0
- package/mcp_server/sample_engine/critics.py +7 -3
- package/mcp_server/sample_engine/slice_workflow.py +3 -2
- package/mcp_server/sample_engine/tools.py +53 -21
- package/mcp_server/song_brain/builder.py +17 -1
- package/mcp_server/sound_design/tools.py +1 -1
- package/mcp_server/splice_client/http_bridge.py +43 -1
- package/mcp_server/splice_client/models.py +7 -3
- package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
- package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
- package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
- package/mcp_server/tools/_composition_engine/models.py +6 -0
- package/mcp_server/tools/_composition_engine/sections.py +4 -0
- package/mcp_server/tools/clips.py +5 -4
- package/mcp_server/tools/composition.py +5 -1
- package/mcp_server/tools/midi_io.py +40 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
- package/mcp_server/user_corpus/runner.py +7 -1
- package/mcp_server/user_corpus/scanners/amxd.py +24 -13
- package/mcp_server/user_corpus/tools.py +45 -9
- package/mcp_server/wonder_mode/tools.py +66 -27
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/server.py +23 -3
- package/requirements.txt +1 -1
- package/server.json +2 -2
|
@@ -17,11 +17,14 @@ session state, returning the same shape used by `mcp_server/audit/checks.py`:
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import logging
|
|
20
21
|
from typing import Any, Callable, Iterable
|
|
21
22
|
|
|
22
23
|
from mcp_server.audit import checks as audit_checks
|
|
23
24
|
from mcp_server.audit.checks import infer_role
|
|
24
25
|
|
|
26
|
+
_logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
25
28
|
|
|
26
29
|
_TRACK_COUNT_WARN = 8
|
|
27
30
|
_TRACK_COUNT_FAIL = 12
|
|
@@ -441,11 +444,21 @@ def _aggregate_per_track(
|
|
|
441
444
|
try:
|
|
442
445
|
result = check_fn(*args)
|
|
443
446
|
except Exception as exc:
|
|
447
|
+
_logger.warning(
|
|
448
|
+
"grader check %s failed for criterion %r track %r (%s): %s",
|
|
449
|
+
getattr(check_fn, "__name__", repr(check_fn)),
|
|
450
|
+
criterion_id,
|
|
451
|
+
t.get("index"),
|
|
452
|
+
t.get("name"),
|
|
453
|
+
exc,
|
|
454
|
+
exc_info=True,
|
|
455
|
+
)
|
|
444
456
|
per_track.append({
|
|
445
457
|
"track_index": t.get("index"),
|
|
446
458
|
"name": t.get("name"),
|
|
447
459
|
"role": role,
|
|
448
460
|
"severity": "n/a",
|
|
461
|
+
"errored": True,
|
|
449
462
|
"summary": f"check failed: {type(exc).__name__}",
|
|
450
463
|
"issues": [],
|
|
451
464
|
"evidence": {},
|
|
@@ -459,15 +472,30 @@ def _aggregate_per_track(
|
|
|
459
472
|
})
|
|
460
473
|
|
|
461
474
|
actionable = [r for r in per_track if r["severity"] != "n/a"]
|
|
475
|
+
n_errored = sum(1 for r in per_track if r.get("errored"))
|
|
462
476
|
|
|
463
477
|
if not actionable:
|
|
478
|
+
if n_errored:
|
|
479
|
+
# Every checkable track raised — do NOT masquerade an all-error
|
|
480
|
+
# sweep as a benign no-op pass. Surface it as a failure.
|
|
481
|
+
return {
|
|
482
|
+
"id": criterion_id,
|
|
483
|
+
"passed": False,
|
|
484
|
+
"severity": "fail",
|
|
485
|
+
"summary": (
|
|
486
|
+
f"{n_errored} track(s) errored during check; no track "
|
|
487
|
+
"produced a usable result"
|
|
488
|
+
),
|
|
489
|
+
"issues": [],
|
|
490
|
+
"evidence": {"per_track": per_track, "errored": n_errored},
|
|
491
|
+
}
|
|
464
492
|
return {
|
|
465
493
|
"id": criterion_id,
|
|
466
494
|
"passed": True,
|
|
467
495
|
"severity": "n/a",
|
|
468
496
|
"summary": "No checkable tracks (data missing or no applicable role)",
|
|
469
497
|
"issues": [],
|
|
470
|
-
"evidence": {"per_track": per_track},
|
|
498
|
+
"evidence": {"per_track": per_track, "errored": n_errored},
|
|
471
499
|
}
|
|
472
500
|
|
|
473
501
|
has_fail = any(r["severity"] == "fail" for r in actionable)
|
|
@@ -505,7 +533,7 @@ def _aggregate_per_track(
|
|
|
505
533
|
"severity": rubric_severity,
|
|
506
534
|
"summary": summary,
|
|
507
535
|
"issues": issues,
|
|
508
|
-
"evidence": {"per_track": per_track},
|
|
536
|
+
"evidence": {"per_track": per_track, "errored": n_errored},
|
|
509
537
|
}
|
|
510
538
|
|
|
511
539
|
|
|
@@ -64,9 +64,15 @@ def _build_light_state(ctx: Context) -> dict[str, Any]:
|
|
|
64
64
|
info = _safe_call(ableton, "get_track_info", {"track_index": idx})
|
|
65
65
|
if not info:
|
|
66
66
|
continue
|
|
67
|
-
|
|
67
|
+
# Skip master/return tracks (real Remote Script field names are
|
|
68
|
+
# is_master_track / is_return_track, not is_master / is_return).
|
|
69
|
+
if info.get("is_master_track") or info.get("is_return_track"):
|
|
68
70
|
continue
|
|
69
|
-
|
|
71
|
+
# Skip group containers — foldable group tracks hold no clips or
|
|
72
|
+
# devices of their own, so they inflate the §7.3 track count and
|
|
73
|
+
# (having a mixer volume but no role/ghost tag) trip the
|
|
74
|
+
# buried-track check. Only count real, content-bearing layers.
|
|
75
|
+
if info.get("is_foldable"):
|
|
70
76
|
continue
|
|
71
77
|
tracks.append({
|
|
72
78
|
"index": idx,
|
|
@@ -108,8 +108,21 @@ def find_hook_candidates(
|
|
|
108
108
|
for c in candidates:
|
|
109
109
|
# Check if hook is present in payoff sections (via motif locations)
|
|
110
110
|
if c.hook_type == "melodic" and motif_data:
|
|
111
|
-
for motif in motif_data.get("motifs", []):
|
|
112
|
-
|
|
111
|
+
for idx, motif in enumerate(motif_data.get("motifs", [])):
|
|
112
|
+
# BUG-B61 fix: the old test `motif.get("name", "") in c.hook_id`
|
|
113
|
+
# was always True for real motif data, because the engine emits
|
|
114
|
+
# `motif_id` (not `name`) so .get("name","") returned "", and
|
|
115
|
+
# `"" in c.hook_id` is True for every candidate. That boosted
|
|
116
|
+
# every melodic candidate by every motif's recurrence. Rebuild
|
|
117
|
+
# the source motif's hook_id exactly as it was constructed above
|
|
118
|
+
# (motif_id -> name -> idxN fallback) and require an exact match
|
|
119
|
+
# so each candidate is boosted only by its own source motif.
|
|
120
|
+
identifier = (
|
|
121
|
+
motif.get("motif_id")
|
|
122
|
+
or motif.get("name")
|
|
123
|
+
or f"idx{idx}"
|
|
124
|
+
)
|
|
125
|
+
if f"motif_{identifier}" == c.hook_id:
|
|
113
126
|
# Motif with high recurrence across sections = stronger hook
|
|
114
127
|
c.memorability = min(1.0, c.memorability + motif.get("recurrence", 0) * 0.2)
|
|
115
128
|
|
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -497,6 +497,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
497
497
|
self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
|
|
498
498
|
self._chunk_times: dict[str, float] = {} # Monotonic timestamp per chunk sequence
|
|
499
499
|
self._chunk_id = 0
|
|
500
|
+
self._chunk_key: Optional[str] = None # Key of the single active reassembly bucket
|
|
500
501
|
self._response_callback: Optional[asyncio.Future] = None
|
|
501
502
|
self._capture_future: Optional[asyncio.Future] = None
|
|
502
503
|
self._miditool_handler: Optional[Callable[[str, dict, list], None]] = None
|
|
@@ -733,29 +734,26 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
733
734
|
way we never poison a prior sequence, and the problem surfaces in
|
|
734
735
|
logs if it happens.
|
|
735
736
|
"""
|
|
736
|
-
|
|
737
|
+
# Only one response is chunked at a time: send_command serialises on
|
|
738
|
+
# _cmd_lock, so a single active reassembly bucket is sufficient and we
|
|
739
|
+
# do NOT need the first packet to be index 0. Accepting chunks in any
|
|
740
|
+
# order fixes the permanent-loss path where an index>0 chunk arriving
|
|
741
|
+
# before index 0 (UDP loopback reordering under load) used to split
|
|
742
|
+
# one response across two buckets that never completed.
|
|
743
|
+
active = self._chunks.get(self._chunk_key)
|
|
744
|
+
if active is None or active["total"] != total:
|
|
745
|
+
# No bucket open, OR a chunk with a different `total` arrived,
|
|
746
|
+
# meaning a new response started (e.g. the previous one timed out
|
|
747
|
+
# without ever completing). Evict any stale partial and open fresh.
|
|
748
|
+
if active is not None:
|
|
749
|
+
self._chunks.pop(self._chunk_key, None)
|
|
750
|
+
self._chunk_times.pop(self._chunk_key, None)
|
|
737
751
|
self._chunk_id += 1
|
|
738
|
-
|
|
739
|
-
self._chunks[
|
|
740
|
-
self._chunk_times[
|
|
741
|
-
else:
|
|
742
|
-
key = str(self._chunk_id)
|
|
743
|
-
if key not in self._chunks:
|
|
744
|
-
# Out-of-order arrival. Start a new bucket rather than append
|
|
745
|
-
# to the previous sequence's parts — that's the corruption
|
|
746
|
-
# path. Log once so it's diagnosable.
|
|
747
|
-
import sys
|
|
748
|
-
print(
|
|
749
|
-
f"LivePilot: chunk index={index}/{total} arrived before "
|
|
750
|
-
f"index=0 — starting fresh bucket. UDP reordering on "
|
|
751
|
-
f"loopback suggests system load.",
|
|
752
|
-
file=sys.stderr,
|
|
753
|
-
)
|
|
754
|
-
self._chunk_id += 1
|
|
755
|
-
key = str(self._chunk_id)
|
|
756
|
-
self._chunks[key] = {"parts": {}, "total": total}
|
|
757
|
-
self._chunk_times[key] = time.monotonic()
|
|
752
|
+
self._chunk_key = str(self._chunk_id)
|
|
753
|
+
self._chunks[self._chunk_key] = {"parts": {}, "total": total}
|
|
754
|
+
self._chunk_times[self._chunk_key] = time.monotonic()
|
|
758
755
|
|
|
756
|
+
key = self._chunk_key
|
|
759
757
|
self._chunks[key]["parts"][index] = encoded
|
|
760
758
|
|
|
761
759
|
if len(self._chunks[key]["parts"]) == total:
|
|
@@ -182,6 +182,14 @@ class TasteGraph:
|
|
|
182
182
|
self.evidence_count += 1
|
|
183
183
|
self.last_updated_ms = now
|
|
184
184
|
|
|
185
|
+
# Write-back to persistent store
|
|
186
|
+
if self._persistent_store is not None:
|
|
187
|
+
try:
|
|
188
|
+
self._persistent_store.record_device_use(device_name, positive)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
logger.debug("record_device_use failed: %s", exc)
|
|
191
|
+
pass # persistence is best-effort
|
|
192
|
+
|
|
185
193
|
def update_novelty_from_experiment(
|
|
186
194
|
self, chose_bold: bool, goal_mode: str = "improve",
|
|
187
195
|
) -> None:
|
|
@@ -200,6 +208,14 @@ class TasteGraph:
|
|
|
200
208
|
new_val = max(0.0, current - 0.05)
|
|
201
209
|
self.novelty_bands[goal_mode] = new_val
|
|
202
210
|
|
|
211
|
+
# Write-back to persistent store
|
|
212
|
+
if self._persistent_store is not None:
|
|
213
|
+
try:
|
|
214
|
+
self._persistent_store.update_novelty(chose_bold, goal_mode)
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
logger.debug("update_novelty failed: %s", exc)
|
|
217
|
+
pass # persistence is best-effort
|
|
218
|
+
|
|
203
219
|
# ── Ranking ──────────────────────────────────────────────────────
|
|
204
220
|
|
|
205
221
|
def rank_moves(
|
|
@@ -31,6 +31,19 @@ class TechniqueStore:
|
|
|
31
31
|
self._lock = threading.Lock()
|
|
32
32
|
self._initialized = False
|
|
33
33
|
self._data: dict = {"version": 1, "techniques": []}
|
|
34
|
+
# Signature (mtime_ns, size) of the file as we last loaded it. Lets
|
|
35
|
+
# multiple TechniqueStore instances pointing at the same file pick up
|
|
36
|
+
# each other's writes (reload-on-read) instead of caching stale data
|
|
37
|
+
# for the life of the process.
|
|
38
|
+
self._loaded_sig: Optional[tuple] = None
|
|
39
|
+
|
|
40
|
+
def _file_signature(self) -> Optional[tuple]:
|
|
41
|
+
"""Return (mtime_ns, size) of the backing file, or None if absent."""
|
|
42
|
+
try:
|
|
43
|
+
st = self._file.stat()
|
|
44
|
+
except OSError:
|
|
45
|
+
return None
|
|
46
|
+
return (st.st_mtime_ns, st.st_size)
|
|
34
47
|
|
|
35
48
|
def _ensure_initialized(self) -> None:
|
|
36
49
|
"""Lazily create directory and load data on first access.
|
|
@@ -40,12 +53,14 @@ class TechniqueStore:
|
|
|
40
53
|
Thread-safe: uses double-checked locking to prevent concurrent
|
|
41
54
|
callers from racing on initialization.
|
|
42
55
|
"""
|
|
43
|
-
|
|
56
|
+
# Fast path: already initialized AND the file on disk has not changed
|
|
57
|
+
# since we last loaded it (no other instance has written).
|
|
58
|
+
if self._initialized and self._file_signature() == self._loaded_sig:
|
|
44
59
|
return
|
|
45
60
|
with self._lock:
|
|
46
61
|
# Double-check after acquiring lock — another thread may have
|
|
47
|
-
#
|
|
48
|
-
if self._initialized:
|
|
62
|
+
# (re)loaded while we were waiting.
|
|
63
|
+
if self._initialized and self._file_signature() == self._loaded_sig:
|
|
49
64
|
return
|
|
50
65
|
try:
|
|
51
66
|
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -58,10 +73,12 @@ class TechniqueStore:
|
|
|
58
73
|
try:
|
|
59
74
|
with open(self._file, "r") as f:
|
|
60
75
|
self._data = json.load(f)
|
|
76
|
+
self._loaded_sig = self._file_signature()
|
|
61
77
|
except (json.JSONDecodeError, ValueError):
|
|
62
78
|
corrupt = self._file.with_suffix(".json.corrupt")
|
|
63
79
|
self._file.rename(corrupt)
|
|
64
80
|
self._data = {"version": 1, "techniques": []}
|
|
81
|
+
self._loaded_sig = None
|
|
65
82
|
else:
|
|
66
83
|
self._data = {"version": 1, "techniques": []}
|
|
67
84
|
self._flush()
|
|
@@ -77,6 +94,9 @@ class TechniqueStore:
|
|
|
77
94
|
f.flush()
|
|
78
95
|
os.fsync(f.fileno())
|
|
79
96
|
os.replace(str(tmp), str(self._file))
|
|
97
|
+
# Record the signature of our own write so this instance does not
|
|
98
|
+
# needlessly reload the data it already holds in memory.
|
|
99
|
+
self._loaded_sig = self._file_signature()
|
|
80
100
|
|
|
81
101
|
# ── public API ───────────────────────────────────────────────
|
|
82
102
|
|
|
@@ -241,8 +241,10 @@ def build_dynamics_state(
|
|
|
241
241
|
|
|
242
242
|
crest = 20 * math.log10(max(peak_linear, 1e-10) / max(rms_linear, 1e-10))
|
|
243
243
|
|
|
244
|
-
# Over-compressed
|
|
245
|
-
|
|
244
|
+
# Over-compressed band is 3-6 dB crest. Below 3 dB the signal is so flat
|
|
245
|
+
# that the dynamics critic should report the stronger `flat_dynamics`
|
|
246
|
+
# issue instead, which only fires when over_compressed is False.
|
|
247
|
+
over_compressed = 3.0 <= crest < 6.0
|
|
246
248
|
|
|
247
249
|
# Headroom = distance from peak to 0 dBFS
|
|
248
250
|
if peak_linear > 0:
|
|
@@ -134,9 +134,15 @@ def detect_repetition_fatigue(
|
|
|
134
134
|
staleness = min(1.0, (reuse_count / total - 1) * 0.3) if total else 0
|
|
135
135
|
report.section_staleness[name] = round(max(0, staleness), 3)
|
|
136
136
|
|
|
137
|
-
# Overall fatigue level
|
|
137
|
+
# Overall fatigue level — saturating combine (probabilistic OR) so that
|
|
138
|
+
# adding more issues never *reduces* fatigue. A plain mean would let a
|
|
139
|
+
# cluster of low-severity issues dilute a single serious one.
|
|
138
140
|
if report.issues:
|
|
139
|
-
|
|
141
|
+
remaining_freshness = 1.0
|
|
142
|
+
for issue in report.issues:
|
|
143
|
+
severity = max(0.0, min(1.0, issue["severity"]))
|
|
144
|
+
remaining_freshness *= (1.0 - severity)
|
|
145
|
+
report.fatigue_level = min(1.0, 1.0 - remaining_freshness)
|
|
140
146
|
|
|
141
147
|
# Recommendations
|
|
142
148
|
if report.fatigue_level > 0.5:
|
|
@@ -337,6 +343,9 @@ def infer_section_purposes(
|
|
|
337
343
|
if position < 0.15 and density < 0.5:
|
|
338
344
|
purpose = "setup"
|
|
339
345
|
confidence = 0.7
|
|
346
|
+
elif density >= 0.8 and density_delta >= 0:
|
|
347
|
+
purpose = "payoff"
|
|
348
|
+
confidence = 0.65
|
|
340
349
|
elif density_delta > 0.2:
|
|
341
350
|
purpose = "tension"
|
|
342
351
|
confidence = 0.6
|
|
@@ -215,8 +215,21 @@ def compare_phrase_renders(
|
|
|
215
215
|
|
|
216
216
|
critiques = []
|
|
217
217
|
for path in file_paths:
|
|
218
|
-
#
|
|
219
|
-
|
|
218
|
+
# Run offline analysis per file so each render gets a real critique
|
|
219
|
+
loudness_data = None
|
|
220
|
+
spectrum_data = None
|
|
221
|
+
try:
|
|
222
|
+
from ..tools._perception_engine import compute_loudness
|
|
223
|
+
loudness_data = compute_loudness(path, detail="full")
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
logger.debug("compare_phrase_renders loudness failed for %s: %s", path, exc)
|
|
226
|
+
try:
|
|
227
|
+
from ..tools._perception_engine import compute_spectral
|
|
228
|
+
spectrum_data = compute_spectral(path)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
logger.debug("compare_phrase_renders spectral failed for %s: %s", path, exc)
|
|
231
|
+
|
|
232
|
+
critique = phrase_critic.analyze_phrase(loudness_data, spectrum_data, target)
|
|
220
233
|
critique.render_id = path.split("/")[-1] if isinstance(path, str) and "/" in path else str(path)
|
|
221
234
|
critiques.append(critique)
|
|
222
235
|
|
|
@@ -33,6 +33,35 @@ def store_preview_set(ps: PreviewSet) -> None:
|
|
|
33
33
|
del _preview_sets[oldest_key]
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
# Statuses that represent work a user (or caller) has already invested in:
|
|
37
|
+
# a compared ranking or a committed pick. A fresh request that hashes to the
|
|
38
|
+
# same set_id must NOT clobber these — it branches to a distinct id instead.
|
|
39
|
+
_PROTECTED_STATUSES = {"committed", "compared"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_set_id(base_id: str) -> str:
|
|
43
|
+
"""Return a set_id that won't clobber a protected (committed/compared) set.
|
|
44
|
+
|
|
45
|
+
The base_id is a deterministic hash of request_text + kernel_id, so a
|
|
46
|
+
re-request reuses it by design. That reuse is fine while the existing set
|
|
47
|
+
is still 'pending'/'discarded' (nothing of value to lose). But if the
|
|
48
|
+
existing set under base_id has been compared or committed, overwriting it
|
|
49
|
+
would silently drop its rankings / committed pick. In that case we branch
|
|
50
|
+
to a distinct, still-deterministic id (base_id + a monotonic suffix) so the
|
|
51
|
+
protected set survives and the new set gets its own slot.
|
|
52
|
+
"""
|
|
53
|
+
existing = _preview_sets.get(base_id)
|
|
54
|
+
if existing is None or existing.status not in _PROTECTED_STATUSES:
|
|
55
|
+
return base_id
|
|
56
|
+
suffix = 2
|
|
57
|
+
while True:
|
|
58
|
+
candidate = f"{base_id}_b{suffix}"
|
|
59
|
+
occupant = _preview_sets.get(candidate)
|
|
60
|
+
if occupant is None or occupant.status not in _PROTECTED_STATUSES:
|
|
61
|
+
return candidate
|
|
62
|
+
suffix += 1
|
|
63
|
+
|
|
64
|
+
|
|
36
65
|
# ── Creation ──────────────────────────────────────────────────────
|
|
37
66
|
|
|
38
67
|
|
|
@@ -58,7 +87,7 @@ def create_preview_set(
|
|
|
58
87
|
flags the resulting PreviewSet with ``degradation.is_degraded=True``
|
|
59
88
|
so callers can tell a synthesized compile from a real one.
|
|
60
89
|
"""
|
|
61
|
-
set_id = _compute_set_id(request_text, kernel_id)
|
|
90
|
+
set_id = _resolve_set_id(_compute_set_id(request_text, kernel_id))
|
|
62
91
|
now = int(time.time() * 1000)
|
|
63
92
|
|
|
64
93
|
moves = available_moves or []
|
|
@@ -84,17 +84,41 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
84
84
|
except Exception as exc:
|
|
85
85
|
logger.debug("build_project_brain failed: %s", exc)
|
|
86
86
|
|
|
87
|
-
# 5b.
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
87
|
+
# 5b/5c. Single combined grid sweep for notes (role inference) and clip
|
|
88
|
+
# automation envelopes. Previously these were two separate
|
|
89
|
+
# N_tracks x N_scenes loops, each issuing a remote round-trip per slot —
|
|
90
|
+
# including empty slots. We now consult the get_scene_matrix presence grid
|
|
91
|
+
# (already fetched at step 3 as clip_matrix) and skip slots that are
|
|
92
|
+
# positively empty, collapsing both sweeps into one pass over the grid.
|
|
91
93
|
#
|
|
92
94
|
# BUG-E1: section_id must match what build_section_graph_from_scenes emits.
|
|
93
95
|
# The composition engine emits `sec_{i:02d}` using the RAW enumerate index
|
|
94
96
|
# of the scene — it skips unnamed scenes (gap-preserving), so e.g. scenes
|
|
95
97
|
# ["Intro", "", "Verse"] become sections sec_00 and sec_02, not sec_01.
|
|
96
|
-
#
|
|
98
|
+
# Both maps mirror that or keys won't align.
|
|
99
|
+
#
|
|
100
|
+
# clips_scanned is the denominator for coverage_pct (BUG-D2): it counts
|
|
101
|
+
# the slots we actually probed for envelopes. Slots skipped as empty are
|
|
102
|
+
# not counted, so coverage_pct stays "fraction of present clips automated".
|
|
103
|
+
def _slot_is_empty(s_idx: int, t_idx: int) -> bool:
|
|
104
|
+
"""True only when the presence grid positively reports no clip.
|
|
105
|
+
|
|
106
|
+
Unknown / out-of-range / malformed cells return False so we still
|
|
107
|
+
issue the round-trip (safe fallback — never skip on ambiguity).
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
cell = clip_matrix[s_idx][t_idx]
|
|
111
|
+
except (IndexError, TypeError, KeyError):
|
|
112
|
+
return False
|
|
113
|
+
if not isinstance(cell, dict):
|
|
114
|
+
return False
|
|
115
|
+
if cell.get("has_clip"):
|
|
116
|
+
return False
|
|
117
|
+
return cell.get("state") in ("empty", "missing")
|
|
118
|
+
|
|
97
119
|
notes_map: dict[str, dict[int, list[dict]]] = {}
|
|
120
|
+
clip_automation: list[dict] = []
|
|
121
|
+
clips_scanned = 0
|
|
98
122
|
try:
|
|
99
123
|
for scene_idx, scene in enumerate(scenes or []):
|
|
100
124
|
scene_name = str(scene.get("name", "")).strip()
|
|
@@ -105,6 +129,11 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
105
129
|
per_track: dict[int, list[dict]] = {}
|
|
106
130
|
for track in tracks:
|
|
107
131
|
t_idx = track.get("index", 0)
|
|
132
|
+
if _slot_is_empty(scene_idx, t_idx):
|
|
133
|
+
continue # no clip in this slot — skip both round-trips
|
|
134
|
+
clips_scanned += 1
|
|
135
|
+
|
|
136
|
+
# Notes for role inference.
|
|
108
137
|
try:
|
|
109
138
|
notes_resp = ableton.send_command("get_notes", {
|
|
110
139
|
"track_index": t_idx,
|
|
@@ -115,39 +144,9 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
115
144
|
if notes:
|
|
116
145
|
per_track[t_idx] = notes
|
|
117
146
|
except Exception as exc:
|
|
118
|
-
logger.debug("build_project_brain failed: %s", exc)
|
|
119
|
-
# Individual note fetch failing is fine — continue with others
|
|
120
|
-
continue
|
|
121
|
-
if per_track:
|
|
122
|
-
notes_map[section_id] = per_track
|
|
123
|
-
except Exception as exc:
|
|
124
|
-
logger.debug("build_project_brain failed: %s", exc)
|
|
125
|
-
# Overall failure: empty map, degrade to "all tracks active" fallback
|
|
126
|
-
notes_map = {}
|
|
147
|
+
logger.debug("build_project_brain notes fetch failed: %s", exc)
|
|
127
148
|
|
|
128
|
-
|
|
129
|
-
# Device-parameter is_automated flags only reflect whether a parameter
|
|
130
|
-
# is mapped somewhere — they don't reveal clip envelopes. Ableton's
|
|
131
|
-
# automation actually lives on each clip (session + arrangement). We
|
|
132
|
-
# walk every clip slot that has a clip and ask get_clip_automation, then
|
|
133
|
-
# aggregate into a flat list keyed by section.
|
|
134
|
-
#
|
|
135
|
-
# clips_scanned is the denominator for coverage_pct (BUG-D2) — it
|
|
136
|
-
# counts how many (track, scene) slots we probed, regardless of
|
|
137
|
-
# whether an envelope came back. Without this, a session with zero
|
|
138
|
-
# automation would be indistinguishable from a session where we
|
|
139
|
-
# failed to probe, which is exactly the ambiguity BUG-D2 flagged.
|
|
140
|
-
clip_automation: list[dict] = []
|
|
141
|
-
clips_scanned = 0
|
|
142
|
-
try:
|
|
143
|
-
for scene_idx, scene in enumerate(scenes or []):
|
|
144
|
-
scene_name = str(scene.get("name", "")).strip()
|
|
145
|
-
if not scene_name:
|
|
146
|
-
continue
|
|
147
|
-
section_id = f"sec_{scene_idx:02d}"
|
|
148
|
-
for track in tracks:
|
|
149
|
-
t_idx = track.get("index", 0)
|
|
150
|
-
clips_scanned += 1
|
|
149
|
+
# Clip automation envelopes (BUG-E2).
|
|
151
150
|
try:
|
|
152
151
|
auto_resp = ableton.send_command("get_clip_automation", {
|
|
153
152
|
"track_index": t_idx,
|
|
@@ -156,22 +155,27 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
156
155
|
except Exception as exc:
|
|
157
156
|
# No clip in slot, or remote script rejected — skip
|
|
158
157
|
logger.debug("build_project_brain automation skip: %s", exc)
|
|
159
|
-
|
|
160
|
-
if
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
158
|
+
auto_resp = None
|
|
159
|
+
if isinstance(auto_resp, dict):
|
|
160
|
+
for env in (auto_resp.get("envelopes") or []):
|
|
161
|
+
clip_automation.append({
|
|
162
|
+
"section_id": section_id,
|
|
163
|
+
"track_index": t_idx,
|
|
164
|
+
"track_name": track.get("name", ""),
|
|
165
|
+
"clip_index": scene_idx,
|
|
166
|
+
"parameter_name": env.get("parameter_name", ""),
|
|
167
|
+
"parameter_type": env.get("parameter_type", ""),
|
|
168
|
+
"device_name": env.get("device_name"),
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
if per_track:
|
|
172
|
+
notes_map[section_id] = per_track
|
|
173
173
|
except Exception as exc:
|
|
174
|
-
logger.debug("build_project_brain
|
|
174
|
+
logger.debug("build_project_brain grid sweep failed: %s", exc)
|
|
175
|
+
# Overall failure: empty maps, degrade to "all tracks active" fallback
|
|
176
|
+
notes_map = {}
|
|
177
|
+
clip_automation = []
|
|
178
|
+
clips_scanned = 0
|
|
175
179
|
|
|
176
180
|
# 6. Probe capabilities (direct SpectralCache access, not TCP)
|
|
177
181
|
analyzer_ok = False
|
|
@@ -71,14 +71,18 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
|
|
|
71
71
|
|
|
72
72
|
spec_data = spectral.get("spectrum")
|
|
73
73
|
if spec_data:
|
|
74
|
-
|
|
74
|
+
# gap_analyzer.analyze_gaps reads proj_spectral["band_balance"]
|
|
75
|
+
# and the reference profile stores its bands under the same
|
|
76
|
+
# key, so the project spectrum MUST go under band_balance too
|
|
77
|
+
# (writing it under "bands" made every band read as 0.0).
|
|
78
|
+
snapshot["spectral"] = {"band_balance": spec_data["value"]}
|
|
75
79
|
key_data = spectral.get("key")
|
|
76
80
|
if key_data:
|
|
77
81
|
snapshot["spectral"]["detected_key"] = key_data["value"]
|
|
78
82
|
except Exception as exc:
|
|
79
83
|
logger.debug("_fetch_project_snapshot failed: %s", exc)
|
|
80
84
|
|
|
81
|
-
# Try to get session info for pacing / density
|
|
85
|
+
# Try to get session info for pacing / density / stereo width
|
|
82
86
|
try:
|
|
83
87
|
session_info = ableton.send_command("get_session_info", {})
|
|
84
88
|
track_count = session_info.get("track_count", 0)
|
|
@@ -86,6 +90,22 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
|
|
|
86
90
|
# Rough density estimate
|
|
87
91
|
snapshot["density"] = min(1.0, track_count / 20.0)
|
|
88
92
|
snapshot["pacing"] = [{"label": f"scene_{i}", "bars": 8} for i in range(scene_count)]
|
|
93
|
+
|
|
94
|
+
# Estimate project stereo width from track pans (mirrors the
|
|
95
|
+
# pan-spread estimate in translation_engine/tools.py). Without this,
|
|
96
|
+
# snapshot["width"] stayed 0.0 and the width gap always reported the
|
|
97
|
+
# full reference width.
|
|
98
|
+
tracks = session_info.get("tracks", [])
|
|
99
|
+
if tracks:
|
|
100
|
+
def _get_pan(t: dict) -> float:
|
|
101
|
+
mixer = t.get("mixer")
|
|
102
|
+
if isinstance(mixer, dict):
|
|
103
|
+
return abs(mixer.get("panning", 0.0))
|
|
104
|
+
return abs(t.get("pan", 0.0))
|
|
105
|
+
|
|
106
|
+
pan_values = [_get_pan(t) for t in tracks if not t.get("muted", False)]
|
|
107
|
+
if pan_values:
|
|
108
|
+
snapshot["width"] = min(1.0, sum(pan_values) / max(len(pan_values), 1))
|
|
89
109
|
except Exception as exc:
|
|
90
110
|
logger.debug("_fetch_project_snapshot failed: %s", exc)
|
|
91
111
|
|
|
@@ -6,6 +6,7 @@ responses and exposes feature flags for tool routing.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import re
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
|
|
11
12
|
|
|
@@ -19,21 +20,39 @@ class LiveVersionCapabilities:
|
|
|
19
20
|
|
|
20
21
|
@classmethod
|
|
21
22
|
def from_version_string(cls, version_str: str) -> LiveVersionCapabilities:
|
|
22
|
-
"""Parse '12.3.6' into a capabilities instance.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
"""Parse '12.3.6' into a capabilities instance.
|
|
24
|
+
|
|
25
|
+
Tolerant of malformed input: extracts the leading integer of each
|
|
26
|
+
dotted component and falls back to the conservative floor
|
|
27
|
+
(major=12, minor=0, patch=0) for missing or non-numeric components.
|
|
28
|
+
Mirrors the Remote Script's degrade-to-(12,0,0) contract
|
|
29
|
+
(remote_script/LivePilot/version_detect.py) so a junk live_version
|
|
30
|
+
string never crashes get_capability_state / get_session_kernel /
|
|
31
|
+
--doctor.
|
|
32
|
+
"""
|
|
33
|
+
def _component(parts: list[str], index: int, default: int) -> int:
|
|
34
|
+
if index >= len(parts):
|
|
35
|
+
return default
|
|
36
|
+
match = re.match(r"\s*(\d+)", parts[index])
|
|
37
|
+
return int(match.group(1)) if match else default
|
|
38
|
+
|
|
39
|
+
parts = (version_str or "").split(".")
|
|
40
|
+
major = _component(parts, 0, 12)
|
|
41
|
+
minor = _component(parts, 1, 0)
|
|
42
|
+
patch = _component(parts, 2, 0)
|
|
27
43
|
return cls(major=major, minor=minor, patch=patch)
|
|
28
44
|
|
|
29
45
|
@classmethod
|
|
30
46
|
def from_session_info(cls, session_info: dict) -> LiveVersionCapabilities:
|
|
31
47
|
"""Extract version from get_session_info response.
|
|
32
48
|
|
|
33
|
-
Looks for 'live_version' field. Falls back to 12.0.0 if absent
|
|
34
|
-
(pre-upgrade Remote Script).
|
|
49
|
+
Looks for 'live_version' field. Falls back to 12.0.0 if absent,
|
|
50
|
+
null, or empty (pre-upgrade Remote Script). Uses a falsy-guard
|
|
51
|
+
rather than a key-presence default so a present-but-empty value
|
|
52
|
+
degrades to the conservative floor, aligning with the sibling
|
|
53
|
+
readers in runtime/tools.py and runtime/capability_probe.py.
|
|
35
54
|
"""
|
|
36
|
-
version_str = session_info.get("live_version"
|
|
55
|
+
version_str = session_info.get("live_version") or "12.0.0"
|
|
37
56
|
return cls.from_version_string(version_str)
|
|
38
57
|
|
|
39
58
|
@property
|
|
@@ -56,6 +56,15 @@ CONFIRM_REQUIRED_ACTIONS: set[str] = {
|
|
|
56
56
|
"replace_simpler_sample",
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
# Names that LOOK read-only by prefix but actually mutate session state.
|
|
60
|
+
# These must NOT be classified as read-only — they are intersected against
|
|
61
|
+
# the prefix matcher below so a mutating verb never slips through a read
|
|
62
|
+
# prefix (e.g. ``find_and_load_device`` starts with ``find_`` but loads a
|
|
63
|
+
# device, a real mutation routed through REMOTE_COMMANDS).
|
|
64
|
+
_MUTATING_OVERRIDES: set[str] = {
|
|
65
|
+
"find_and_load_device",
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
# Read-only prefixes — any action starting with one of these is a read.
|
|
60
69
|
_READ_ONLY_PREFIXES = (
|
|
61
70
|
"get_",
|
|
@@ -203,6 +212,8 @@ _WIDE_SCOPE_THRESHOLD = 5 # tracks
|
|
|
203
212
|
|
|
204
213
|
def is_read_only_action(action: str) -> bool:
|
|
205
214
|
"""Return True if *action* is a non-mutating read/query."""
|
|
215
|
+
if action in _MUTATING_OVERRIDES:
|
|
216
|
+
return False
|
|
206
217
|
if action in SAFE_ACTIONS:
|
|
207
218
|
return True
|
|
208
219
|
return action.startswith(_READ_ONLY_PREFIXES)
|