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
|
@@ -301,9 +301,13 @@ def run_vibe_fit_critic(
|
|
|
301
301
|
recommendation="No taste evidence yet — neutral score",
|
|
302
302
|
)
|
|
303
303
|
|
|
304
|
-
# Compute energy proxy from sample characteristics (0.0 - 1.0)
|
|
305
|
-
# brightness
|
|
306
|
-
|
|
304
|
+
# Compute energy proxy from sample characteristics (0.0 - 1.0).
|
|
305
|
+
# brightness is already a 0.0-1.0 fraction, but transient_density is peaks
|
|
306
|
+
# PER SECOND (unbounded). Normalize it through a saturating curve before
|
|
307
|
+
# averaging so a busy break does not pin the proxy to 1.0 for every sample.
|
|
308
|
+
# ~12 peaks/s (busy 16ths) maps to ~1.0.
|
|
309
|
+
transient_norm = max(0.0, min(1.0, profile.transient_density / 12.0))
|
|
310
|
+
energy = (profile.brightness + transient_norm) / 2.0
|
|
307
311
|
energy = max(0.0, min(1.0, energy))
|
|
308
312
|
|
|
309
313
|
# Compare against novelty_band as taste proxy
|
|
@@ -12,8 +12,9 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import hashlib
|
|
14
14
|
|
|
15
|
-
# Simpler maps slices to MIDI notes starting at
|
|
16
|
-
|
|
15
|
+
# Simpler maps slices to MIDI notes starting at C1 (36): slice N -> pitch 36+N.
|
|
16
|
+
# Notes at 60+ (C3) trigger no slice and produce silence.
|
|
17
|
+
SLICE_BASE_NOTE = 36
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def plan_slice_steps(
|
|
@@ -6,6 +6,7 @@ direct Splice online catalog hunt/download via the gRPC client.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import asyncio
|
|
9
10
|
import logging
|
|
10
11
|
import os
|
|
11
12
|
from typing import Optional
|
|
@@ -57,7 +58,11 @@ async def analyze_sample(
|
|
|
57
58
|
return {"error": "Could not determine file path — provide file_path directly"}
|
|
58
59
|
|
|
59
60
|
source = "session_clip" if track_index is not None else "filesystem"
|
|
60
|
-
|
|
61
|
+
# Offload audio decode + numpy FFT off the event loop (heavy CPU/IO).
|
|
62
|
+
loop = asyncio.get_running_loop()
|
|
63
|
+
profile = await loop.run_in_executor(
|
|
64
|
+
None, build_profile_from_filename, file_path, source
|
|
65
|
+
)
|
|
61
66
|
return profile.to_dict()
|
|
62
67
|
|
|
63
68
|
|
|
@@ -86,6 +91,11 @@ def evaluate_sample_fit(
|
|
|
86
91
|
song_key = None
|
|
87
92
|
session_tempo = 120.0
|
|
88
93
|
existing_roles: list[str] = []
|
|
94
|
+
# Map track index -> name so the key-detection loop below can look a
|
|
95
|
+
# track's name up by its real index. existing_roles is a *packed*
|
|
96
|
+
# list (unnamed/errored tracks are skipped), so indexing it by track
|
|
97
|
+
# index misaligns names with the clip that produced the notes.
|
|
98
|
+
track_names_by_index: dict[int, str] = {}
|
|
89
99
|
|
|
90
100
|
try:
|
|
91
101
|
ableton = ctx.lifespan_context["ableton"]
|
|
@@ -100,6 +110,7 @@ def evaluate_sample_fit(
|
|
|
100
110
|
name = track_info.get("name", "").lower()
|
|
101
111
|
if name:
|
|
102
112
|
existing_roles.append(name)
|
|
113
|
+
track_names_by_index[i] = name
|
|
103
114
|
except Exception as exc:
|
|
104
115
|
logger.debug("get_track_info(%d) skipped: %s", i, exc)
|
|
105
116
|
continue
|
|
@@ -147,9 +158,7 @@ def evaluate_sample_fit(
|
|
|
147
158
|
) else []
|
|
148
159
|
if not notes:
|
|
149
160
|
continue
|
|
150
|
-
track_name = (
|
|
151
|
-
existing_roles[i] if i < len(existing_roles) else ""
|
|
152
|
-
)
|
|
161
|
+
track_name = track_names_by_index.get(i, "")
|
|
153
162
|
if harmonic_score(notes, track_name) >= 0.3:
|
|
154
163
|
harmonic_pool.extend(notes)
|
|
155
164
|
|
|
@@ -189,7 +198,11 @@ def evaluate_sample_fit(
|
|
|
189
198
|
recommended_intent=intent,
|
|
190
199
|
surgeon_plan=surgeon_plan,
|
|
191
200
|
alchemist_plan=alchemist_plan,
|
|
192
|
-
warnings=[
|
|
201
|
+
warnings=[
|
|
202
|
+
c.recommendation
|
|
203
|
+
for c in critics.values()
|
|
204
|
+
if getattr(c, "available", True) and c.score < 0.5
|
|
205
|
+
],
|
|
193
206
|
)
|
|
194
207
|
return report.to_dict()
|
|
195
208
|
|
|
@@ -316,12 +329,16 @@ async def search_samples(
|
|
|
316
329
|
if not used_grpc:
|
|
317
330
|
splice = SpliceSource()
|
|
318
331
|
if splice.enabled:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
332
|
+
# Offload blocking SQLite query off the event loop.
|
|
333
|
+
splice_results = await asyncio.get_running_loop().run_in_executor(
|
|
334
|
+
None,
|
|
335
|
+
lambda: splice.search(
|
|
336
|
+
query=query,
|
|
337
|
+
max_results=max_results,
|
|
338
|
+
key=key,
|
|
339
|
+
bpm_min=bpm_min,
|
|
340
|
+
bpm_max=bpm_max,
|
|
341
|
+
),
|
|
325
342
|
)
|
|
326
343
|
for candidate in splice_results:
|
|
327
344
|
d = candidate.to_dict()
|
|
@@ -335,12 +352,16 @@ async def search_samples(
|
|
|
335
352
|
browser = BrowserSource()
|
|
336
353
|
for category in browser.DEFAULT_CATEGORIES:
|
|
337
354
|
try:
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
"
|
|
342
|
-
|
|
343
|
-
|
|
355
|
+
# Offload blocking TCP round-trip off the event loop.
|
|
356
|
+
search_result = await asyncio.get_running_loop().run_in_executor(
|
|
357
|
+
None,
|
|
358
|
+
lambda: ableton.send_command("search_browser", {
|
|
359
|
+
"path": category,
|
|
360
|
+
"name_filter": query,
|
|
361
|
+
"loadable_only": True,
|
|
362
|
+
"max_results": max_results,
|
|
363
|
+
}),
|
|
364
|
+
)
|
|
344
365
|
raw = search_result.get("results", [])
|
|
345
366
|
parsed = browser.parse_results(raw, category)
|
|
346
367
|
for candidate in parsed:
|
|
@@ -359,7 +380,10 @@ async def search_samples(
|
|
|
359
380
|
"~/Music", "~/Documents/Samples",
|
|
360
381
|
"~/Documents/LivePilot/downloads",
|
|
361
382
|
])
|
|
362
|
-
|
|
383
|
+
# Offload blocking recursive filesystem scan off the event loop.
|
|
384
|
+
fs_results = await asyncio.get_running_loop().run_in_executor(
|
|
385
|
+
None, lambda: fs.search(query, max_results=max_results)
|
|
386
|
+
)
|
|
363
387
|
for candidate in fs_results:
|
|
364
388
|
d = candidate.to_dict()
|
|
365
389
|
d["source_priority"] = 3
|
|
@@ -1049,10 +1073,18 @@ async def splice_download_sample(
|
|
|
1049
1073
|
if copy_to_user_library:
|
|
1050
1074
|
dest_dir = os.path.expanduser(_SPLICE_USER_LIB_DEST)
|
|
1051
1075
|
try:
|
|
1052
|
-
os.makedirs(dest_dir, exist_ok=True)
|
|
1053
1076
|
dest_path = os.path.join(dest_dir, os.path.basename(local_path))
|
|
1054
|
-
|
|
1055
|
-
|
|
1077
|
+
|
|
1078
|
+
def _copy_into_user_library():
|
|
1079
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
1080
|
+
if not os.path.exists(dest_path):
|
|
1081
|
+
shutil.copy2(local_path, dest_path)
|
|
1082
|
+
|
|
1083
|
+
# Offload the blocking filesystem copy off the event loop,
|
|
1084
|
+
# mirroring the run_in_executor used in splice_preview_sample.
|
|
1085
|
+
await asyncio.get_running_loop().run_in_executor(
|
|
1086
|
+
None, _copy_into_user_library
|
|
1087
|
+
)
|
|
1056
1088
|
response["user_library_path"] = dest_path
|
|
1057
1089
|
# URI format Ableton uses for user_library samples
|
|
1058
1090
|
response["browser_uri"] = (
|
|
@@ -566,7 +566,23 @@ def detect_identity_drift(
|
|
|
566
566
|
drift.drift_score += 0.2 * len(lost)
|
|
567
567
|
|
|
568
568
|
# Energy arc shift
|
|
569
|
-
|
|
569
|
+
# Align on section_id rather than list position: inserting/removing/
|
|
570
|
+
# renaming-to-empty a scene shifts energy_arc indices (the BUG-B12 skip
|
|
571
|
+
# drops empty sections), so a positional diff fabricates drift on every
|
|
572
|
+
# section after the change. Diffing the shared section_ids compares like
|
|
573
|
+
# with like and is stable under reordering/insertion.
|
|
574
|
+
before_energy = {s.section_id: s.energy_level for s in before.section_purposes}
|
|
575
|
+
after_energy = {s.section_id: s.energy_level for s in after.section_purposes}
|
|
576
|
+
shared_ids = before_energy.keys() & after_energy.keys()
|
|
577
|
+
if shared_ids:
|
|
578
|
+
diff = sum(
|
|
579
|
+
abs(before_energy[sid] - after_energy[sid]) for sid in shared_ids
|
|
580
|
+
) / len(shared_ids)
|
|
581
|
+
drift.energy_arc_shift = round(diff, 3)
|
|
582
|
+
drift.drift_score += diff * 0.2
|
|
583
|
+
elif before.energy_arc and after.energy_arc:
|
|
584
|
+
# Fallback for snapshots that carry energy_arc but no section_purposes
|
|
585
|
+
# (e.g. older brains): keep the legacy positional comparison.
|
|
570
586
|
min_len = min(len(before.energy_arc), len(after.energy_arc))
|
|
571
587
|
if min_len > 0:
|
|
572
588
|
diff = sum(
|
|
@@ -408,7 +408,7 @@ def _cross_engine_hint_for_track(
|
|
|
408
408
|
return None
|
|
409
409
|
track_issues = [
|
|
410
410
|
i for i in mix_issues
|
|
411
|
-
if getattr(i, "
|
|
411
|
+
if track_index in (getattr(i, "affected_tracks", []) or [])
|
|
412
412
|
]
|
|
413
413
|
if not track_issues:
|
|
414
414
|
return None
|
|
@@ -45,6 +45,7 @@ import logging
|
|
|
45
45
|
import os
|
|
46
46
|
import ssl
|
|
47
47
|
import urllib.error
|
|
48
|
+
import urllib.parse
|
|
48
49
|
import urllib.request
|
|
49
50
|
from dataclasses import dataclass, field
|
|
50
51
|
from typing import Any, Optional
|
|
@@ -89,6 +90,10 @@ class SpliceHTTPConfig:
|
|
|
89
90
|
user_agent: str = "LivePilot/1.16 (+splice-http-bridge)"
|
|
90
91
|
timeout_sec: float = 30.0
|
|
91
92
|
max_retries: int = 2
|
|
93
|
+
# Opt-in escape hatch: when True, the bearer-token host guard in
|
|
94
|
+
# SpliceHTTPBridge._request is bypassed so a custom (non-splice.com)
|
|
95
|
+
# base_url may receive the session token. Defaults off.
|
|
96
|
+
allow_unverified_endpoints: bool = False
|
|
92
97
|
# Whether any of the above values came from user config (file or env)
|
|
93
98
|
# rather than the built-in defaults. Used by `is_user_configured`.
|
|
94
99
|
_user_configured: bool = False
|
|
@@ -135,6 +140,7 @@ class SpliceHTTPConfig:
|
|
|
135
140
|
"splice.json: %s must be an integer", key,
|
|
136
141
|
)
|
|
137
142
|
if data.get("allow_unverified_endpoints"):
|
|
143
|
+
instance.allow_unverified_endpoints = True
|
|
138
144
|
loaded_from_file = True
|
|
139
145
|
except (OSError, json.JSONDecodeError) as exc:
|
|
140
146
|
logger.warning(
|
|
@@ -161,6 +167,9 @@ class SpliceHTTPConfig:
|
|
|
161
167
|
"Env %s has invalid value: %s", env_name, exc,
|
|
162
168
|
)
|
|
163
169
|
|
|
170
|
+
if os.environ.get("SPLICE_ALLOW_UNVERIFIED_ENDPOINTS") == "1":
|
|
171
|
+
instance.allow_unverified_endpoints = True
|
|
172
|
+
|
|
164
173
|
instance._user_configured = (
|
|
165
174
|
loaded_from_file
|
|
166
175
|
or env_configured
|
|
@@ -406,6 +415,23 @@ class SpliceHTTPError(Exception):
|
|
|
406
415
|
}
|
|
407
416
|
|
|
408
417
|
|
|
418
|
+
def _is_trusted_splice_host(url: str) -> bool:
|
|
419
|
+
"""True only for HTTPS URLs on a splice.com-owned host.
|
|
420
|
+
|
|
421
|
+
Gates attaching the rotating session bearer token: a poisoned base_url
|
|
422
|
+
(custom config file or SPLICE_API_BASE_URL env) must not be able to
|
|
423
|
+
exfiltrate the token to an attacker-controlled endpoint.
|
|
424
|
+
"""
|
|
425
|
+
try:
|
|
426
|
+
parsed = urllib.parse.urlparse(url)
|
|
427
|
+
except (ValueError, AttributeError):
|
|
428
|
+
return False
|
|
429
|
+
if parsed.scheme != "https":
|
|
430
|
+
return False
|
|
431
|
+
host = (parsed.hostname or "").lower()
|
|
432
|
+
return host == "splice.com" or host.endswith(".splice.com")
|
|
433
|
+
|
|
434
|
+
|
|
409
435
|
class SpliceHTTPBridge:
|
|
410
436
|
"""Low-level HTTPS client for Splice cloud APIs.
|
|
411
437
|
|
|
@@ -442,10 +468,26 @@ class SpliceHTTPBridge:
|
|
|
442
468
|
|
|
443
469
|
url = self.config.base_url.rstrip("/") + path
|
|
444
470
|
if query:
|
|
445
|
-
import urllib.parse
|
|
446
471
|
qs = urllib.parse.urlencode(query)
|
|
447
472
|
url = f"{url}?{qs}"
|
|
448
473
|
|
|
474
|
+
# Token-exfil guard: only attach the Splice session bearer when the
|
|
475
|
+
# destination is a splice.com-owned HTTPS host. A poisoned base_url
|
|
476
|
+
# (custom config/env) otherwise leaks the rotating session token to an
|
|
477
|
+
# attacker-controlled endpoint. Explicit opt-in via
|
|
478
|
+
# allow_unverified_endpoints / SPLICE_ALLOW_UNVERIFIED_ENDPOINTS=1.
|
|
479
|
+
if not _is_trusted_splice_host(url) and not self.config.allow_unverified_endpoints:
|
|
480
|
+
raise SpliceHTTPError(
|
|
481
|
+
code="UNTRUSTED_ENDPOINT",
|
|
482
|
+
message=(
|
|
483
|
+
f"Refusing to send the Splice session token to non-Splice host "
|
|
484
|
+
f"'{self.config.base_url}'. Set allow_unverified_endpoints in "
|
|
485
|
+
f"~/.livepilot/splice.json (or SPLICE_ALLOW_UNVERIFIED_ENDPOINTS=1) "
|
|
486
|
+
f"to override."
|
|
487
|
+
),
|
|
488
|
+
endpoint=path,
|
|
489
|
+
)
|
|
490
|
+
|
|
449
491
|
data_bytes = None
|
|
450
492
|
headers = {
|
|
451
493
|
"Authorization": f"Bearer {token}",
|
|
@@ -185,10 +185,14 @@ class SpliceSample:
|
|
|
185
185
|
def is_free(self) -> bool:
|
|
186
186
|
"""True iff this sample costs no credits under any plan.
|
|
187
187
|
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
`IsPremium` is Splice's authoritative free flag and is always
|
|
189
|
+
populated in the proto. `Price` is NOT reliable: proto3 ints
|
|
190
|
+
default to 0 when the server omits the field, so OR-ing in
|
|
191
|
+
`price == 0` would misclassify premium samples (whose Price is
|
|
192
|
+
unset/zero) as free and bypass ALL credit/quota gating. Trust
|
|
193
|
+
only `IsPremium`.
|
|
190
194
|
"""
|
|
191
|
-
return
|
|
195
|
+
return not self.is_premium
|
|
192
196
|
|
|
193
197
|
def to_dict(self) -> dict:
|
|
194
198
|
return {
|
|
@@ -9,9 +9,12 @@ detune/unison variants and dual-filter variants.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import hashlib
|
|
12
|
+
import logging
|
|
12
13
|
from typing import Optional
|
|
13
14
|
|
|
14
15
|
from ...branches import BranchSeed, freeform_seed
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
15
18
|
from ..models import (
|
|
16
19
|
SynthProfile,
|
|
17
20
|
TimbralFingerprint,
|
|
@@ -115,7 +118,16 @@ class AnalogAdapter:
|
|
|
115
118
|
try:
|
|
116
119
|
maybe = strategy_fn(profile, target, kernel, adapter=self)
|
|
117
120
|
except Exception:
|
|
118
|
-
# Never let one strategy's crash kill the rest
|
|
121
|
+
# Never let one strategy's crash kill the rest, but make the
|
|
122
|
+
# swallowed failure observable instead of silently degrading
|
|
123
|
+
# the branch set.
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Analog strategy %s crashed on track %s device %s; skipping",
|
|
126
|
+
getattr(strategy_fn, "__name__", strategy_fn),
|
|
127
|
+
profile.track_index,
|
|
128
|
+
profile.device_index,
|
|
129
|
+
exc_info=True,
|
|
130
|
+
)
|
|
119
131
|
continue
|
|
120
132
|
if maybe is not None:
|
|
121
133
|
results.append(maybe)
|
|
@@ -103,8 +103,11 @@ def _pick_target_modulator(
|
|
|
103
103
|
return None
|
|
104
104
|
candidates.sort(reverse=True) # highest level first
|
|
105
105
|
top_level, top_op = candidates[0]
|
|
106
|
-
# If every modulator is silent
|
|
107
|
-
#
|
|
106
|
+
# If every modulator is silent (Level <= 0), shifting its Coarse produces
|
|
107
|
+
# no audible change. Return None so the caller falls through to
|
|
108
|
+
# _fallback_carrier_target and targets an audible carrier instead.
|
|
109
|
+
if top_level <= 0.0:
|
|
110
|
+
return None
|
|
108
111
|
return top_op
|
|
109
112
|
|
|
110
113
|
|
|
@@ -48,8 +48,8 @@ from .base import register_adapter
|
|
|
48
48
|
# corpus (see skills/livepilot-core/references/device-knowledge/
|
|
49
49
|
# instruments-synths.md). PR9 uses a small subset; later PRs extend.
|
|
50
50
|
_KNOWN_PARAMS = {
|
|
51
|
-
"Osc 1
|
|
52
|
-
"Osc 2
|
|
51
|
+
"Osc 1 Pos",
|
|
52
|
+
"Osc 2 Pos",
|
|
53
53
|
"Osc 1 Transpose",
|
|
54
54
|
"Osc 2 Transpose",
|
|
55
55
|
"Voices",
|
|
@@ -220,7 +220,7 @@ class WavetableAdapter:
|
|
|
220
220
|
# shift to that region's center. The actual shift magnitude
|
|
221
221
|
# (how close to the center) scales with freshness — low
|
|
222
222
|
# freshness stops partway, high freshness commits fully.
|
|
223
|
-
current_pos = float(profile.parameter_state.get("Osc 1
|
|
223
|
+
current_pos = float(profile.parameter_state.get("Osc 1 Pos", 0.0) or 0.0)
|
|
224
224
|
current_region = _classify_position(current_pos)
|
|
225
225
|
target_region = _choose_target_region(current_region, target)
|
|
226
226
|
region_target_pos = _region_center(target_region)
|
|
@@ -281,7 +281,7 @@ class WavetableAdapter:
|
|
|
281
281
|
"params": {
|
|
282
282
|
"track_index": track,
|
|
283
283
|
"device_index": device,
|
|
284
|
-
"parameter_name": "Osc 1
|
|
284
|
+
"parameter_name": "Osc 1 Pos",
|
|
285
285
|
"value": new_pos,
|
|
286
286
|
},
|
|
287
287
|
},
|
|
@@ -62,6 +62,12 @@ class SectionNode:
|
|
|
62
62
|
density: float # 0.0-1.0 (how many tracks are active)
|
|
63
63
|
tracks_active: list[int] = field(default_factory=list)
|
|
64
64
|
name: str = ""
|
|
65
|
+
# Real session scene/row index this section maps to (the clip slot to
|
|
66
|
+
# read notes from). -1 means "not scene-backed" (e.g. built from the
|
|
67
|
+
# arrangement view), in which case callers should fall back to the
|
|
68
|
+
# section's position in the graph. Kept last so existing positional
|
|
69
|
+
# SectionNode(...) constructions stay valid.
|
|
70
|
+
scene_index: int = -1
|
|
65
71
|
|
|
66
72
|
def length_bars(self) -> int:
|
|
67
73
|
return self.end_bar - self.start_bar
|
|
@@ -112,6 +112,10 @@ def build_section_graph_from_scenes(
|
|
|
112
112
|
density=density,
|
|
113
113
|
tracks_active=active_tracks,
|
|
114
114
|
name=scene_name,
|
|
115
|
+
# Real session scene row — used as the get_notes clip_index.
|
|
116
|
+
# Differs from the section's position in this list whenever
|
|
117
|
+
# earlier unnamed/empty scenes were skipped above.
|
|
118
|
+
scene_index=i,
|
|
115
119
|
))
|
|
116
120
|
current_bar = end_bar
|
|
117
121
|
|
|
@@ -380,9 +380,10 @@ async def check_clip_key_consistency(
|
|
|
380
380
|
# 1) Resolve the clip's file path. Relies on the M4L bridge.
|
|
381
381
|
try:
|
|
382
382
|
from .analyzer import get_clip_file_path as _get_path
|
|
383
|
-
#
|
|
384
|
-
#
|
|
385
|
-
|
|
383
|
+
# Under FastMCP 3.3.1 @mcp.tool() returns the plain async function
|
|
384
|
+
# (no .fn accessor), so we call it directly for composition — the
|
|
385
|
+
# same pattern analyzer.verify_all_devices_health uses.
|
|
386
|
+
path_resp = await _get_path(ctx, track_index, clip_index)
|
|
386
387
|
except Exception as exc:
|
|
387
388
|
return {
|
|
388
389
|
"track_index": track_index,
|
|
@@ -414,7 +415,7 @@ async def check_clip_key_consistency(
|
|
|
414
415
|
# 3) Query the session-detected key (needs the analyzer).
|
|
415
416
|
try:
|
|
416
417
|
from .analyzer import get_detected_key as _get_key
|
|
417
|
-
key_resp = await _get_key
|
|
418
|
+
key_resp = await _get_key(ctx)
|
|
418
419
|
except Exception as exc:
|
|
419
420
|
return {
|
|
420
421
|
"track_index": track_index,
|
|
@@ -408,12 +408,16 @@ def get_harmony_field(
|
|
|
408
408
|
for i, t in enumerate(tracks)}
|
|
409
409
|
|
|
410
410
|
# Per-track scan: fetch notes + score, then sort by score desc.
|
|
411
|
+
# Use the section's real scene row (scene_index) for the clip slot,
|
|
412
|
+
# not section_index (its position in the section graph) — these
|
|
413
|
+
# diverge whenever earlier unnamed/empty scenes were skipped.
|
|
414
|
+
scene_idx = section.scene_index if getattr(section, "scene_index", -1) >= 0 else section_index
|
|
411
415
|
HARMONIC_THRESHOLD = 0.3
|
|
412
416
|
candidates: list[tuple[float, int, list[dict]]] = []
|
|
413
417
|
for t_idx in section.tracks_active:
|
|
414
418
|
try:
|
|
415
419
|
result = ableton.send_command("get_notes", {
|
|
416
|
-
"track_index": t_idx, "clip_index":
|
|
420
|
+
"track_index": t_idx, "clip_index": scene_idx,
|
|
417
421
|
})
|
|
418
422
|
except Exception as exc:
|
|
419
423
|
logger.debug("harmony scan track %d: %s", t_idx, exc)
|
|
@@ -34,6 +34,12 @@ def _require_midiutil():
|
|
|
34
34
|
)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
# Hard cap on extract_piano_roll's emitted matrix (pitch_range * time_steps).
|
|
38
|
+
# ~16k cells keeps the JSON response well inside a single context window even
|
|
39
|
+
# for a full 88-key range; coarsen 'resolution' to fit larger material.
|
|
40
|
+
_PIANO_ROLL_CELL_BUDGET = 16000
|
|
41
|
+
|
|
42
|
+
|
|
37
43
|
def _require_pretty_midi():
|
|
38
44
|
try:
|
|
39
45
|
import pretty_midi
|
|
@@ -348,7 +354,20 @@ def extract_piano_roll(
|
|
|
348
354
|
|
|
349
355
|
Returns a velocity matrix [pitch_index][time_step] trimmed to
|
|
350
356
|
the actual pitch range. Resolution is in beats (0.125 = 32nd note).
|
|
357
|
+
|
|
358
|
+
To keep the response bounded, the emitted matrix is capped at
|
|
359
|
+
``_PIANO_ROLL_CELL_BUDGET`` cells (pitch_range * time_steps). When the
|
|
360
|
+
full roll exceeds the budget the tool returns a structured error with the
|
|
361
|
+
offending dimensions instead of a multi-MB matrix; coarsen ``resolution``
|
|
362
|
+
(larger value = fewer time steps) to fit.
|
|
351
363
|
"""
|
|
364
|
+
if not resolution or resolution <= 0:
|
|
365
|
+
return {
|
|
366
|
+
"error": "resolution must be a positive number of beats per step "
|
|
367
|
+
f"(got {resolution!r}); e.g. 0.125 for 32nd notes.",
|
|
368
|
+
"code": "INVALID_PARAM",
|
|
369
|
+
}
|
|
370
|
+
|
|
352
371
|
pretty_midi = _require_pretty_midi()
|
|
353
372
|
path = _validate_midi_path(file_path)
|
|
354
373
|
pm = pretty_midi.PrettyMIDI(str(path))
|
|
@@ -373,10 +392,30 @@ def extract_piano_roll(
|
|
|
373
392
|
pitch_max = int(active_pitches[-1])
|
|
374
393
|
trimmed = roll[pitch_min:pitch_max + 1, :]
|
|
375
394
|
|
|
395
|
+
pitch_range = int(trimmed.shape[0])
|
|
396
|
+
time_steps = int(trimmed.shape[1])
|
|
397
|
+
cell_count = pitch_range * time_steps
|
|
398
|
+
if cell_count > _PIANO_ROLL_CELL_BUDGET:
|
|
399
|
+
return {
|
|
400
|
+
"error": (
|
|
401
|
+
f"piano roll too large: {pitch_range} pitches x {time_steps} "
|
|
402
|
+
f"steps = {cell_count} cells exceeds the {_PIANO_ROLL_CELL_BUDGET}"
|
|
403
|
+
"-cell budget. Increase 'resolution' (e.g. 0.25 or 0.5 beats) "
|
|
404
|
+
"to coarsen the time axis."
|
|
405
|
+
),
|
|
406
|
+
"code": "INVALID_PARAM",
|
|
407
|
+
"pitch_min": pitch_min,
|
|
408
|
+
"pitch_max": pitch_max,
|
|
409
|
+
"pitch_range": pitch_range,
|
|
410
|
+
"time_steps": time_steps,
|
|
411
|
+
"cell_budget": _PIANO_ROLL_CELL_BUDGET,
|
|
412
|
+
"resolution": resolution,
|
|
413
|
+
}
|
|
414
|
+
|
|
376
415
|
return {
|
|
377
416
|
"piano_roll": trimmed.astype(int).tolist(),
|
|
378
417
|
"pitch_min": pitch_min,
|
|
379
418
|
"pitch_max": pitch_max,
|
|
380
|
-
"time_steps":
|
|
419
|
+
"time_steps": time_steps,
|
|
381
420
|
"resolution": resolution,
|
|
382
421
|
}
|
|
@@ -167,7 +167,7 @@ async def get_session_diagnostics(ctx: Context, check_clip_keys: bool = False) -
|
|
|
167
167
|
# reasonable upper bound for typical production sessions.
|
|
168
168
|
for clip_idx in range(min(32, len(session_info.get("scenes", []) or []) or 8)):
|
|
169
169
|
try:
|
|
170
|
-
check = await check_clip_key_consistency
|
|
170
|
+
check = await check_clip_key_consistency(ctx, t_idx, clip_idx)
|
|
171
171
|
except Exception: # noqa: BLE001 — any failure means "skip this clip"
|
|
172
172
|
continue
|
|
173
173
|
if not isinstance(check, dict):
|
|
@@ -105,6 +105,62 @@ class DetectedPlugin:
|
|
|
105
105
|
# ─── Top-level entry ────────────────────────────────────────────────────────
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
# Bundle file/dir extensions per plugin format. VST3/AU/AAX/LV2 bundles are
|
|
109
|
+
# themselves directories (Foo.vst3/Contents/...), so the recursive scan treats
|
|
110
|
+
# a matching entry as a leaf — it records it but never descends inside.
|
|
111
|
+
_BUNDLE_EXTS: dict[str, tuple[str, ...]] = {
|
|
112
|
+
"VST3": (".vst3",),
|
|
113
|
+
"AU": (".component",),
|
|
114
|
+
"VST2": (".vst", ".dylib"),
|
|
115
|
+
"AAX": (".aaxplugin",),
|
|
116
|
+
"LV2": (".lv2",),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_plugin_bundle(path: Path, fmt: str) -> bool:
|
|
121
|
+
"""True when *path* is itself a plugin bundle/file for *fmt* (a leaf to
|
|
122
|
+
record) rather than a vendor/organisational subfolder to recurse into."""
|
|
123
|
+
return path.suffix in _BUNDLE_EXTS.get(fmt, ())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _walk_plugin_bundles(root: Path, fmt: str, max_depth: int = 8):
|
|
127
|
+
"""Yield every *fmt* plugin bundle/file under *root*, recursing into plain
|
|
128
|
+
(vendor) subdirectories but NOT descending into bundle directories.
|
|
129
|
+
|
|
130
|
+
Fixes the flat scan that only saw top-level bundles: plugins nested in
|
|
131
|
+
vendor subfolders (e.g. VST3/Arturia/Analog Lab.vst3) were invisible, and
|
|
132
|
+
the vendor folders themselves were mis-emitted as junk 'unknown-*' records.
|
|
133
|
+
Symlink cycles are guarded via resolved-path dedup; recursion depth is
|
|
134
|
+
capped; unreadable directories are skipped with a warning.
|
|
135
|
+
"""
|
|
136
|
+
seen_dirs: set[Path] = set()
|
|
137
|
+
stack: list[tuple[Path, int]] = [(root, 0)]
|
|
138
|
+
while stack:
|
|
139
|
+
current, depth = stack.pop()
|
|
140
|
+
try:
|
|
141
|
+
real = current.resolve()
|
|
142
|
+
except OSError:
|
|
143
|
+
continue
|
|
144
|
+
if real in seen_dirs:
|
|
145
|
+
continue
|
|
146
|
+
seen_dirs.add(real)
|
|
147
|
+
try:
|
|
148
|
+
entries = sorted(current.iterdir())
|
|
149
|
+
except (PermissionError, OSError) as e:
|
|
150
|
+
logger.warning("Cannot read %s: %s", current, e)
|
|
151
|
+
continue
|
|
152
|
+
for entry in entries:
|
|
153
|
+
if _is_plugin_bundle(entry, fmt):
|
|
154
|
+
yield entry # leaf — never descend into a bundle
|
|
155
|
+
elif depth < max_depth:
|
|
156
|
+
try:
|
|
157
|
+
descend = entry.is_dir()
|
|
158
|
+
except OSError:
|
|
159
|
+
descend = False
|
|
160
|
+
if descend:
|
|
161
|
+
stack.append((entry, depth + 1))
|
|
162
|
+
|
|
163
|
+
|
|
108
164
|
def detect_installed_plugins(
|
|
109
165
|
paths: list[tuple[Path, str]] | None = None,
|
|
110
166
|
formats: list[str] | None = None,
|
|
@@ -138,20 +194,19 @@ def detect_installed_plugins(
|
|
|
138
194
|
results: list[DetectedPlugin] = []
|
|
139
195
|
seen_keys: set[tuple[str, str, str]] = set() # (vendor_lc, name_lc, format)
|
|
140
196
|
|
|
141
|
-
# 1. Path-based scan — covers VST3 / VST2 / AAX / LV2 + co-located AU v2
|
|
197
|
+
# 1. Path-based scan — covers VST3 / VST2 / AAX / LV2 + co-located AU v2.
|
|
198
|
+
# Recurses into vendor subfolders (e.g. VST3/Arturia/Foo.vst3) but never
|
|
199
|
+
# descends into bundle directories themselves. See _walk_plugin_bundles.
|
|
142
200
|
for root, fmt in paths:
|
|
143
201
|
if not root.exists():
|
|
144
202
|
continue
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
results.append(plugin)
|
|
153
|
-
except (PermissionError, OSError) as e:
|
|
154
|
-
logger.warning("Cannot read %s: %s", root, e)
|
|
203
|
+
for entry in _walk_plugin_bundles(root, fmt):
|
|
204
|
+
plugin = _identify_plugin(entry, fmt)
|
|
205
|
+
if plugin:
|
|
206
|
+
key = ((plugin.vendor or "").lower(), plugin.name.lower(), plugin.format)
|
|
207
|
+
if key not in seen_keys:
|
|
208
|
+
seen_keys.add(key)
|
|
209
|
+
results.append(plugin)
|
|
155
210
|
|
|
156
211
|
# 2. auval-based AU enumeration — captures AUv3 + arbitrary-location AUs
|
|
157
212
|
want_au = formats is None or "AU" in (formats or [])
|
|
@@ -11,6 +11,7 @@ mtime-based incremental skipping is on by default.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import fnmatch
|
|
14
15
|
import hashlib
|
|
15
16
|
import json
|
|
16
17
|
import logging
|
|
@@ -181,7 +182,12 @@ def _iter_files(
|
|
|
181
182
|
continue
|
|
182
183
|
if not scanner.is_applicable(p):
|
|
183
184
|
continue
|
|
184
|
-
|
|
185
|
+
# Honor excludes against BOTH the filename (Path.match, right-anchored —
|
|
186
|
+
# keeps filename-glob patterns like "*.tmp" working) and the full path
|
|
187
|
+
# string (fnmatch — lets directory patterns like "*Backup*" exclude
|
|
188
|
+
# files *inside* a Backup/ folder, which Path.match alone cannot do).
|
|
189
|
+
path_str = p.as_posix()
|
|
190
|
+
if any(p.match(g) or fnmatch.fnmatch(path_str, g) for g in excludes):
|
|
185
191
|
continue
|
|
186
192
|
yield p
|
|
187
193
|
|