livepilot 1.23.6 → 1.24.0

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 (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +59 -13
  3. package/mcp_server/__init__.py +1 -1
  4. package/mcp_server/atlas/__init__.py +17 -3
  5. package/mcp_server/audit/__init__.py +6 -0
  6. package/mcp_server/audit/checks.py +618 -0
  7. package/mcp_server/audit/tools.py +232 -0
  8. package/mcp_server/composer/branch_producer.py +5 -2
  9. package/mcp_server/composer/develop/__init__.py +19 -0
  10. package/mcp_server/composer/develop/apply.py +217 -0
  11. package/mcp_server/composer/develop/brief_builder.py +269 -0
  12. package/mcp_server/composer/develop/seed_introspector.py +195 -0
  13. package/mcp_server/composer/engine.py +15 -521
  14. package/mcp_server/composer/fast/__init__.py +62 -0
  15. package/mcp_server/composer/fast/apply.py +533 -0
  16. package/mcp_server/composer/fast/brief_builder.py +1479 -0
  17. package/mcp_server/composer/fast/tier_classification.py +159 -0
  18. package/mcp_server/composer/framework/__init__.py +0 -0
  19. package/mcp_server/composer/framework/applier.py +179 -0
  20. package/mcp_server/composer/framework/artist_loader.py +63 -0
  21. package/mcp_server/composer/framework/brief.py +79 -0
  22. package/mcp_server/composer/framework/event_lexicon.py +71 -0
  23. package/mcp_server/composer/framework/genre_loader.py +77 -0
  24. package/mcp_server/composer/framework/intent_source.py +137 -0
  25. package/mcp_server/composer/framework/knowledge_pack.py +49 -0
  26. package/mcp_server/composer/framework/plan_compiler.py +10 -0
  27. package/mcp_server/composer/full/__init__.py +10 -0
  28. package/mcp_server/composer/full/apply.py +1139 -0
  29. package/mcp_server/composer/full/brief_builder.py +144 -0
  30. package/mcp_server/composer/full/engine.py +541 -0
  31. package/mcp_server/composer/full/layer_planner.py +491 -0
  32. package/mcp_server/composer/layer_planner.py +19 -465
  33. package/mcp_server/composer/sample_resolver.py +80 -7
  34. package/mcp_server/composer/tools.py +626 -28
  35. package/mcp_server/server.py +1 -0
  36. package/mcp_server/splice_client/client.py +7 -0
  37. package/mcp_server/tools/_analyzer_engine/sample.py +162 -6
  38. package/mcp_server/tools/_planner_engine.py +25 -63
  39. package/mcp_server/tools/analyzer.py +10 -4
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/server.json +3 -3
@@ -0,0 +1,491 @@
1
+ """Layer planner — convert CompositionIntent into LayerSpec list.
2
+
3
+ Pure computation. Determines which layers to create, what to search for,
4
+ which techniques to use, and how to arrange sections. No I/O.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional
12
+
13
+ from ..prompt_parser import CompositionIntent
14
+
15
+
16
+ # ── Data Model ─────────────────────────────────────────────────────
17
+
18
+ @dataclass
19
+ class LayerSpec:
20
+ """Specification for a single layer in a composition."""
21
+
22
+ role: str # "drums", "bass", "lead", "pad", "texture", "vocal", "percussion", "fx"
23
+ search_query: str # Splice search query
24
+ splice_filters: dict = field(default_factory=dict) # key, bpm_range, genre, tags, sample_type
25
+ technique_id: str = "" # from the 29-technique library
26
+ processing: list[dict] = field(default_factory=list) # devices to add + param targets
27
+ volume_db: float = 0.0 # mix level
28
+ pan: float = 0.0 # -1.0 to 1.0
29
+ sections: list[str] = field(default_factory=list) # which arrangement sections
30
+ priority: int = 5 # download order (1=first, 10=last)
31
+
32
+ def to_dict(self) -> dict:
33
+ return {
34
+ "role": self.role,
35
+ "search_query": self.search_query,
36
+ "splice_filters": self.splice_filters,
37
+ "technique_id": self.technique_id,
38
+ "processing": self.processing,
39
+ "volume_db": self.volume_db,
40
+ "pan": self.pan,
41
+ "sections": self.sections,
42
+ "priority": self.priority,
43
+ }
44
+
45
+
46
+ # ── Role Templates ─────────────────────────────────────────────────
47
+ # role → default config used to build LayerSpec
48
+ #
49
+ # 2026-05-01 unit-fix pass (six bugs surfaced by live full-mode test):
50
+ # - EQ Eight `1 Filter Type A` is an int 0-7 enum (1 = "High Pass 12dB"),
51
+ # NOT the string "highpass".
52
+ # - EQ Eight `1 Frequency A` is 0-1 normalized log-scale, NOT Hz direct.
53
+ # 30 Hz ≈ 0.143, 200 Hz ≈ 0.30 (verified via live `value_string` probe).
54
+ # - Compressor (Compressor2 — modern default) `Threshold` and `Ratio` are
55
+ # 0-1 normalized. Threshold 0.85 ≈ 0 dB, 0.70 ≈ -12 dB. Ratio 0.75 = 4:1.
56
+ # - Saturator `Drive` is 0-1 normalized. Drive 0.6 ≈ +7 dB.
57
+ # - Chorus-Ensemble's rate parameter is `Rate`, NOT `Rate 1`.
58
+ # - Grain Delay's wet-mix parameter is `DryWet` (no slash); only Reverb
59
+ # uses `Dry/Wet`.
60
+ # - Auto Filter `Frequency` is also 0-1 normalized on AutoFilter2; left
61
+ # params={} where this conflict arose (matches fast.py convention).
62
+ #
63
+ # Reference: fast.py's GENRE_CREATIVE_GUIDANCE.effect_chain_hints (lines
64
+ # 436-456) already uses the correct normalized convention. This table is
65
+ # the older planner; bringing it in line.
66
+
67
+ _ROLE_TEMPLATES: dict[str, dict] = {
68
+ "drums": {
69
+ "query_template": "{genre} drums {tempo}bpm",
70
+ "sample_type": "loop",
71
+ "technique_id": "slice_and_sequence",
72
+ "processing": [
73
+ {"name": "EQ Eight", "params": {"1 Filter Type A": 1, "1 Frequency A": 0.143}},
74
+ {"name": "Compressor", "params": {"Threshold": 0.70, "Ratio": 0.75}},
75
+ ],
76
+ "volume_db": -3.0,
77
+ "pan": 0.0,
78
+ "priority": 1,
79
+ },
80
+ "bass": {
81
+ "query_template": "{genre} bass {key} oneshot",
82
+ "sample_type": "oneshot",
83
+ "technique_id": "key_matched_layer",
84
+ "processing": [
85
+ {"name": "Saturator", "params": {"Drive": 0.6}},
86
+ {"name": "EQ Eight", "params": {"1 Filter Type A": 1, "1 Frequency A": 0.143}},
87
+ ],
88
+ "volume_db": -5.0,
89
+ "pan": 0.0,
90
+ "priority": 2,
91
+ },
92
+ "lead": {
93
+ "query_template": "{genre} {mood} melody {key}",
94
+ "sample_type": "loop",
95
+ "technique_id": "counterpoint_from_chops",
96
+ "processing": [
97
+ # AutoFilter2 Frequency is 0-1 normalized log; leave defaults
98
+ # (matches fast.py convention — see fast.py line 449).
99
+ {"name": "Auto Filter", "params": {}},
100
+ {"name": "Delay", "params": {"Feedback": 0.35}},
101
+ ],
102
+ "volume_db": -6.0,
103
+ "pan": 0.0,
104
+ "priority": 4,
105
+ },
106
+ "pad": {
107
+ "query_template": "{mood} pad {key}",
108
+ "sample_type": "loop",
109
+ "technique_id": "extreme_stretch",
110
+ "processing": [
111
+ # Reverb Decay Time is 0-1 normalized log; 0.55 ≈ 4.6s (live-verified).
112
+ {"name": "Reverb", "params": {"Decay Time": 0.55, "Dry/Wet": 0.6}},
113
+ {"name": "Chorus-Ensemble", "params": {"Rate": 0.5}},
114
+ ],
115
+ "volume_db": -10.0,
116
+ "pan": 0.0,
117
+ "priority": 5,
118
+ },
119
+ "texture": {
120
+ "query_template": "{mood} texture ambient",
121
+ "sample_type": "loop",
122
+ "technique_id": "granular_scatter",
123
+ "processing": [
124
+ # Grain Delay's wet param is named "DryWet" (no slash), distinct
125
+ # from Reverb's "Dry/Wet". Frequency is 0-1 normalized.
126
+ {"name": "Grain Delay", "params": {"Frequency": 0.5, "DryWet": 0.5}},
127
+ {"name": "Reverb", "params": {"Decay Time": 0.62, "Dry/Wet": 0.7}},
128
+ ],
129
+ "volume_db": -15.0,
130
+ "pan": 0.0,
131
+ "priority": 6,
132
+ },
133
+ "vocal": {
134
+ "query_template": "vocal {mood} {key}",
135
+ "sample_type": "loop",
136
+ "technique_id": "vocal_chop_rhythm",
137
+ "processing": [
138
+ {"name": "Auto Filter", "params": {}},
139
+ {"name": "Reverb", "params": {"Decay Time": 0.45, "Dry/Wet": 0.4}},
140
+ ],
141
+ "volume_db": -8.0,
142
+ "pan": 0.0,
143
+ "priority": 7,
144
+ },
145
+ "percussion": {
146
+ "query_template": "{genre} percussion loop",
147
+ "sample_type": "loop",
148
+ "technique_id": "ghost_note_texture",
149
+ "processing": [
150
+ {"name": "EQ Eight", "params": {"1 Filter Type A": 1, "1 Frequency A": 0.30}},
151
+ {"name": "Compressor", "params": {"Threshold": 0.66, "Ratio": 0.65}},
152
+ ],
153
+ "volume_db": -12.0,
154
+ "pan": 0.0,
155
+ "priority": 3,
156
+ },
157
+ "fx": {
158
+ "query_template": "{genre} riser fx",
159
+ "sample_type": "oneshot",
160
+ "technique_id": "one_sample_challenge",
161
+ "processing": [],
162
+ "volume_db": -6.0,
163
+ "pan": 0.0,
164
+ "priority": 8,
165
+ },
166
+ }
167
+
168
+
169
+ # ── Role Selection per Genre + Energy ──────────────────────────────
170
+ # Define which roles appear at different energy levels per genre.
171
+
172
+ _GENRE_ROLE_PRIORITY: dict[str, list[str]] = {
173
+ # Roles listed in order of priority (first added, last dropped)
174
+ "techno": ["drums", "bass", "percussion", "lead", "texture", "vocal", "fx"],
175
+ "house": ["drums", "bass", "lead", "pad", "vocal", "texture"],
176
+ "hip hop": ["drums", "bass", "lead", "vocal", "texture", "fx"],
177
+ "ambient": ["pad", "texture", "vocal", "lead", "percussion"],
178
+ "drum and bass": ["drums", "bass", "lead", "percussion", "texture", "vocal", "fx"],
179
+ "trap": ["drums", "bass", "lead", "vocal", "fx", "texture"],
180
+ "lo-fi": ["drums", "bass", "pad", "texture", "vocal"],
181
+ }
182
+
183
+ _DEFAULT_ROLE_PRIORITY = ["drums", "bass", "lead", "pad", "texture", "vocal", "percussion", "fx"]
184
+
185
+
186
+ # v1.24: SECTION_TEMPLATES and _DEFAULT_SECTION_TEMPLATE removed per
187
+ # vocabulary-not-form principle (Task 12). The framework provides VOCABULARY
188
+ # (descriptive). The LLM provides FORM (creative). Genre section sequences
189
+ # with bar counts belong in the LLM's training data + WebSearch fallback.
190
+
191
+
192
+ # ── Planner Functions ──────────────────────────────────────────────
193
+
194
+ def _build_search_query(template: str, intent: CompositionIntent) -> str:
195
+ """Fill a query template with intent fields."""
196
+ return template.format(
197
+ genre=intent.genre or "electronic",
198
+ mood=intent.mood or "",
199
+ key=intent.key or "",
200
+ tempo=intent.tempo or 120,
201
+ ).strip()
202
+
203
+
204
+ # BUG-FULL-MODE-9 (2026-05-01) — per-role instrument category for Splice
205
+ # server-side filtering. Splice's gRPC `SearchSampleRequest.Instrument`
206
+ # field supports values like "bass", "drum", "synth", "piano", "vocal",
207
+ # "guitar", "pad", "fx". Without this, a query of "electro bass Am
208
+ # oneshot" lexically matches `Piano_OneShot_PianoPhrase_Am.wav` because
209
+ # the filename contains "OneShot" + "Am" — the bass slot got a piano.
210
+ # Setting `instrument` makes Splice filter at the catalog level.
211
+ #
212
+ # When omitted (e.g. drums where any drum sub-category is fine, or
213
+ # texture where we want creative latitude), Splice falls back to its
214
+ # normal text+tag scoring.
215
+ _ROLE_INSTRUMENT: dict[str, str] = {
216
+ "bass": "bass",
217
+ "lead": "synth",
218
+ "pad": "synth",
219
+ "vocal": "vocal",
220
+ "percussion": "drum",
221
+ # drums: omit — drum loops aren't classified as a single Instrument
222
+ # texture: omit — too freeform; we want any-instrument FX/textures
223
+ # fx: omit — same reasoning
224
+ }
225
+
226
+ # BUG-FULL-MODE-13 (2026-05-01) — non-tonal roles must NOT receive
227
+ # `key` or `chord_type` filters. Splice's drum/percussion/fx samples
228
+ # don't carry pitch metadata, so applying `chord_type=minor, key=a`
229
+ # narrows the catalog to ZERO matches, returning unresolved. Excluding
230
+ # these roles from the key filter lets the BPM + sample_type filters
231
+ # still narrow appropriately while drum samples can actually match.
232
+ _NON_TONAL_ROLES: frozenset[str] = frozenset({"drums", "percussion", "fx"})
233
+
234
+
235
+ def _build_splice_filters(
236
+ intent: CompositionIntent,
237
+ sample_type: str,
238
+ role: str = "",
239
+ ) -> dict:
240
+ """Build Splice filter dict from intent + role.
241
+
242
+ `role` (added 2026-05-01 BUG-FULL-MODE-9): when set, an `instrument`
243
+ field is added per `_ROLE_INSTRUMENT` so Splice filters at the
244
+ server side instead of relying on text matching.
245
+
246
+ `role` also gates the key/chord_type filters (BUG-FULL-MODE-13):
247
+ drums/percussion/fx don't carry pitch metadata in Splice's catalog,
248
+ so applying `chord_type=minor, key=a` to those roles narrows the
249
+ catalog to ZERO matches → unresolved. Tonal roles (bass/lead/pad/
250
+ vocal/texture) keep the full filter set.
251
+ """
252
+ filters: dict = {}
253
+ role_lower = (role or "").lower()
254
+ is_tonal = role_lower not in _NON_TONAL_ROLES
255
+
256
+ # Key → Splice format (lowercase root, separate chord_type) — tonal roles only.
257
+ if intent.key and is_tonal:
258
+ key = intent.key
259
+ if key.endswith("m") and len(key) >= 2:
260
+ root = key[:-1].lower()
261
+ filters["chord_type"] = "minor"
262
+ else:
263
+ root = key.lower()
264
+ filters["chord_type"] = "major"
265
+ filters["key"] = root
266
+
267
+ # BPM range (+-5) — applies to every role
268
+ if intent.tempo:
269
+ filters["bpm_min"] = max(1, intent.tempo - 5)
270
+ filters["bpm_max"] = intent.tempo + 5
271
+
272
+ if intent.genre:
273
+ filters["genre"] = intent.genre
274
+
275
+ if sample_type:
276
+ filters["sample_type"] = sample_type
277
+
278
+ # Instrument category — server-side filter at Splice
279
+ instrument = _ROLE_INSTRUMENT.get(role_lower)
280
+ if instrument:
281
+ filters["instrument"] = instrument
282
+
283
+ return filters
284
+
285
+
286
+ def _select_roles(intent: CompositionIntent) -> list[str]:
287
+ """Select which roles to include based on genre, energy, and explicit elements."""
288
+ role_priority = _GENRE_ROLE_PRIORITY.get(intent.genre, _DEFAULT_ROLE_PRIORITY)
289
+
290
+ # How many layers to pick
291
+ count = intent.layer_count or 5
292
+
293
+ # Start with the top N roles by priority
294
+ roles = list(role_priority[:count])
295
+
296
+ # Add any explicitly requested elements as roles
297
+ element_to_role = {
298
+ "vocal": "vocal",
299
+ "808": "bass",
300
+ "bass": "bass",
301
+ "drums": "drums",
302
+ "percussion": "percussion",
303
+ "pad": "pad",
304
+ "texture": "texture",
305
+ "fx": "fx",
306
+ "strings": "pad", # strings map to pad role
307
+ "piano": "lead", # piano maps to lead role
308
+ "guitar": "lead",
309
+ "brass": "lead",
310
+ "synth": "lead",
311
+ }
312
+
313
+ for element in intent.explicit_elements:
314
+ role = element_to_role.get(element)
315
+ if role and role not in roles:
316
+ roles.append(role)
317
+
318
+ return roles
319
+
320
+
321
+ def plan_layers(intent: CompositionIntent) -> list[LayerSpec]:
322
+ """Convert a CompositionIntent into a list of LayerSpec.
323
+
324
+ Each LayerSpec describes one track to create: what to search for,
325
+ which technique to use, processing chain, and mix settings.
326
+
327
+ # DEPRECATED in v1.24 — full mode is now LLM-creative. Tool may stay
328
+ # functional but should not be relied on for v1.24+ flows.
329
+ # v1.24: SECTION_TEMPLATES removed per vocabulary-not-form principle (Task 12).
330
+ # This function will raise until callers are updated in Task 14.
331
+ """
332
+ roles = _select_roles(intent)
333
+ sections = plan_sections(intent) # raises in v1.24 — Task 14 will rewire
334
+ section_names = [s["name"] for s in sections]
335
+
336
+ layers: list[LayerSpec] = []
337
+
338
+ for role in roles:
339
+ template = _ROLE_TEMPLATES.get(role)
340
+ if not template:
341
+ continue
342
+
343
+ # Build search query
344
+ query = _build_search_query(template["query_template"], intent)
345
+
346
+ # Add descriptors to query for richer searches
347
+ if intent.descriptors:
348
+ query += " " + " ".join(intent.descriptors[:2])
349
+
350
+ # Build Splice filters (pass `role` so per-role instrument category
351
+ # gets server-side filtering — BUG-FULL-MODE-9).
352
+ splice_filters = _build_splice_filters(intent, template["sample_type"], role=role)
353
+
354
+ # Determine which sections this role appears in
355
+ role_sections: list[str] = []
356
+ for section in sections:
357
+ section_layers = section.get("layers", [])
358
+ for layer_ref in section_layers:
359
+ # Parse "drums:-6dB" → "drums"
360
+ layer_role = layer_ref.split(":")[0]
361
+ if layer_role == role:
362
+ role_sections.append(section["name"])
363
+ break
364
+ # If no section template match, include in all sections
365
+ if not role_sections:
366
+ role_sections = section_names
367
+
368
+ # Pan spread for stereo width
369
+ pan = _compute_pan(role, intent.energy)
370
+
371
+ layer = LayerSpec(
372
+ role=role,
373
+ search_query=query,
374
+ splice_filters=splice_filters,
375
+ technique_id=template["technique_id"],
376
+ processing=list(template["processing"]), # copy
377
+ volume_db=template["volume_db"],
378
+ pan=pan,
379
+ sections=role_sections,
380
+ priority=template["priority"],
381
+ )
382
+
383
+ layers.append(layer)
384
+
385
+ # Sort by priority (drums first, fx last)
386
+ layers.sort(key=lambda l: l.priority)
387
+
388
+ return layers
389
+
390
+
391
+ def plan_sections(
392
+ intent: CompositionIntent,
393
+ section_template: list[dict] | None = None,
394
+ ) -> list[dict]:
395
+ """Plan arrangement sections based on a caller-supplied template and duration.
396
+
397
+ # DEPRECATED in v1.24 — full mode is now LLM-creative. This function may
398
+ # remain callable but must NOT be relied on for v1.24+ flows. Section
399
+ # templates (section sequences with bar counts) are forbidden in the
400
+ # framework — the LLM provides form, not the registry.
401
+ # v1.24: SECTION_TEMPLATES removed per vocabulary-not-form principle (Task 12).
402
+
403
+ Args:
404
+ intent: CompositionIntent with duration_bars.
405
+ section_template: List of dicts {name, bars, layers}. Required in
406
+ v1.24+. The LLM or caller supplies this — no built-in registry.
407
+
408
+ Returns a list of dicts: {name, bars, layers, start_bar}.
409
+ """
410
+ if section_template is None:
411
+ raise ValueError(
412
+ "plan_sections() requires explicit section_template in v1.24+. "
413
+ "SECTION_TEMPLATES was removed — the LLM provides form, not the "
414
+ "framework. Pass a section_template list or use the v1.24 full-mode "
415
+ "compose flow which is LLM-creative."
416
+ )
417
+
418
+ template = section_template
419
+
420
+ # Scale sections to fit duration_bars
421
+ total_template_bars = sum(s["bars"] for s in template)
422
+ if total_template_bars == 0:
423
+ total_template_bars = 64
424
+
425
+ scale = intent.duration_bars / total_template_bars
426
+
427
+ sections: list[dict] = []
428
+ current_bar = 0
429
+
430
+ for section in template:
431
+ scaled_bars = max(4, round(section["bars"] * scale))
432
+ # Round to nearest 4 bars
433
+ scaled_bars = max(4, (scaled_bars // 4) * 4)
434
+
435
+ sections.append({
436
+ "name": section["name"],
437
+ "bars": scaled_bars,
438
+ "layers": list(section["layers"]),
439
+ "start_bar": current_bar,
440
+ })
441
+ current_bar += scaled_bars
442
+
443
+ # Clamp overshoot. Rounding each section up to the nearest 4 bars plus
444
+ # the min-of-4-bars floor means a short duration_bars (e.g. 16) against
445
+ # a 6-section template could produce 24+ bars of sections — a 50%
446
+ # overshoot that pushed arrangement clips into unexpected territory.
447
+ # Trim from the longest non-intro section until we fit.
448
+ total_placed = sum(s["bars"] for s in sections)
449
+ overshoot = total_placed - intent.duration_bars
450
+ if overshoot > 0 and sections:
451
+ # Sort indices by section length desc, skipping the first section
452
+ # (usually intro) which we'd rather preserve at its snapped length.
453
+ trimmable = sorted(
454
+ range(1, len(sections)),
455
+ key=lambda i: -sections[i]["bars"],
456
+ ) or [0]
457
+ i = 0
458
+ while overshoot > 0 and i < len(trimmable) * 4:
459
+ idx = trimmable[i % len(trimmable)]
460
+ if sections[idx]["bars"] > 4:
461
+ sections[idx]["bars"] -= 4
462
+ overshoot -= 4
463
+ i += 1
464
+ # Recompute start_bar values after any trim
465
+ running = 0
466
+ for s in sections:
467
+ s["start_bar"] = running
468
+ running += s["bars"]
469
+
470
+ return sections
471
+
472
+
473
+ def _compute_pan(role: str, energy: float) -> float:
474
+ """Compute pan position for a role.
475
+
476
+ Core elements (drums, bass) stay centered.
477
+ Support elements get wider spread at higher energy.
478
+ """
479
+ _PAN_MAP = {
480
+ "drums": 0.0,
481
+ "bass": 0.0,
482
+ "lead": 0.0,
483
+ "pad": 0.0,
484
+ "vocal": 0.0,
485
+ "percussion": 0.3,
486
+ "texture": -0.3,
487
+ "fx": 0.4,
488
+ }
489
+ base_pan = _PAN_MAP.get(role, 0.0)
490
+ # Widen slightly with energy
491
+ return base_pan * (0.5 + 0.5 * energy)