livepilot 1.26.2 → 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 +92 -0
- package/README.md +34 -14
- package/bin/livepilot.js +4 -2
- package/installer/install.js +21 -12
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +12 -12
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +2 -1
- package/livepilot/skills/livepilot-core/references/overview.md +6 -4
- package/livepilot/skills/livepilot-core/references/sound-design.md +5 -2
- package/livepilot/skills/livepilot-creative-director/SKILL.md +36 -0
- package/livepilot/skills/livepilot-creative-director/references/move-family-diversity-rule.md +1 -1
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +15 -9
- package/livepilot/skills/livepilot-release/SKILL.md +7 -5
- 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/capability_probe.py +1 -1
- package/mcp_server/runtime/capability_state.py +66 -9
- package/mcp_server/runtime/live_version.py +45 -8
- package/mcp_server/runtime/safety_kernel.py +11 -0
- package/mcp_server/runtime/session_kernel.py +7 -0
- package/mcp_server/runtime/tools.py +324 -22
- 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/analyzer.py +38 -1
- 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 +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/server.py +23 -3
- package/remote_script/LivePilot/version_detect.py +3 -0
- package/requirements.txt +4 -4
- package/server.json +3 -3
|
@@ -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
|
|
|
@@ -73,7 +73,7 @@ def probe_capabilities(
|
|
|
73
73
|
bridge_ok = bridge is not None and spectral is not None and getattr(spectral, "is_connected", False)
|
|
74
74
|
report["m4l_bridge"] = {
|
|
75
75
|
"status": "ok" if bridge_ok else "unavailable",
|
|
76
|
-
"detail": "UDP 9880 / OSC 9881 active" if bridge_ok else "Not connected —
|
|
76
|
+
"detail": "UDP 9880 / OSC 9881 active" if bridge_ok else "Not connected — 38 analyzer tools unavailable",
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
# 4. Offline perception
|
|
@@ -118,6 +118,12 @@ def build_capability_state(
|
|
|
118
118
|
memory_ok: bool = False,
|
|
119
119
|
web_ok: bool = False,
|
|
120
120
|
flucoma_ok: bool = False,
|
|
121
|
+
flucoma_device_loaded: Optional[bool] = None,
|
|
122
|
+
flucoma_reasons: Optional[list[str]] = None,
|
|
123
|
+
link_audio_mode: str = "manual_only",
|
|
124
|
+
link_audio_reasons: Optional[list[str]] = None,
|
|
125
|
+
stem_workflow_mode: str = "manual_only",
|
|
126
|
+
stem_workflow_reasons: Optional[list[str]] = None,
|
|
121
127
|
) -> CapabilityState:
|
|
122
128
|
"""Build a CapabilityState from simple boolean probes.
|
|
123
129
|
|
|
@@ -200,19 +206,70 @@ def build_capability_state(
|
|
|
200
206
|
)
|
|
201
207
|
|
|
202
208
|
# ── flucoma ──────────────────────────────────────────────────────
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
# Max/FluCoMa real-time streams. Emitted unconditionally so consumers
|
|
210
|
+
# can distinguish "not installed" from "installed but bridge/streams
|
|
211
|
+
# are currently unavailable".
|
|
212
|
+
resolved_flucoma_device_loaded = (
|
|
213
|
+
flucoma_ok if flucoma_device_loaded is None else flucoma_device_loaded
|
|
214
|
+
)
|
|
215
|
+
resolved_flucoma_reasons = list(flucoma_reasons or [])
|
|
216
|
+
if not flucoma_ok and not resolved_flucoma_reasons:
|
|
217
|
+
resolved_flucoma_reasons.append(
|
|
218
|
+
"flucoma_no_streams"
|
|
219
|
+
if resolved_flucoma_device_loaded
|
|
220
|
+
else "flucoma_not_installed"
|
|
221
|
+
)
|
|
209
222
|
domains["flucoma"] = CapabilityDomain(
|
|
210
223
|
name="flucoma",
|
|
211
224
|
available=flucoma_ok,
|
|
212
|
-
confidence=0.9 if flucoma_ok else 0.0,
|
|
225
|
+
confidence=0.9 if flucoma_ok else (0.2 if resolved_flucoma_device_loaded else 0.0),
|
|
213
226
|
mode="available" if flucoma_ok else "unavailable",
|
|
214
|
-
reasons=
|
|
215
|
-
device_loaded=
|
|
227
|
+
reasons=resolved_flucoma_reasons,
|
|
228
|
+
device_loaded=resolved_flucoma_device_loaded,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# ── link_audio ────────────────────────────────────────────────────
|
|
232
|
+
# Live 12.4 exposes Link Audio in the product UX, but LivePilot must
|
|
233
|
+
# not claim automation support from the version number alone. This
|
|
234
|
+
# domain only becomes available when runtime probing observes a stable
|
|
235
|
+
# readable/routable surface.
|
|
236
|
+
link_mode = link_audio_mode if session_ok else "unavailable"
|
|
237
|
+
link_reasons = list(link_audio_reasons or [])
|
|
238
|
+
if not link_reasons:
|
|
239
|
+
if not session_ok:
|
|
240
|
+
link_reasons.append("session_unavailable")
|
|
241
|
+
elif link_mode == "manual_only":
|
|
242
|
+
link_reasons.append("link_audio_unprobed")
|
|
243
|
+
elif link_mode == "unavailable":
|
|
244
|
+
link_reasons.append("link_audio_not_exposed")
|
|
245
|
+
link_available = link_mode in {"readable", "routable"}
|
|
246
|
+
domains["link_audio"] = CapabilityDomain(
|
|
247
|
+
name="link_audio",
|
|
248
|
+
available=link_available,
|
|
249
|
+
confidence=0.8 if link_available else (0.2 if session_ok else 0.0),
|
|
250
|
+
mode=link_mode,
|
|
251
|
+
reasons=link_reasons,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# ── stem_workflow ─────────────────────────────────────────────────
|
|
255
|
+
# Selected-time stem separation / merge are also probe-first. The
|
|
256
|
+
# safe default is manual_only; callable only after concrete evidence.
|
|
257
|
+
stem_mode = stem_workflow_mode if session_ok else "unavailable"
|
|
258
|
+
stem_reasons = list(stem_workflow_reasons or [])
|
|
259
|
+
if not stem_reasons:
|
|
260
|
+
if not session_ok:
|
|
261
|
+
stem_reasons.append("session_unavailable")
|
|
262
|
+
elif stem_mode == "manual_only":
|
|
263
|
+
stem_reasons.append("stem_workflow_unprobed")
|
|
264
|
+
elif stem_mode == "unavailable":
|
|
265
|
+
stem_reasons.append("stem_workflow_not_exposed")
|
|
266
|
+
stem_available = stem_mode == "callable"
|
|
267
|
+
domains["stem_workflow"] = CapabilityDomain(
|
|
268
|
+
name="stem_workflow",
|
|
269
|
+
available=stem_available,
|
|
270
|
+
confidence=0.8 if stem_available else (0.2 if session_ok else 0.0),
|
|
271
|
+
mode=stem_mode,
|
|
272
|
+
reasons=stem_reasons,
|
|
216
273
|
)
|
|
217
274
|
|
|
218
275
|
# ── research (composite) ────────────────────────────────────────
|
|
@@ -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
|
|
@@ -72,6 +91,21 @@ class LiveVersionCapabilities:
|
|
|
72
91
|
"""Stem separation via MFL — 12.3+"""
|
|
73
92
|
return self._version_tuple >= (12, 3, 0)
|
|
74
93
|
|
|
94
|
+
@property
|
|
95
|
+
def has_link_audio(self) -> bool:
|
|
96
|
+
"""Live 12.4 Link Audio UX exists; MCP control remains probe-gated."""
|
|
97
|
+
return self._version_tuple >= (12, 4, 0)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def has_stem_time_selection(self) -> bool:
|
|
101
|
+
"""Live 12.4 selected-time stem separation UX exists; probe before use."""
|
|
102
|
+
return self._version_tuple >= (12, 4, 0)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def has_stem_merge_selected(self) -> bool:
|
|
106
|
+
"""Live 12.4 merge-selected-stems UX exists; probe before use."""
|
|
107
|
+
return self._version_tuple >= (12, 4, 0)
|
|
108
|
+
|
|
75
109
|
@property
|
|
76
110
|
def has_replace_sample_native(self) -> bool:
|
|
77
111
|
"""SimplerDevice.replace_sample(path) — 12.4+"""
|
|
@@ -100,5 +134,8 @@ class LiveVersionCapabilities:
|
|
|
100
134
|
"drum_rack_construction": self.has_drum_rack_construction,
|
|
101
135
|
"take_lanes": self.has_take_lanes,
|
|
102
136
|
"stem_separation": self.has_stem_separation,
|
|
137
|
+
"link_audio": self.has_link_audio,
|
|
138
|
+
"stem_time_selection": self.has_stem_time_selection,
|
|
139
|
+
"stem_merge_selected": self.has_stem_merge_selected,
|
|
103
140
|
"replace_sample_native": self.has_replace_sample_native,
|
|
104
141
|
}
|
|
@@ -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)
|
|
@@ -60,6 +60,11 @@ class SessionKernel:
|
|
|
60
60
|
# may add "sculptor". Empty string = producer picks a default.
|
|
61
61
|
creativity_profile: str = ""
|
|
62
62
|
|
|
63
|
+
# v1.27 operation posture. This is intentionally descriptive at the
|
|
64
|
+
# kernel layer; engines can opt into stricter behavior without forcing
|
|
65
|
+
# every tool to know every profile immediately.
|
|
66
|
+
operation_profile: str = "studio_deep"
|
|
67
|
+
|
|
63
68
|
# Caller-asserted sacred elements. Normally sacred elements come from
|
|
64
69
|
# song_brain; this lets the user or a skill override. Shape matches
|
|
65
70
|
# song_brain.sacred_elements entries: {element_type, description, salience}.
|
|
@@ -94,6 +99,7 @@ def build_session_kernel(
|
|
|
94
99
|
# PR2 — creative controls. All optional; legacy callers unaffected.
|
|
95
100
|
freshness: float = 0.5,
|
|
96
101
|
creativity_profile: str = "",
|
|
102
|
+
operation_profile: str = "studio_deep",
|
|
97
103
|
sacred_elements: Optional[list] = None,
|
|
98
104
|
synth_hints: Optional[dict] = None,
|
|
99
105
|
) -> SessionKernel:
|
|
@@ -137,6 +143,7 @@ def build_session_kernel(
|
|
|
137
143
|
protected_dimensions=protected_dimensions or {},
|
|
138
144
|
freshness=freshness,
|
|
139
145
|
creativity_profile=creativity_profile,
|
|
146
|
+
operation_profile=operation_profile,
|
|
140
147
|
sacred_elements=sacred_elements or [],
|
|
141
148
|
synth_hints=synth_hints or {},
|
|
142
149
|
)
|