livepilot 1.16.1 → 1.17.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 (52) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/README.md +16 -15
  3. package/installer/codex.js +14 -0
  4. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  5. package/m4l_device/livepilot_bridge.js +1 -1
  6. package/mcp_server/__init__.py +1 -1
  7. package/mcp_server/atlas/__init__.py +85 -0
  8. package/mcp_server/atlas/device_atlas.json +3183 -382
  9. package/mcp_server/atlas/device_techniques_index.json +1510 -0
  10. package/mcp_server/atlas/enrichments/__init__.py +1 -0
  11. package/mcp_server/atlas/enrichments/audio_effects/amp.yaml +112 -0
  12. package/mcp_server/atlas/enrichments/audio_effects/audio_effect_rack.yaml +77 -0
  13. package/mcp_server/atlas/enrichments/audio_effects/cabinet.yaml +81 -0
  14. package/mcp_server/atlas/enrichments/audio_effects/corpus.yaml +128 -0
  15. package/mcp_server/atlas/enrichments/audio_effects/envelope_follower.yaml +99 -0
  16. package/mcp_server/atlas/enrichments/audio_effects/external_audio_effect.yaml +64 -0
  17. package/mcp_server/atlas/enrichments/audio_effects/looper.yaml +85 -0
  18. package/mcp_server/atlas/enrichments/audio_effects/resonators.yaml +121 -0
  19. package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +17 -0
  20. package/mcp_server/atlas/enrichments/audio_effects/spectrum.yaml +61 -0
  21. package/mcp_server/atlas/enrichments/audio_effects/tuner.yaml +43 -0
  22. package/mcp_server/atlas/enrichments/audio_effects/utility.yaml +118 -0
  23. package/mcp_server/atlas/enrichments/audio_effects/vocoder.yaml +94 -0
  24. package/mcp_server/atlas/enrichments/instruments/analog.yaml +11 -0
  25. package/mcp_server/atlas/enrichments/instruments/bass.yaml +11 -0
  26. package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +38 -0
  27. package/mcp_server/atlas/enrichments/instruments/collision.yaml +11 -0
  28. package/mcp_server/atlas/enrichments/instruments/drift.yaml +11 -0
  29. package/mcp_server/atlas/enrichments/instruments/drum_rack.yaml +142 -0
  30. package/mcp_server/atlas/enrichments/instruments/electric.yaml +11 -0
  31. package/mcp_server/atlas/enrichments/instruments/emit.yaml +11 -0
  32. package/mcp_server/atlas/enrichments/instruments/meld.yaml +11 -0
  33. package/mcp_server/atlas/enrichments/instruments/operator.yaml +11 -0
  34. package/mcp_server/atlas/enrichments/instruments/poli.yaml +11 -0
  35. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +12 -0
  36. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +15 -0
  37. package/mcp_server/atlas/enrichments/instruments/tension.yaml +11 -0
  38. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +11 -0
  39. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +11 -0
  40. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +11 -0
  41. package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +17 -0
  42. package/mcp_server/atlas/enrichments/utility/performer.yaml +15 -0
  43. package/mcp_server/atlas/enrichments/utility/vector_map.yaml +21 -0
  44. package/mcp_server/atlas/tools.py +291 -0
  45. package/mcp_server/m4l_bridge.py +19 -2
  46. package/mcp_server/sample_engine/tools.py +201 -128
  47. package/mcp_server/splice_client/http_bridge.py +319 -116
  48. package/mcp_server/tools/automation.py +168 -0
  49. package/package.json +2 -2
  50. package/remote_script/LivePilot/__init__.py +1 -1
  51. package/remote_script/LivePilot/arrangement.py +216 -1
  52. package/server.json +3 -3
@@ -149,6 +149,297 @@ def atlas_compare(ctx: Context, device_a: str, device_b: str, role: str = "") ->
149
149
  return atlas.compare(device_a, device_b, role=role)
150
150
 
151
151
 
152
+ @mcp.tool()
153
+ def atlas_describe_chain(
154
+ ctx: Context,
155
+ description: str,
156
+ genre: str = "",
157
+ limit_per_role: int = 3,
158
+ ) -> dict:
159
+ """Free-text describe-a-chain: "a granular pad that sounds like Tim Hecker"
160
+ → device chain proposal.
161
+
162
+ The mirror of `splice_describe_sound` for the device library. Where
163
+ `atlas_chain_suggest(role, genre)` takes structured inputs, this takes
164
+ a free-form sentence and proposes a chain by:
165
+
166
+ 1. Parsing role hints from the description ("bass", "pad", "lead",
167
+ "percussion", "drum", "texture", "vocal", "keys")
168
+ 2. Parsing aesthetic hints (artist names → `artist-vocabularies.md`,
169
+ genre names → `genre-vocabularies.md`, character words → atlas tags)
170
+ 3. Searching the atlas with those terms
171
+ 4. Proposing the top devices per role with brief rationale
172
+
173
+ This does NOT autoload anything — it returns a proposal the caller can
174
+ review, adjust, then execute with `load_browser_item` + a chain of FX.
175
+
176
+ description: free text. Examples:
177
+ "a granular pad that sounds like Tim Hecker"
178
+ "warm analog bass for minimal techno, deep and dubby"
179
+ "chopped vocal melody, Akufen-style microhouse"
180
+ "brittle mallet percussion with long reverb, Stars of the Lid territory"
181
+ genre: optional genre bias if the description is genre-agnostic
182
+ limit_per_role: max devices to suggest per detected role (default 3)
183
+
184
+ Returns {description, detected_roles, detected_aesthetic,
185
+ per_role_suggestions: [...], chain_proposal: [...]}.
186
+ """
187
+ atlas = _get_atlas()
188
+ if atlas is None:
189
+ return {"error": "Atlas not loaded. Run scan_full_library first."}
190
+ if not description or not description.strip():
191
+ return {"error": "description is required"}
192
+
193
+ desc_lower = description.lower().strip()
194
+
195
+ # ── Detect roles ──────────────────────────────────────────────
196
+ ROLE_KEYWORDS = {
197
+ "bass": ["bass", "sub", "808", "low end", "bottom"],
198
+ "lead": ["lead", "melody", "topline", "hook"],
199
+ "pad": ["pad", "texture", "atmosphere", "atmos", "drone", "ambient"],
200
+ "keys": ["keys", "piano", "rhodes", "wurli", "wurly", "chord"],
201
+ "percussion": ["percussion", "perc", "shaker", "conga", "claves", "tambourine"],
202
+ "drums": ["drums", "drum kit", "kick", "snare", "hat", "hi-hat", "hihat", "break"],
203
+ "vocal": ["vocal", "vox", "voice", "chop", "chant"],
204
+ "fx": ["fx", "riser", "downlifter", "sweep", "whoosh", "impact"],
205
+ }
206
+ detected_roles = []
207
+ for role, keywords in ROLE_KEYWORDS.items():
208
+ if any(k in desc_lower for k in keywords):
209
+ detected_roles.append(role)
210
+ if not detected_roles:
211
+ detected_roles = ["pad"] # sensible default
212
+
213
+ # ── Detect aesthetic / artist cues ────────────────────────────
214
+ ARTIST_TO_TAGS = {
215
+ "villalobos": ["minimal_techno", "deep_minimal"],
216
+ "hawtin": ["minimal_techno", "deep_minimal"],
217
+ "plastikman": ["minimal_techno"],
218
+ "basic channel": ["dub_techno", "dub"],
219
+ "rhythm and sound": ["dub_techno", "dub"],
220
+ "voigt": ["ambient", "dub_techno"],
221
+ "gas": ["ambient"],
222
+ "basinski": ["ambient", "drone"],
223
+ "stars of the lid": ["ambient", "drone", "modern_classical"],
224
+ "hecker": ["ambient", "drone", "experimental"],
225
+ "aphex": ["idm", "experimental"],
226
+ "autechre": ["idm", "experimental"],
227
+ "dilla": ["hip_hop", "lo_fi"],
228
+ "burial": ["dubstep", "uk_garage", "ambient"],
229
+ "akufen": ["microhouse"],
230
+ "isolee": ["microhouse", "deep_house"],
231
+ "henke": ["minimal_techno", "experimental"],
232
+ "monolake": ["minimal_techno", "experimental"],
233
+ "tycho": ["synthwave", "electronica"],
234
+ "boards of canada": ["downtempo", "lo_fi"],
235
+ }
236
+ CHARACTER_TAGS = [
237
+ "warm", "cold", "bright", "dark", "lush", "thin", "fat", "metallic",
238
+ "granular", "glitch", "gritty", "clean", "wet", "dry", "resonant",
239
+ "breathy", "analog", "digital", "vintage", "modern", "organic", "synthetic",
240
+ ]
241
+ GENRE_KEYWORDS = [
242
+ "microhouse", "minimal", "techno", "house", "deep house", "ambient",
243
+ "drone", "idm", "experimental", "dubstep", "dnb", "drum and bass",
244
+ "hip hop", "hip-hop", "lo-fi", "lo fi", "lofi", "trap", "garage",
245
+ "dub techno", "dub", "jazz", "classical", "cinematic", "synthwave",
246
+ "vaporwave", "ambient techno", "deep minimal",
247
+ ]
248
+ detected_aesthetic = []
249
+ for artist, tags in ARTIST_TO_TAGS.items():
250
+ if artist in desc_lower:
251
+ detected_aesthetic.extend(tags)
252
+ for tag in CHARACTER_TAGS:
253
+ if f" {tag}" in f" {desc_lower}":
254
+ detected_aesthetic.append(tag)
255
+ for g in GENRE_KEYWORDS:
256
+ if g in desc_lower:
257
+ detected_aesthetic.append(g.replace(" ", "_").replace("-", "_"))
258
+ if genre:
259
+ detected_aesthetic.append(genre.lower())
260
+ # Dedupe preserving order
261
+ seen = set()
262
+ detected_aesthetic = [
263
+ t for t in detected_aesthetic
264
+ if not (t in seen or seen.add(t))
265
+ ]
266
+
267
+ # ── Build per-role suggestions via atlas.suggest ─────────────
268
+ per_role_suggestions = []
269
+ for role in detected_roles:
270
+ # Build an intent string that combines role + aesthetic cues
271
+ intent_parts = [role]
272
+ intent_parts.extend(detected_aesthetic[:3]) # top 3 aesthetic tags
273
+ intent = " ".join(intent_parts)
274
+ results = atlas.suggest(
275
+ intent=intent,
276
+ genre=(detected_aesthetic[0] if detected_aesthetic else genre),
277
+ energy="medium",
278
+ limit=int(limit_per_role),
279
+ )
280
+ per_role_suggestions.append({
281
+ "role": role,
282
+ "intent_used": intent,
283
+ "suggestions": [
284
+ {
285
+ "device_id": r["device"].get("id", ""),
286
+ "device_name": r["device"].get("name", ""),
287
+ "uri": r["device"].get("uri", ""),
288
+ "rationale": r.get("rationale", ""),
289
+ "recipe": r.get("recipe"),
290
+ }
291
+ for r in results
292
+ ],
293
+ })
294
+
295
+ # ── Propose a simple chain from the highest-ranked suggestions ─
296
+ chain_proposal = []
297
+ position = 0
298
+ for role_block in per_role_suggestions:
299
+ if not role_block["suggestions"]:
300
+ continue
301
+ top = role_block["suggestions"][0]
302
+ chain_proposal.append({
303
+ "position": position,
304
+ "role": role_block["role"],
305
+ "device_name": top["device_name"],
306
+ "device_id": top["device_id"],
307
+ "uri": top["uri"],
308
+ "why": top["rationale"],
309
+ })
310
+ position += 1
311
+
312
+ # ── Cross-reference aesthetic to the vocabulary files ──────────
313
+ next_steps = []
314
+ if any("villalobos" in desc_lower or a in detected_aesthetic for a in
315
+ ("microhouse", "deep_minimal", "minimal_techno", "dub_techno",
316
+ "ambient", "drone", "idm", "experimental")):
317
+ next_steps.append(
318
+ "Cross-reference "
319
+ "`livepilot/skills/livepilot-core/references/artist-vocabularies.md` "
320
+ "and `genre-vocabularies.md` for deeper aesthetic guidance."
321
+ )
322
+ if not detected_aesthetic:
323
+ next_steps.append(
324
+ "No aesthetic or genre cues detected. If the description "
325
+ "should have matched, add it to the ARTIST_TO_TAGS map or "
326
+ "provide genre= explicitly."
327
+ )
328
+ next_steps.append(
329
+ "Call `atlas_techniques_for_device(device_id)` on any proposal "
330
+ "to see what techniques reference it."
331
+ )
332
+
333
+ return {
334
+ "description": description,
335
+ "detected_roles": detected_roles,
336
+ "detected_aesthetic": detected_aesthetic,
337
+ "per_role_suggestions": per_role_suggestions,
338
+ "chain_proposal": chain_proposal,
339
+ "next_steps": next_steps,
340
+ }
341
+
342
+
343
+ @mcp.tool()
344
+ def atlas_techniques_for_device(ctx: Context, device_id: str) -> dict:
345
+ """Reverse-lookup: what techniques / principles reference this device?
346
+
347
+ Answers questions like "what can I do with Granulator III?" by returning
348
+ every technique across the knowledge base that mentions this device —
349
+ the device's own `signature_techniques`, sample-manipulation principles
350
+ that use it, sound-design-deep.md references. Complements
351
+ `atlas_device_info` (which returns the device's own curated fields) by
352
+ showing the device's OUTWARD connections — how it fits into techniques
353
+ that weren't written from the device's perspective.
354
+
355
+ device_id: atlas ID (e.g. "granulator_iii", "simpler", "analog"). Use
356
+ `atlas_search` or `atlas_device_info` to discover IDs.
357
+
358
+ Returns {device_id, technique_count, techniques: [...]}, where each
359
+ technique entry has:
360
+ - technique: short name (e.g. "Vocal micro-chop (Akufen)")
361
+ - description: one-line
362
+ - aesthetic: list of aesthetic/genre tags
363
+ - source: where this technique lives (`atlas/<id>`,
364
+ `sample-techniques.md`, `sound-design-deep.md`)
365
+ - kind: signature_technique | sample_technique | sound_design_principle
366
+
367
+ Index is auto-generated from the knowledge base; regenerate via the
368
+ companion script when adding new techniques (rare — most additions
369
+ happen through enrichment YAMLs, which the index reads directly).
370
+ """
371
+ import json, os
372
+ index_path = os.path.join(
373
+ os.path.dirname(os.path.abspath(__file__)),
374
+ "device_techniques_index.json",
375
+ )
376
+ if not os.path.isfile(index_path):
377
+ return {
378
+ "error": "device_techniques_index.json not found",
379
+ "hint": "regenerate via the post-v1.17 reverse-index builder script",
380
+ }
381
+ try:
382
+ with open(index_path, "r") as f:
383
+ data = json.load(f)
384
+ except (OSError, json.JSONDecodeError) as exc:
385
+ return {"error": f"Failed to load index: {exc}"}
386
+
387
+ if not device_id:
388
+ # Return a summary of indexed devices
389
+ devices = data.get("devices", {})
390
+ return {
391
+ "indexed_device_count": len(devices),
392
+ "total_cross_references": data.get("entry_count", 0),
393
+ "devices": sorted(devices.keys()),
394
+ "hint": "Pass a device_id for per-device techniques",
395
+ }
396
+
397
+ entries = data.get("devices", {}).get(device_id)
398
+ if entries is None:
399
+ return {
400
+ "device_id": device_id,
401
+ "technique_count": 0,
402
+ "techniques": [],
403
+ "hint": (
404
+ "No techniques indexed for this device. Try a different ID "
405
+ "or use `atlas_search` to find the correct one. Devices "
406
+ "with no cross-references either haven't been enriched yet "
407
+ "or aren't referenced in any technique doc."
408
+ ),
409
+ }
410
+
411
+ return {
412
+ "device_id": device_id,
413
+ "technique_count": len(entries),
414
+ "techniques": entries,
415
+ }
416
+
417
+
418
+ @mcp.tool()
419
+ def atlas_pack_info(ctx: Context, pack_name: str = "") -> dict:
420
+ """Inspect a single Ableton pack — device list + enrichment coverage.
421
+
422
+ pack_name: the pack name (e.g., "Drone Lab", "Core Library",
423
+ "Creative Extensions", "Inspired by Nature"). Case-insensitive.
424
+ Pass an empty string to get the full list of packs known to
425
+ the atlas with device counts.
426
+
427
+ Returns {pack, device_count, enriched_count, devices[...]} for a
428
+ specific pack, or {packs: [...]} when called with no name.
429
+
430
+ Use this to answer questions like "what's in Drone Lab?" or "how
431
+ much of Creative Extensions do we have aesthetic knowledge about?"
432
+ """
433
+ atlas = _get_atlas()
434
+ if atlas is None:
435
+ return {"error": "Atlas not loaded. Run scan_full_library first."}
436
+
437
+ if not pack_name:
438
+ return {"packs": atlas.list_packs()}
439
+
440
+ return atlas.pack_info(pack_name)
441
+
442
+
152
443
  @mcp.tool()
153
444
  def scan_full_library(
154
445
  ctx: Context,
@@ -479,7 +479,16 @@ class SpectralReceiver(asyncio.DatagramProtocol):
479
479
  /response_chunk i i s — chunked response (index, total, data)
480
480
  """
481
481
 
482
- BAND_NAMES = ["sub", "low", "low_mid", "mid", "high_mid", "high", "presence", "air"]
482
+ # Band names keyed by how many bands the .amxd emits. 8 bands is the v1.x
483
+ # layout (sub starts at 20 Hz, ~octave per band). 9 bands is v1.16.x+
484
+ # with an explicit sub_low (20-60 Hz) split off so Villalobos-style kicks
485
+ # at 40-50 Hz are no longer hidden inside the sub band. The .amxd is the
486
+ # source of truth for band count — this server picks the right names
487
+ # based on how many floats actually arrive on /spectrum.
488
+ BAND_NAMES_8 = ["sub", "low", "low_mid", "mid", "high_mid", "high", "presence", "air"]
489
+ BAND_NAMES_9 = ["sub_low", "sub", "low", "low_mid", "mid", "high_mid", "high", "presence", "air"]
490
+ # Default alias kept for any external reader.
491
+ BAND_NAMES = BAND_NAMES_9
483
492
 
484
493
  def __init__(self, cache: SpectralCache, miditool_cache: Optional["MidiToolCache"] = None):
485
494
  self.cache = cache
@@ -571,8 +580,16 @@ class SpectralReceiver(asyncio.DatagramProtocol):
571
580
 
572
581
  def _handle_message(self, address: str, args: list) -> None:
573
582
  if address == "/spectrum" and len(args) >= 8:
583
+ # Pick the right name set based on how many bands the .amxd emits.
584
+ # 9-band payloads come from v1.16.x+ devices with the sub_low split.
585
+ # 8-band payloads come from older frozen .amxd builds — we keep
586
+ # working against them until every user has re-frozen.
587
+ if len(args) >= 9:
588
+ names = self.BAND_NAMES_9
589
+ else:
590
+ names = self.BAND_NAMES_8
574
591
  bands = {}
575
- for i, name in enumerate(self.BAND_NAMES):
592
+ for i, name in enumerate(names):
576
593
  if i < len(args):
577
594
  bands[name] = round(float(args[i]), 4)
578
595
  self.cache.update("spectrum", bands)