livepilot 1.27.2 → 1.27.3

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 (113) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +97 -39
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +1 -1
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  28. package/mcp_server/composer/full/apply.py +40 -34
  29. package/mcp_server/composer/full/engine.py +66 -32
  30. package/mcp_server/composer/tools.py +4 -3
  31. package/mcp_server/connection.py +67 -9
  32. package/mcp_server/creative_constraints/engine.py +10 -2
  33. package/mcp_server/creative_constraints/tools.py +24 -7
  34. package/mcp_server/curves.py +30 -7
  35. package/mcp_server/device_forge/builder.py +40 -11
  36. package/mcp_server/device_forge/models.py +9 -0
  37. package/mcp_server/device_forge/tools.py +30 -11
  38. package/mcp_server/experiment/engine.py +29 -22
  39. package/mcp_server/experiment/tools.py +15 -5
  40. package/mcp_server/m4l_bridge.py +69 -7
  41. package/mcp_server/memory/taste_graph.py +43 -0
  42. package/mcp_server/memory/technique_store.py +26 -3
  43. package/mcp_server/memory/tools.py +62 -4
  44. package/mcp_server/mix_engine/critics.py +187 -30
  45. package/mcp_server/mix_engine/models.py +21 -1
  46. package/mcp_server/mix_engine/state_builder.py +87 -8
  47. package/mcp_server/mix_engine/tools.py +16 -4
  48. package/mcp_server/performance_engine/tools.py +56 -8
  49. package/mcp_server/persistence/base_store.py +11 -0
  50. package/mcp_server/persistence/project_store.py +132 -8
  51. package/mcp_server/persistence/taste_store.py +90 -7
  52. package/mcp_server/preview_studio/engine.py +40 -10
  53. package/mcp_server/preview_studio/models.py +40 -0
  54. package/mcp_server/preview_studio/tools.py +56 -12
  55. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  56. package/mcp_server/project_brain/models.py +2 -0
  57. package/mcp_server/project_brain/role_graph.py +13 -7
  58. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  59. package/mcp_server/reference_engine/profile_builder.py +47 -4
  60. package/mcp_server/reference_engine/tools.py +6 -0
  61. package/mcp_server/runtime/execution_router.py +51 -1
  62. package/mcp_server/runtime/tools.py +0 -1
  63. package/mcp_server/sample_engine/sources.py +0 -2
  64. package/mcp_server/sample_engine/tools.py +19 -38
  65. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  66. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  67. package/mcp_server/semantic_moves/resolvers.py +45 -0
  68. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  69. package/mcp_server/semantic_moves/tools.py +80 -2
  70. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  71. package/mcp_server/server.py +26 -24
  72. package/mcp_server/session_continuity/tracker.py +51 -3
  73. package/mcp_server/song_brain/builder.py +47 -5
  74. package/mcp_server/song_brain/tools.py +21 -7
  75. package/mcp_server/sound_design/critics.py +1 -0
  76. package/mcp_server/splice_client/client.py +117 -33
  77. package/mcp_server/splice_client/http_bridge.py +15 -3
  78. package/mcp_server/splice_client/quota.py +28 -0
  79. package/mcp_server/stuckness_detector/detector.py +8 -5
  80. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  81. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  82. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  83. package/mcp_server/tools/_perception_engine.py +6 -0
  84. package/mcp_server/tools/agent_os.py +4 -1
  85. package/mcp_server/tools/analyzer.py +198 -209
  86. package/mcp_server/tools/arrangement.py +7 -4
  87. package/mcp_server/tools/automation.py +24 -4
  88. package/mcp_server/tools/browser.py +25 -11
  89. package/mcp_server/tools/clips.py +6 -0
  90. package/mcp_server/tools/composition.py +33 -2
  91. package/mcp_server/tools/devices.py +53 -53
  92. package/mcp_server/tools/generative.py +14 -14
  93. package/mcp_server/tools/harmony.py +7 -7
  94. package/mcp_server/tools/mixing.py +4 -4
  95. package/mcp_server/tools/planner.py +68 -6
  96. package/mcp_server/tools/research.py +20 -2
  97. package/mcp_server/tools/theory.py +10 -10
  98. package/mcp_server/tools/transport.py +7 -2
  99. package/mcp_server/transition_engine/critics.py +13 -1
  100. package/mcp_server/user_corpus/tools.py +30 -1
  101. package/mcp_server/wonder_mode/engine.py +82 -9
  102. package/mcp_server/wonder_mode/session.py +32 -10
  103. package/mcp_server/wonder_mode/tools.py +14 -1
  104. package/package.json +1 -1
  105. package/remote_script/LivePilot/__init__.py +1 -1
  106. package/remote_script/LivePilot/arrangement.py +93 -33
  107. package/remote_script/LivePilot/browser.py +60 -4
  108. package/remote_script/LivePilot/devices.py +132 -62
  109. package/remote_script/LivePilot/mixing.py +31 -5
  110. package/remote_script/LivePilot/server.py +94 -22
  111. package/remote_script/LivePilot/transport.py +11 -0
  112. package/requirements.txt +5 -5
  113. package/server.json +2 -2
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import logging
14
14
  import re
15
+ import threading
15
16
  from dataclasses import dataclass, field
16
17
  from pathlib import Path
17
18
  from typing import Optional
@@ -20,6 +21,15 @@ import yaml
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
24
+ # Prefer the libyaml-backed CSafeLoader (~8.5x faster than the pure-Python
25
+ # SafeLoader) when the yaml package was built with libyaml support. Fall
26
+ # back to SafeLoader if CSafeLoader isn't available (e.g. libyaml missing
27
+ # from the environment) so overlay loading never hard-fails on this.
28
+ try:
29
+ _YAML_LOADER = yaml.CSafeLoader
30
+ except AttributeError:
31
+ _YAML_LOADER = yaml.SafeLoader
32
+
23
33
 
24
34
  # ─── Tokenizer (used by OverlayIndex.search) ─────────────────────────────────
25
35
  # Producer-style queries use natural language with hyphenated phrases
@@ -318,7 +328,14 @@ def load_overlays(root: Optional[Path] = None,
318
328
  if root is None:
319
329
  root = _resolve_overlay_root()
320
330
 
321
- idx = get_overlay_index()
331
+ # Mark loaded up front (not just on success) so a call to load_overlays()
332
+ # — with either the default or an explicit root — always short-circuits
333
+ # the lazy _ensure_loaded() path on subsequent get_overlay_index() calls,
334
+ # including the "root doesn't exist" early-return below.
335
+ global _overlay_loaded
336
+ _overlay_loaded = True
337
+
338
+ idx = _overlay_index
322
339
  idx.clear()
323
340
 
324
341
  if not root.exists():
@@ -337,7 +354,7 @@ def load_overlays(root: Optional[Path] = None,
337
354
  continue
338
355
  try:
339
356
  with yaml_path.open("r") as f:
340
- parsed = yaml.safe_load(f)
357
+ parsed = yaml.load(f, Loader=_YAML_LOADER)
341
358
  except yaml.YAMLError as e:
342
359
  log.warning(f"overlays: skipped {yaml_path}: {e}")
343
360
  continue
@@ -367,8 +384,34 @@ def load_overlays(root: Optional[Path] = None,
367
384
  # request time so they always see current state (never capture a reference).
368
385
  _overlay_index: "OverlayIndex" = OverlayIndex()
369
386
 
387
+ # Lazy-population state (v1.27.3 perf batch). The singleton used to be
388
+ # populated unconditionally at server import time (server.py boot hook),
389
+ # which cost ~1.1s of a ~2s import on a machine with a populated
390
+ # ~/.livepilot/atlas-overlays tree. Now the first call to
391
+ # get_overlay_index() populates it under a lock; subsequent calls are a
392
+ # cheap flag check. Explicit load_overlays() calls (tests, reload tools)
393
+ # always force a fresh scan and mark the singleton as loaded.
394
+ _overlay_loaded = False
395
+ _overlay_load_lock = threading.Lock()
396
+
397
+
398
+ def _ensure_loaded() -> None:
399
+ """Populate the singleton on first access only. Double-checked locking
400
+ avoids taking the lock on the (overwhelmingly common) already-loaded path."""
401
+ global _overlay_loaded
402
+ if _overlay_loaded:
403
+ return
404
+ with _overlay_load_lock:
405
+ if _overlay_loaded:
406
+ return
407
+ load_overlays()
408
+ _overlay_loaded = True
409
+
370
410
 
371
411
  def get_overlay_index() -> "OverlayIndex":
372
412
  """Accessor for the live overlay singleton. Always returns the same
373
- instance — load_overlays() mutates it in place rather than replacing."""
413
+ instance — load_overlays() mutates it in place rather than replacing.
414
+
415
+ Triggers lazy population on first call (see _ensure_loaded)."""
416
+ _ensure_loaded()
374
417
  return _overlay_index
@@ -43,6 +43,59 @@ def _get_atlas():
43
43
  # atlas_device_info.
44
44
  _ATLAS_SEARCH_DESCRIPTION_CHAR_CAP: int = 400
45
45
 
46
+ # ── M4L pack-instrument URI guard (LIVE#3) ────────────────────────────────────
47
+ # These instruments ship as Max for Live (.amxd) devices inside pack bundles
48
+ # (Inspired by Nature, etc.). The atlas erroneously stores them as
49
+ # `query:Synths#<Name>` — the same scheme used for native Ableton instruments
50
+ # (Operator, Wavetable, Collision, …). That scheme only resolves in Live's
51
+ # browser for *native* instruments; M4L pack instruments are NOT browsable under
52
+ # "Synths". Their presets (.adg racks) ARE browsable under "sounds", so agents
53
+ # must fall back to search_browser(path="sounds", name_filter="<preset-name>").
54
+ #
55
+ # A device ID belongs here when ALL of the following hold:
56
+ # 1. Its atlas URI is `query:Synths#<Name>`
57
+ # 2. The device is an M4L / pack instrument (not a native Ableton synth engine)
58
+ # 3. load_browser_item with the Synths# URI returns INVALID_PARAM in Live
59
+ #
60
+ # Do NOT add native instruments (Operator, Wavetable, Drift, Meld, Poli, etc.)
61
+ # even if they were introduced in a later Live version — those resolve fine.
62
+ # Add only IDs that have been *confirmed* broken in a live session.
63
+ _M4L_PACK_SYNTH_IDS: frozenset[str] = frozenset({
64
+ "tree_tone", # Inspired by Nature — M4L instrument, presets under sounds
65
+ "vector_fm", # Inspired by Nature — M4L instrument, presets under sounds
66
+ "vector_grain", # Inspired by Nature — M4L instrument, presets under sounds
67
+ "emit", # Inspired by Nature — M4L instrument, presets under sounds
68
+ })
69
+
70
+
71
+ def _patch_m4l_uri(entry_dict: dict, device: dict) -> dict:
72
+ """If this atlas entry is a known M4L pack instrument, clear the bogus
73
+ `query:Synths#` URI and surface a load hint instead.
74
+
75
+ Mutates *entry_dict* in-place and returns it for convenience.
76
+
77
+ Fields added / changed:
78
+ uri → "" (cleared — the Synths# URI is NOT resolvable)
79
+ load_via → "preset"
80
+ browse_hint → {"path": "sounds", "name_filter": "<device name>"}
81
+ (tells the agent to call search_browser to resolve a real URI)
82
+ """
83
+ dev_id = device.get("id", "")
84
+ uri = entry_dict.get("uri", "")
85
+ if dev_id in _M4L_PACK_SYNTH_IDS and uri.startswith("query:Synths#"):
86
+ entry_dict["uri"] = ""
87
+ entry_dict["load_via"] = "preset"
88
+ entry_dict["browse_hint"] = {
89
+ "path": "sounds",
90
+ "name_filter": device.get("name", ""),
91
+ "note": (
92
+ "M4L pack instrument — not directly loadable via query:Synths#. "
93
+ "Call search_browser(path='sounds', name_filter='<preset name>') "
94
+ "to obtain a real URI, then load_browser_item with that URI."
95
+ ),
96
+ }
97
+ return entry_dict
98
+
46
99
 
47
100
  def _surface_enriched_fields(device: dict) -> dict:
48
101
  """Pull discoverability-critical fields from an enriched atlas entry.
@@ -80,16 +133,16 @@ def _surface_enriched_fields(device: dict) -> dict:
80
133
  def atlas_search(ctx: Context, query: str, category: str = "all", limit: int = 10) -> dict:
81
134
  """Search the device atlas for instruments, effects, kits, or plugins.
82
135
 
83
- Searches BOTH:
84
- 1. The bundled factory atlas (5,264 devices across 33 packs)
85
- 2. The user-local overlay corpus (~/.livepilot/atlas-overlays/) — including
86
- user-scanned Max devices, racks, plugin presets, and AI-synthesized
87
- plugin identity yamls. This is the wiring that lets LivePilot reason
88
- over the user's PERSONAL library, not just Ableton's defaults.
136
+ Searches BOTH the bundled factory atlas (5,264 devices/33 packs) AND
137
+ the user-local overlay corpus (~/.livepilot/atlas-overlays/ user-
138
+ scanned Max devices, racks, plugin presets, AI-synthesized plugin
139
+ identity yamls), so results cover the user's PERSONAL library too,
140
+ not just Ableton's defaults. Budget-split details: livepilot-core
141
+ references/atlas-tool-notes.md#atlas_search--overlay-budget-split.
89
142
 
90
- query: natural language search — name, sonic character, use case, or genre
91
- Examples: "warm analog bass", "reverb", "808 kit", "granular",
92
- "my arpeggiator", "the polyrhythmic sequencer in my user library"
143
+ query: natural language search — name, sonic character, use case,
144
+ or genre. Examples: "warm analog bass", "granular",
145
+ "my arpeggiator in user library".
93
146
  category: filter by category (all, instruments, audio_effects, midi_effects,
94
147
  max_for_live, drum_kits, plugins). For user-corpus content, pass
95
148
  "all" — overlay entity_types are surfaced regardless of category.
@@ -155,6 +208,10 @@ def atlas_search(ctx: Context, query: str, category: str = "all", limit: int = 1
155
208
  # agent doesn't need a second atlas_device_info round-trip just
156
209
  # to find out e.g. that Granulator III isn't self-contained.
157
210
  entry_dict.update(_surface_enriched_fields(dev))
211
+ # LIVE#3: M4L pack instruments have a bogus query:Synths# URI in the
212
+ # atlas — clear it and surface a browse_hint so the caller doesn't get
213
+ # an INVALID_PARAM from load_browser_item.
214
+ _patch_m4l_uri(entry_dict, dev)
158
215
  results.append(entry_dict)
159
216
  for entry in overlay_results[:overlay_budget]:
160
217
  results.append({
@@ -169,7 +226,7 @@ def atlas_search(ctx: Context, query: str, category: str = "all", limit: int = 1
169
226
  "source": f"user_overlay:{entry.namespace}",
170
227
  })
171
228
 
172
- return {
229
+ response: dict = {
173
230
  "query": query,
174
231
  "category": category,
175
232
  "count": len(results),
@@ -177,6 +234,14 @@ def atlas_search(ctx: Context, query: str, category: str = "all", limit: int = 1
177
234
  "overlay_count": len(overlay_results),
178
235
  "results": results,
179
236
  }
237
+ # P3-47: warn when this query's category overlaps a category the last
238
+ # scan_full_library run had to truncate — the factory results above
239
+ # are a lower bound for that category, not the full inventory.
240
+ if atlas is not None:
241
+ warning = atlas.truncation_warning(category)
242
+ if warning:
243
+ response["warning"] = warning
244
+ return response
180
245
 
181
246
 
182
247
  @mcp.tool()
@@ -195,8 +260,39 @@ def atlas_device_info(ctx: Context, device_id: str, verbose: bool = True) -> dic
195
260
  entry = atlas.lookup(device_id)
196
261
  if entry is None:
197
262
  return {"error": f"Device '{device_id}' not found in atlas", "suggestion": "Use atlas_search to find devices"}
263
+
264
+ # P2-12: an id/name can collide across multiple distinct devices (719
265
+ # ids / 702 names in the shipped atlas). lookup() deterministically
266
+ # returns the first; surface the others by their unique URI so the
267
+ # agent can re-query the exact variant it wants instead of getting one
268
+ # arbitrary match silently.
269
+ all_matches = atlas.lookup_all(device_id)
270
+ ambiguous_matches = None
271
+ if len(all_matches) > 1:
272
+ ambiguous_matches = [
273
+ {
274
+ "id": d.get("id", ""),
275
+ "name": d.get("name", ""),
276
+ "uri": d.get("uri", ""),
277
+ "category": d.get("category", ""),
278
+ }
279
+ for d in all_matches
280
+ ]
281
+
198
282
  if verbose:
199
- return entry
283
+ # Patch a COPY — `entry` is the live in-memory atlas record; mutating it
284
+ # would corrupt the shared atlas. _patch_m4l_uri clears the bogus
285
+ # query:Synths# URI for M4L pack instruments (LIVE#3) so a direct
286
+ # atlas_device_info lookup doesn't hand the agent an unloadable URI.
287
+ if ambiguous_matches is not None:
288
+ result = _patch_m4l_uri(dict(entry), entry)
289
+ result["ambiguous_matches"] = ambiguous_matches
290
+ result["ambiguous_note"] = (
291
+ f"'{device_id}' matches {len(all_matches)} devices; showing the "
292
+ "first. Re-query by a unique uri to target a specific one."
293
+ )
294
+ return result
295
+ return _patch_m4l_uri(dict(entry), entry)
200
296
  # Compact mode: the common case doesn't need the full multi-KB record.
201
297
  # Truncate the longest free-text fields the way atlas_search already caps
202
298
  # sonic_description, and report counts for list-heavy fields so the caller
@@ -217,6 +313,14 @@ def atlas_device_info(ctx: Context, device_id: str, verbose: bool = True) -> dic
217
313
  }
218
314
  summary.update(_surface_enriched_fields(entry))
219
315
  summary["verbose_available"] = True
316
+ # LIVE#3: clear bogus query:Synths# URI for M4L pack instruments here too.
317
+ _patch_m4l_uri(summary, entry)
318
+ if ambiguous_matches is not None:
319
+ summary["ambiguous_matches"] = ambiguous_matches
320
+ summary["ambiguous_note"] = (
321
+ f"'{device_id}' matches {len(all_matches)} devices; showing the "
322
+ "first. Re-query by a unique uri to target a specific one."
323
+ )
220
324
  return summary
221
325
 
222
326
 
@@ -240,21 +344,32 @@ def atlas_suggest(
240
344
  return {"error": "Atlas not loaded. Run scan_full_library first."}
241
345
 
242
346
  results = atlas.suggest(intent, genre=genre, energy=energy)
243
- return {
347
+ suggestions = []
348
+ for r in results:
349
+ dev = r["device"]
350
+ suggestion: dict = {
351
+ "device_id": dev["id"],
352
+ "device_name": dev["name"],
353
+ "uri": dev.get("uri", ""),
354
+ "rationale": r["rationale"],
355
+ "recipe": r.get("recipe"),
356
+ }
357
+ # LIVE#3: patch out bogus query:Synths# URIs for M4L pack instruments
358
+ _patch_m4l_uri(suggestion, dev)
359
+ suggestions.append(suggestion)
360
+ response: dict = {
244
361
  "intent": intent,
245
362
  "genre": genre,
246
363
  "energy": energy,
247
- "suggestions": [
248
- {
249
- "device_id": r["device"]["id"],
250
- "device_name": r["device"]["name"],
251
- "uri": r["device"].get("uri", ""),
252
- "rationale": r["rationale"],
253
- "recipe": r.get("recipe"),
254
- }
255
- for r in results
256
- ],
364
+ "suggestions": suggestions,
257
365
  }
366
+ # P3-47: suggest() searches across every category internally, so warn
367
+ # like an unfiltered ("all") search would if the last scan truncated
368
+ # any category — the ranked candidates above may be missing devices.
369
+ warning = atlas.truncation_warning("all")
370
+ if warning:
371
+ response["warning"] = warning
372
+ return response
258
373
 
259
374
 
260
375
  @mcp.tool()
@@ -332,33 +447,25 @@ def atlas_describe_chain(
332
447
  genre: str = "",
333
448
  limit_per_role: int = 3,
334
449
  ) -> dict:
335
- """Free-text describe-a-chain: "a granular pad that sounds like Tim Hecker"
336
- → device chain proposal.
450
+ """Free-text describe-a-chain: a sentence like "a warm analog bass for
451
+ deep dubby techno" → device chain proposal.
337
452
 
338
453
  The mirror of `splice_describe_sound` for the device library. Where
339
- `atlas_chain_suggest(role, genre)` takes structured inputs, this takes
340
- a free-form sentence and proposes a chain by:
341
-
342
- 1. Parsing role hints from the description ("bass", "pad", "lead",
343
- "percussion", "drum", "texture", "vocal", "keys")
344
- 2. Parsing aesthetic hints (artist names `artist-vocabularies.md`,
345
- genre names `genre-vocabularies.md`, character words → atlas tags)
346
- 3. Searching the atlas with those terms
347
- 4. Proposing the top devices per role with brief rationale
348
-
349
- This does NOT autoload anything — it returns a proposal the caller can
350
- review, adjust, then execute with `load_browser_item` + a chain of FX.
351
-
352
- description: free text. Examples:
353
- "a granular pad that sounds like Tim Hecker"
354
- "warm analog bass for minimal techno, deep and dubby"
355
- "chopped vocal melody, Akufen-style microhouse"
356
- "brittle mallet percussion with long reverb, Stars of the Lid territory"
454
+ `atlas_chain_suggest(role, genre)` takes structured inputs, this
455
+ detects role + aesthetic cues from free text, searches the atlas,
456
+ and proposes top devices per role. Internals: livepilot-core
457
+ references/atlas-tool-notes.md#atlas_describe_chain--internals.
458
+
459
+ This does NOT autoload anything it returns a proposal the caller
460
+ reviews/adjusts, then executes with `load_browser_item` + FX.
461
+
462
+ description: free text mixing role + aesthetic cues, e.g. "a granular
463
+ pad, dark and dubby" or "chopped vocal melody, microhouse".
357
464
  genre: optional genre bias if the description is genre-agnostic
358
465
  limit_per_role: max devices to suggest per detected role (default 3)
359
466
 
360
467
  Returns {description, detected_roles, detected_aesthetic,
361
- per_role_suggestions: [...], chain_proposal: [...]}.
468
+ per_role_suggestions: [...], chain_proposal: [...], next_steps}.
362
469
  """
363
470
  atlas = _get_atlas()
364
471
  if atlas is None:
@@ -461,17 +568,23 @@ def atlas_describe_chain(
461
568
  energy="medium",
462
569
  limit=int(limit_per_role),
463
570
  )
464
- factory_suggestions = [
465
- {
466
- "device_id": r["device"].get("id", ""),
467
- "device_name": r["device"].get("name", ""),
468
- "uri": r["device"].get("uri", ""),
571
+ factory_suggestions = []
572
+ for r in results:
573
+ dev = r["device"]
574
+ entry = {
575
+ "device_id": dev.get("id", ""),
576
+ "device_name": dev.get("name", ""),
577
+ "uri": dev.get("uri", ""),
469
578
  "rationale": r.get("rationale", ""),
470
579
  "recipe": r.get("recipe"),
471
580
  "source": "factory_atlas",
472
581
  }
473
- for r in results
474
- ]
582
+ # LIVE#3: clear the bogus query:Synths# URI for M4L pack instruments
583
+ # here too (same atlas.suggest backend atlas_suggest sanitizes). The
584
+ # chain_proposal block below reads top["uri"] from these entries, so
585
+ # patching here propagates automatically.
586
+ _patch_m4l_uri(entry, dev)
587
+ factory_suggestions.append(entry)
475
588
 
476
589
  # Query overlay namespaces for matching user-corpus devices
477
590
  overlay_hits = []
@@ -553,29 +666,22 @@ def atlas_describe_chain(
553
666
  def atlas_techniques_for_device(ctx: Context, device_id: str) -> dict:
554
667
  """Reverse-lookup: what techniques / principles reference this device?
555
668
 
556
- Answers questions like "what can I do with Granulator III?" by returning
557
- every technique across the knowledge base that mentions this device —
558
- the device's own `signature_techniques`, sample-manipulation principles
559
- that use it, sound-design-deep.md references. Complements
560
- `atlas_device_info` (which returns the device's own curated fields) by
561
- showing the device's OUTWARD connections — how it fits into techniques
562
- that weren't written from the device's perspective.
669
+ Answers "what can I do with this device?" by returning every
670
+ technique across the knowledge base that mentions it. Complements
671
+ `atlas_device_info` (the device's own curated fields) by showing its
672
+ OUTWARD connections. Index details: livepilot-core references/
673
+ atlas-tool-notes.md#atlas_techniques_for_device--index.
563
674
 
564
675
  device_id: atlas ID (e.g. "granulator_iii", "simpler", "analog"). Use
565
676
  `atlas_search` or `atlas_device_info` to discover IDs.
566
677
 
567
678
  Returns {device_id, technique_count, techniques: [...]}, where each
568
679
  technique entry has:
569
- - technique: short name (e.g. "Vocal micro-chop (Akufen)")
570
- - description: one-line
680
+ - technique: short name, description: one-line
571
681
  - aesthetic: list of aesthetic/genre tags
572
- - source: where this technique lives (`atlas/<id>`,
573
- `sample-techniques.md`, `sound-design-deep.md`)
682
+ - source: originating doc (`atlas/<id>`, `sample-techniques.md`,
683
+ `sound-design-deep.md`)
574
684
  - kind: signature_technique | sample_technique | sound_design_principle
575
-
576
- Index is auto-generated from the knowledge base; regenerate via the
577
- companion script when adding new techniques (rare — most additions
578
- happen through enrichment YAMLs, which the index reads directly).
579
685
  """
580
686
  import json, os
581
687
  index_path = os.path.join(
@@ -653,27 +759,31 @@ def atlas_pack_info(ctx: Context, pack_name: str = "") -> dict:
653
759
  def scan_full_library(
654
760
  ctx: Context,
655
761
  force: bool = False,
656
- max_per_category: int = 5000,
762
+ max_per_category: int = 25000,
657
763
  ) -> dict:
658
764
  """Scan the full Ableton browser and rebuild the device atlas.
659
765
 
660
766
  Walks every category (instruments, audio_effects, midi_effects, max_for_live,
661
767
  drums, plugins, packs) and records every loadable item with its URI.
662
- Results are merged with curated enrichments and saved to device_atlas.json.
768
+ Results are merged with curated enrichments and saved to the user
769
+ atlas path (~/.livepilot/atlas/device_atlas.json — never the bundled
770
+ baseline).
663
771
 
664
- force: if True, rescan even if atlas already exists (default False)
665
- max_per_category: ceiling per category (default 5000). The previous
666
- hardcoded 1000 cap silently truncated large categories — for
667
- example, the samples category alone has ~22,000 items per the
668
- browser tree, so the reported count "1000 samples" was wrong by
669
- a factor of 22 (BUG-2026-04-22 #12). Raise this if your library
670
- is huge; lower it for fast smoke scans.
772
+ force: if True, rescan even if a recent (<24h) atlas already exists
773
+ (default False)
774
+ max_per_category: ceiling per category (default 25000). Raise this
775
+ further for very large libraries; lower it for fast smoke scans.
776
+ Cap-sizing history: livepilot-core references/
777
+ atlas-tool-notes.md#scan_full_library--scan-cap-history.
671
778
 
672
779
  Returns a stats dict including `truncated_categories` listing any
673
780
  category that hit the cap (so callers know the count is a lower
674
- bound rather than the true total).
781
+ bound rather than the true total), also folded into
782
+ `stats.category_truncated` and persisted into device_atlas.json so
783
+ AtlasManager can warn future atlas_search/atlas_suggest calls that
784
+ touch a truncated category without requiring a fresh scan first.
675
785
  """
676
- from .scanner import normalize_scan_results
786
+ from .scanner import normalize_scan_results, _CATEGORY_MAP
677
787
  from .enrichments import load_enrichments, merge_enrichments
678
788
  from . import AtlasManager, USER_ATLAS_DIR, USER_ATLAS_PATH
679
789
 
@@ -711,17 +821,34 @@ def scan_full_library(
711
821
  # Normalize
712
822
  devices = normalize_scan_results(raw)
713
823
 
714
- # Detect truncation: per-category count == cap means we likely hit it.
715
- truncated_categories = []
824
+ # Detect truncation (P3-47). Prefer the explicit `category_truncated`
825
+ # map emitted by scan_browser_deep on updated remote scripts — it
826
+ # correctly flags a category as truncated both when it hit
827
+ # max_per_category directly AND when the shared iteration safety bound
828
+ # cut the scan short mid-category. Older (pre-update) remote scripts
829
+ # only echo a `counts`/`stats` mapping of ints, so fall back to the
830
+ # count>=cap heuristic in that case.
831
+ truncated_categories: list = []
832
+ raw_category_truncated: dict = {}
716
833
  if isinstance(raw, dict):
717
- per_cat = raw.get("counts") or raw.get("stats") or {}
718
- if isinstance(per_cat, dict):
719
- for cat, count in per_cat.items():
720
- try:
721
- if int(count) >= max_per_category:
722
- truncated_categories.append(cat)
723
- except (TypeError, ValueError):
724
- continue
834
+ explicit_truncated = raw.get("category_truncated")
835
+ if isinstance(explicit_truncated, dict):
836
+ raw_category_truncated = {
837
+ cat: bool(hit) for cat, hit in explicit_truncated.items()
838
+ }
839
+ truncated_categories = [
840
+ cat for cat, hit in raw_category_truncated.items() if hit
841
+ ]
842
+ else:
843
+ per_cat = raw.get("counts") or raw.get("stats") or {}
844
+ if isinstance(per_cat, dict):
845
+ for cat, count in per_cat.items():
846
+ try:
847
+ if int(count) >= max_per_category:
848
+ truncated_categories.append(cat)
849
+ raw_category_truncated[cat] = True
850
+ except (TypeError, ValueError):
851
+ continue
725
852
 
726
853
  # Load and merge enrichments
727
854
  enrichments = load_enrichments(enrichments_dir)
@@ -734,6 +861,19 @@ def scan_full_library(
734
861
  stats[cat] = stats.get(cat, 0) + 1
735
862
  stats["enriched_devices"] = sum(1 for d in devices if d.get("enriched"))
736
863
 
864
+ # Truncation observability persisted into device_atlas.json so
865
+ # AtlasManager can warn callers later without needing the raw scan
866
+ # payload. Raw browser category names (e.g. "drums", "sounds") are
867
+ # remapped through the scanner's category vocabulary (e.g.
868
+ # "drum_kits") so the flags line up with the same `category` values
869
+ # atlas_search / atlas_suggest actually filter on.
870
+ stats["category_counts"] = dict(raw.get("counts", {})) if isinstance(raw, dict) else {}
871
+ stats["category_truncated"] = {
872
+ _CATEGORY_MAP.get(raw_cat, raw_cat): True
873
+ for raw_cat, hit in raw_category_truncated.items()
874
+ if hit
875
+ }
876
+
737
877
  # Read the actual running Live version from the session rather than
738
878
  # hardcoding "12.3.6" — the hardcoded string was baking last year's
739
879
  # version into every new user's atlas until they forced a rescan.
@@ -53,18 +53,21 @@ def infer_role(track_name: str, devices: list[dict]) -> str:
53
53
  # ── §5.1 Timbre via spectrum ─────────────────────────────────────────
54
54
 
55
55
  # Loose role → expected spectrum-band dominance.
56
- # Uses 7-band convention: SUB_LOW, LOW, LOW_MID, MID, PRESENCE, HIGH, AIR.
56
+ # Uses the canonical lowercase 9-band vocabulary every spectrum producer
57
+ # emits (m4l_bridge BAND_NAMES_9 / synthesis_brain.timbre._BANDS):
58
+ # sub_low, sub, low, low_mid, mid, high_mid, high, presence, air.
59
+ # Comparison in check_timbre is case-folded defensively.
57
60
  _ROLE_BAND_EXPECTATIONS: dict[str, tuple[str, ...]] = {
58
- "kick": ("SUB_LOW", "LOW", "MID"),
59
- "snare": ("MID", "PRESENCE", "HIGH"),
60
- "hat": ("PRESENCE", "HIGH", "AIR"),
61
- "perc": ("MID", "PRESENCE", "HIGH"),
62
- "bass": ("SUB_LOW", "LOW", "LOW_MID"),
63
- "pad": ("LOW_MID", "MID", "PRESENCE"),
64
- "lead": ("MID", "PRESENCE", "HIGH"),
65
- "atmos": ("LOW_MID", "MID", "PRESENCE", "HIGH"),
66
- "vox": ("MID", "PRESENCE", "HIGH"),
67
- "fx": ("PRESENCE", "HIGH", "AIR"),
61
+ "kick": ("sub_low", "sub", "low", "mid"),
62
+ "snare": ("mid", "high_mid", "presence", "high"),
63
+ "hat": ("presence", "high", "air"),
64
+ "perc": ("mid", "high_mid", "presence", "high"),
65
+ "bass": ("sub_low", "sub", "low", "low_mid"),
66
+ "pad": ("low_mid", "mid", "high_mid", "presence"),
67
+ "lead": ("mid", "high_mid", "presence", "high"),
68
+ "atmos": ("low_mid", "mid", "high_mid", "presence", "high"),
69
+ "vox": ("mid", "high_mid", "presence", "high"),
70
+ "fx": ("presence", "high", "air"),
68
71
  }
69
72
 
70
73
 
@@ -93,10 +96,11 @@ def check_timbre(role: str, fingerprint: dict | None) -> dict:
93
96
  "issues": [],
94
97
  "evidence": {"bands": bands},
95
98
  }
96
- # Find dominant band(s) — top1 is the discriminator
97
- sorted_bands = sorted(bands.items(), key=lambda kv: kv[1], reverse=True)
98
- top2 = [b for b, _ in sorted_bands[:2]]
99
- expected_set = set(expected)
99
+ # Find dominant band(s) — top1 is the discriminator. Case-fold so an
100
+ # uppercase-emitting producer can never silently fail every comparison.
101
+ sorted_bands = sorted(bands.items(), key=lambda kv: float(kv[1] or 0.0), reverse=True)
102
+ top2 = [str(b).lower() for b, _ in sorted_bands[:2]]
103
+ expected_set = {e.lower() for e in expected}
100
104
  if top2[0] in expected_set:
101
105
  return {
102
106
  "severity": "pass",
@@ -264,16 +268,31 @@ def check_masking(track_index: int, masking_report: dict | None) -> dict:
264
268
  "issues": [],
265
269
  "evidence": {"collision_count": 0},
266
270
  }
271
+ # MaskingEntry.severity is a float 0.0-1.0 (base 0.7 kick/bass-sub down to
272
+ # 0.3), and the band lives under the "overlap_band" key (MaskingEntry.to_dict
273
+ # via asdict). Treat >= 0.65 as a FAIL-grade collision.
274
+ _FAIL_THRESHOLD = 0.65
275
+
276
+ def _sev(c: dict) -> float:
277
+ try:
278
+ return float(c.get("severity", 0.0))
279
+ except (TypeError, ValueError):
280
+ return 0.0
281
+
267
282
  issues = []
268
283
  for c in my_collisions:
269
- sev = c.get("severity", "warn")
284
+ sev = _sev(c)
270
285
  other = c.get("track_b") if c.get("track_a") == track_index else c.get("track_a")
271
- band = c.get("band") or c.get("frequency_band") or "?"
286
+ band = c.get("overlap_band") or "?"
272
287
  issues.append({
273
288
  "code": "masking_collision",
274
- "detail": f"Frequency clash with track {other} in band {band} (severity={sev})",
289
+ "detail": f"Frequency clash with track {other} in band {band} (severity={sev:.2f})",
275
290
  })
276
- severity = "fail" if any(c.get("severity") == "high" for c in my_collisions) else "warn"
291
+ severity = (
292
+ "fail"
293
+ if any(_sev(c) >= _FAIL_THRESHOLD for c in my_collisions)
294
+ else "warn"
295
+ )
277
296
  return {
278
297
  "severity": severity,
279
298
  "summary": f"{len(my_collisions)} masking collision(s) involving this track",
@@ -649,10 +668,19 @@ def check_samples(role: str, devices: list[dict], slice_classifications: list[di
649
668
  # Read Volume param if present.
650
669
  params = simpler.get("parameters", []) or []
651
670
  vol = next((p for p in params if (p.get("name") or "").lower() == "volume"), None)
652
- if vol and float(vol.get("value", 0.0)) < -10.0 and role in ("pad", "lead", "bass", "vox"):
671
+ # `value` is normally a float, but some LOM param shapes serialize it as a
672
+ # formatted string (e.g. "-12.0 dB"); float() / :.1f would then raise and
673
+ # take down the whole audit_layer call. Convert defensively.
674
+ vol_db = None
675
+ if vol is not None:
676
+ try:
677
+ vol_db = float(vol.get("value", 0.0))
678
+ except (TypeError, ValueError):
679
+ vol_db = None
680
+ if vol_db is not None and vol_db < -10.0 and role in ("pad", "lead", "bass", "vox"):
653
681
  issues.append({
654
682
  "code": "simpler_default_volume",
655
- "detail": f"Simpler Volume at {vol.get('value'):.1f} dB (default -12). Set to 0 for sustained roles.",
683
+ "detail": f"Simpler Volume at {vol_db:.1f} dB (default -12). Set to 0 for sustained roles.",
656
684
  })
657
685
  if slice_classifications:
658
686
  unclassified = sum(1 for s in slice_classifications if s.get("classification") in (None, "unknown"))
@@ -667,7 +695,7 @@ def check_samples(role: str, devices: list[dict], slice_classifications: list[di
667
695
  "summary": f"Simpler/Sampler present; {len(issues)} issue(s)",
668
696
  "issues": issues,
669
697
  "evidence": {
670
- "has_volume_default": bool(vol and float(vol.get("value", 0.0)) < -10.0),
698
+ "has_volume_default": bool(vol_db is not None and vol_db < -10.0),
671
699
  "slice_count": len(slice_classifications) if slice_classifications else 0,
672
700
  },
673
701
  }