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.
Files changed (58) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +25 -6
  3. package/bin/livepilot.js +4 -2
  4. package/installer/install.js +21 -12
  5. package/livepilot/.Codex-plugin/plugin.json +1 -1
  6. package/livepilot/.claude-plugin/plugin.json +1 -1
  7. package/livepilot/skills/livepilot-core/SKILL.md +8 -8
  8. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  9. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  10. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  11. package/m4l_device/livepilot_bridge.js +1 -1
  12. package/mcp_server/__init__.py +1 -1
  13. package/mcp_server/atlas/__init__.py +20 -1
  14. package/mcp_server/atlas/tools.py +83 -22
  15. package/mcp_server/composer/full/apply.py +9 -0
  16. package/mcp_server/composer/full/layer_planner.py +16 -6
  17. package/mcp_server/creative_constraints/engine.py +46 -0
  18. package/mcp_server/experiment/engine.py +10 -3
  19. package/mcp_server/grader/client.py +30 -2
  20. package/mcp_server/grader/tools.py +8 -2
  21. package/mcp_server/hook_hunter/analyzer.py +15 -2
  22. package/mcp_server/m4l_bridge.py +19 -21
  23. package/mcp_server/memory/taste_graph.py +16 -0
  24. package/mcp_server/memory/technique_store.py +23 -3
  25. package/mcp_server/mix_engine/state_builder.py +4 -2
  26. package/mcp_server/musical_intelligence/detectors.py +11 -2
  27. package/mcp_server/musical_intelligence/tools.py +15 -2
  28. package/mcp_server/preview_studio/engine.py +30 -1
  29. package/mcp_server/project_brain/tools.py +56 -52
  30. package/mcp_server/reference_engine/tools.py +22 -2
  31. package/mcp_server/runtime/live_version.py +27 -8
  32. package/mcp_server/runtime/safety_kernel.py +11 -0
  33. package/mcp_server/sample_engine/critics.py +7 -3
  34. package/mcp_server/sample_engine/slice_workflow.py +3 -2
  35. package/mcp_server/sample_engine/tools.py +53 -21
  36. package/mcp_server/song_brain/builder.py +17 -1
  37. package/mcp_server/sound_design/tools.py +1 -1
  38. package/mcp_server/splice_client/http_bridge.py +43 -1
  39. package/mcp_server/splice_client/models.py +7 -3
  40. package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
  41. package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
  42. package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
  43. package/mcp_server/tools/_composition_engine/models.py +6 -0
  44. package/mcp_server/tools/_composition_engine/sections.py +4 -0
  45. package/mcp_server/tools/clips.py +5 -4
  46. package/mcp_server/tools/composition.py +5 -1
  47. package/mcp_server/tools/midi_io.py +40 -1
  48. package/mcp_server/tools/transport.py +1 -1
  49. package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
  50. package/mcp_server/user_corpus/runner.py +7 -1
  51. package/mcp_server/user_corpus/scanners/amxd.py +24 -13
  52. package/mcp_server/user_corpus/tools.py +45 -9
  53. package/mcp_server/wonder_mode/tools.py +66 -27
  54. package/package.json +1 -1
  55. package/remote_script/LivePilot/__init__.py +1 -1
  56. package/remote_script/LivePilot/server.py +23 -3
  57. package/requirements.txt +1 -1
  58. 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 and transient_density are both 0.0-1.0 range
306
- energy = (profile.brightness + profile.transient_density) / 2.0
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 C3 (60)
16
- SLICE_BASE_NOTE = 60
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
- profile = build_profile_from_filename(file_path, source=source)
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=[c.recommendation for c in critics.values() if c.score < 0.5],
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
- splice_results = splice.search(
320
- query=query,
321
- max_results=max_results,
322
- key=key,
323
- bpm_min=bpm_min,
324
- bpm_max=bpm_max,
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
- search_result = ableton.send_command("search_browser", {
339
- "path": category,
340
- "name_filter": query,
341
- "loadable_only": True,
342
- "max_results": max_results,
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
- fs_results = fs.search(query, max_results=max_results)
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
- if not os.path.exists(dest_path):
1055
- shutil.copy2(local_path, dest_path)
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
- if before.energy_arc and after.energy_arc:
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, "track_index", None) == track_index
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
- Splice marks samples as free via `IsPremium == False` or `Price == 0`.
189
- This is orthogonal to plan: even a free-tier user can license these.
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 (not self.is_premium) or self.price == 0
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, still return the first one the shift
107
- # primes the patch for a future Level bump. Better than no branch.
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 Position",
52
- "Osc 2 Position",
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 Position", 0.0) or 0.0)
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 Position",
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
- # get_clip_file_path is an @mcp.tool, but FastMCP decorators preserve
384
- # the underlying function we can call it directly for composition.
385
- path_resp = await _get_path.fn(ctx, track_index, clip_index)
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.fn(ctx)
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": section_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": int(trimmed.shape[1]),
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.fn(ctx, t_idx, clip_idx)
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
- try:
146
- for entry in sorted(root.iterdir()):
147
- plugin = _identify_plugin(entry, fmt)
148
- if plugin:
149
- key = ((plugin.vendor or "").lower(), plugin.name.lower(), plugin.format)
150
- if key not in seen_keys:
151
- seen_keys.add(key)
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
- if any(p.match(g) for g in excludes):
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