livepilot 1.27.1 → 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.
- package/CHANGELOG.md +86 -0
- package/README.md +10 -7
- package/bin/livepilot.js +156 -37
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +48 -11
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/fast/brief_builder.py +6 -2
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +16 -9
- package/mcp_server/connection.py +69 -10
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +217 -62
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +57 -1
- package/mcp_server/runtime/mcp_dispatch.py +22 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +27 -25
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +21 -8
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/tracks.py +11 -5
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- package/server.json +2 -2
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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,
|
|
91
|
-
Examples: "warm analog bass", "
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
"
|
|
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
|
|
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
|
|
557
|
-
|
|
558
|
-
the device's own
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
570
|
-
- description: one-line
|
|
680
|
+
- technique: short name, description: one-line
|
|
571
681
|
- aesthetic: list of aesthetic/genre tags
|
|
572
|
-
- source:
|
|
573
|
-
`
|
|
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 =
|
|
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
|
|
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
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
|
715
|
-
|
|
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
|
-
|
|
718
|
-
if isinstance(
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
|
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": ("
|
|
59
|
-
"snare": ("
|
|
60
|
-
"hat": ("
|
|
61
|
-
"perc": ("
|
|
62
|
-
"bass": ("
|
|
63
|
-
"pad": ("
|
|
64
|
-
"lead": ("
|
|
65
|
-
"atmos": ("
|
|
66
|
-
"vox": ("
|
|
67
|
-
"fx": ("
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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("
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
}
|
|
@@ -70,7 +70,11 @@ def count_wavetable_routings(ableton, track_index: int, devices: list[dict]) ->
|
|
|
70
70
|
"""Sum non-zero mod-matrix routings across any Wavetable on the track."""
|
|
71
71
|
total = 0
|
|
72
72
|
for i, dev in enumerate(devices or []):
|
|
73
|
-
|
|
73
|
+
# Accept both "Wavetable" (most Live versions) and "InstrumentVector"
|
|
74
|
+
# (how Ableton reports the Wavetable instrument in some builds).
|
|
75
|
+
# Param-level disambiguation (Osc 1 Pos) is enforced inside the remote
|
|
76
|
+
# script _get_wavetable guard; the audit dict only carries class_name.
|
|
77
|
+
if dev.get("class_name") not in ("Wavetable", "InstrumentVector"):
|
|
74
78
|
continue
|
|
75
79
|
mod = safe_call(ableton, "get_wavetable_mod_matrix", {
|
|
76
80
|
"track_index": track_index,
|
|
@@ -78,7 +82,11 @@ def count_wavetable_routings(ableton, track_index: int, devices: list[dict]) ->
|
|
|
78
82
|
})
|
|
79
83
|
if not mod:
|
|
80
84
|
continue
|
|
81
|
-
|
|
85
|
+
# The remote get_wavetable_mod_matrix handler returns {"routings": [...]}
|
|
86
|
+
# (remote_script/LivePilot/devices.py). Read that real key FIRST; keep
|
|
87
|
+
# the matrix/entries aliases only as defensive fallbacks. Reading the
|
|
88
|
+
# wrong key here left this metric silently 0 against every real device.
|
|
89
|
+
entries = mod.get("routings") or mod.get("matrix") or mod.get("entries") or []
|
|
82
90
|
for e in entries:
|
|
83
91
|
try:
|
|
84
92
|
if abs(float(e.get("amount", 0.0))) > 0.001:
|
|
@@ -40,19 +40,40 @@ def _get_ableton(ctx: Context):
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def _maybe_get_timbre_fingerprint(ctx: Context, track_index: int) -> dict | None:
|
|
43
|
-
"""
|
|
43
|
+
"""Build a timbre fingerprint from the M4L analyzer's cached spectral data.
|
|
44
44
|
|
|
45
|
-
Returns None when the M4L bridge is offline or no
|
|
46
|
-
Does NOT solo the track —
|
|
45
|
+
Returns None when the M4L bridge is offline or no spectrum is cached.
|
|
46
|
+
Does NOT solo the track — the bands come from the MASTER bus, so with
|
|
47
|
+
other tracks playing the read is approximate. The returned dict carries
|
|
48
|
+
source="master_bus_unsoloed" so downstream consumers can label it.
|
|
49
|
+
|
|
50
|
+
extract_timbre_fingerprint is a pure function over already-fetched
|
|
51
|
+
spectrum/loudness/spectral_shape dicts — the data plumbing lives here.
|
|
47
52
|
"""
|
|
48
53
|
try:
|
|
49
54
|
from ..synthesis_brain.timbre import extract_timbre_fingerprint # type: ignore
|
|
50
55
|
except Exception:
|
|
51
56
|
return None
|
|
52
57
|
try:
|
|
53
|
-
|
|
54
|
-
if
|
|
55
|
-
return
|
|
58
|
+
cache = ctx.lifespan_context.get("spectral")
|
|
59
|
+
if cache is None:
|
|
60
|
+
return None
|
|
61
|
+
snap = cache.get("spectrum")
|
|
62
|
+
bands = (snap or {}).get("value") if isinstance(snap, dict) else None
|
|
63
|
+
if not bands:
|
|
64
|
+
return None
|
|
65
|
+
loud = cache.get("loudness")
|
|
66
|
+
shape = cache.get("spectral_shape")
|
|
67
|
+
fp = extract_timbre_fingerprint(
|
|
68
|
+
spectrum={"bands": bands},
|
|
69
|
+
loudness=(loud or {}).get("value") if isinstance(loud, dict) else None,
|
|
70
|
+
spectral_shape=(shape or {}).get("value") if isinstance(shape, dict) else None,
|
|
71
|
+
)
|
|
72
|
+
return {
|
|
73
|
+
"bands": dict(bands),
|
|
74
|
+
"dimensions": fp.to_dict(),
|
|
75
|
+
"source": "master_bus_unsoloed",
|
|
76
|
+
}
|
|
56
77
|
except Exception as exc:
|
|
57
78
|
logger.debug("audit_layer timbre fetch failed: %s", exc)
|
|
58
79
|
return None
|