livepilot 1.23.6 → 1.25.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 (48) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +60 -14
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/__init__.py +17 -3
  7. package/mcp_server/atlas/explore_tools.py +332 -0
  8. package/mcp_server/atlas/tools.py +161 -0
  9. package/mcp_server/audit/__init__.py +6 -0
  10. package/mcp_server/audit/checks.py +618 -0
  11. package/mcp_server/audit/tools.py +232 -0
  12. package/mcp_server/composer/branch_producer.py +5 -2
  13. package/mcp_server/composer/develop/__init__.py +19 -0
  14. package/mcp_server/composer/develop/apply.py +217 -0
  15. package/mcp_server/composer/develop/brief_builder.py +269 -0
  16. package/mcp_server/composer/develop/seed_introspector.py +195 -0
  17. package/mcp_server/composer/engine.py +15 -521
  18. package/mcp_server/composer/fast/__init__.py +62 -0
  19. package/mcp_server/composer/fast/apply.py +533 -0
  20. package/mcp_server/composer/fast/brief_builder.py +1479 -0
  21. package/mcp_server/composer/fast/tier_classification.py +159 -0
  22. package/mcp_server/composer/framework/__init__.py +0 -0
  23. package/mcp_server/composer/framework/applier.py +179 -0
  24. package/mcp_server/composer/framework/artist_loader.py +63 -0
  25. package/mcp_server/composer/framework/atlas_resolver.py +554 -0
  26. package/mcp_server/composer/framework/brief.py +79 -0
  27. package/mcp_server/composer/framework/event_lexicon.py +71 -0
  28. package/mcp_server/composer/framework/genre_loader.py +77 -0
  29. package/mcp_server/composer/framework/intent_source.py +137 -0
  30. package/mcp_server/composer/framework/knowledge_pack.py +140 -0
  31. package/mcp_server/composer/framework/plan_compiler.py +10 -0
  32. package/mcp_server/composer/full/__init__.py +10 -0
  33. package/mcp_server/composer/full/apply.py +1139 -0
  34. package/mcp_server/composer/full/brief_builder.py +227 -0
  35. package/mcp_server/composer/full/engine.py +541 -0
  36. package/mcp_server/composer/full/layer_planner.py +491 -0
  37. package/mcp_server/composer/layer_planner.py +19 -465
  38. package/mcp_server/composer/sample_resolver.py +80 -7
  39. package/mcp_server/composer/tools.py +626 -28
  40. package/mcp_server/server.py +1 -0
  41. package/mcp_server/splice_client/client.py +7 -0
  42. package/mcp_server/tools/_analyzer_engine/sample.py +172 -7
  43. package/mcp_server/tools/_planner_engine.py +25 -63
  44. package/mcp_server/tools/analyzer.py +10 -4
  45. package/mcp_server/tools/browser.py +102 -19
  46. package/package.json +2 -2
  47. package/remote_script/LivePilot/__init__.py +1 -1
  48. package/server.json +3 -3
@@ -0,0 +1,1479 @@
1
+ """LLM-creative fast mode helpers.
2
+
3
+ Architecture (2026-05-01 redesign per user feedback):
4
+ Phase 1 — `compose(mode="fast")` returns a CREATIVE BRIEF: parsed intent,
5
+ atlas-filtered instrument suggestions per role, key/tempo
6
+ context, scale pitches, genre creative guidance, fresh-project
7
+ cleanup state. This phase does NOT generate content.
8
+ Phase 2 — The agent (LLM) reads the brief, picks instruments from
9
+ atlas-filtered suggestions, designs MIDI note patterns inline,
10
+ and submits a complete plan to `compose_fast_apply(plan)`.
11
+ Phase 3 — `compose_fast_apply` bulk-executes server-side: creates tracks,
12
+ loads instruments, populates clips with the LLM's notes, fires
13
+ scene. Same speed as the old template-based execute, but the
14
+ CONTENT is fresh per call.
15
+
16
+ Why this is faster than the old plan-walking compose AND more creative
17
+ than the old template-based fast mode:
18
+ - One LLM round-trip between brief and apply (vs ~16 round-trips
19
+ walking individual tool calls in plan-mode)
20
+ - LLM creativity per call (vs ~hundred bounded combinations in
21
+ template-mode)
22
+
23
+ What this module contains:
24
+ - Atlas viability filters (§1 ban, drum-keyword check, sample-less skip)
25
+ - Atlas tag-based instrument picker
26
+ - Fresh-project detection + cleanup helpers
27
+ - Key parser, scale-degree math (LLM's optional-use building blocks)
28
+ - Genre creative guidance (text hints, not templates)
29
+ - Brief builder
30
+
31
+ What this module deliberately does NOT contain (deleted 2026-05-01):
32
+ - Pattern generators (four_floor, bass_walk, progression_*, etc.)
33
+ - FAST_LAYER_TEMPLATES with fixed layer specs
34
+ - select_template / generate_notes_for_layer / _PATTERN_REGISTRY
35
+
36
+ Patterns now live in the LLM's creative output per call, not in this file.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import logging
42
+ import random
43
+ import re
44
+ from typing import Any, Optional
45
+
46
+ from ..prompt_parser import CompositionIntent
47
+ from .tier_classification import (
48
+ classify_instrument,
49
+ is_drum_specific_synth,
50
+ CONTAINERS_NEEDING_PRESETS,
51
+ MELODIC_AUDIBLE_DEFAULTS,
52
+ DRUM_ROLES,
53
+ TIER_A_CURATED_PRESET,
54
+ TIER_A_DRUM_SAMPLE,
55
+ TIER_B_DRUM_SYNTH,
56
+ TIER_B_AUDIBLE_DEFAULT_VALUE,
57
+ # Backward-compat alias — used by Tier-C guard in get_role_candidates
58
+ TIER_C_NEEDS_PRESET,
59
+ ROLE_SEARCH_TERMS,
60
+ )
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+
65
+ # ── Atlas instrument viability filters ───────────────────────────────
66
+
67
+ _INSTRUMENT_URI_PREFIXES: tuple[str, ...] = (
68
+ "query:Synths#",
69
+ "query:Drums#",
70
+ "query:Instruments#",
71
+ "query:Sounds",
72
+ "query:UserLibrary#",
73
+ )
74
+
75
+ # Bare empty devices that load silent until further configuration.
76
+ # Granular synths and bare racks that load silent until further config.
77
+ # Adding to this set means: the picker won't return them, and the agent
78
+ # never gets URIs that would produce silence.
79
+ _SILENT_WITHOUT_CONFIG: frozenset[str] = frozenset({
80
+ "Granulator III",
81
+ "Vector Grain", # BUG-M (2026-05-01 live test): same class as Granulator III
82
+ "Looper",
83
+ "External Instrument",
84
+ "External Audio Effect",
85
+ "Sampler",
86
+ "Drum Rack",
87
+ "DrumGroup",
88
+ "Instrument Rack",
89
+ })
90
+
91
+ # §1 ban — Analog/Poli/Drift/Meld are forbidden as defaults per CLAUDE.md.
92
+ # The LLM picking creatively from atlas suggestions still won't see these
93
+ # as candidates because the brief filters them out.
94
+ _BANNED_DEFAULT_DEVICES: frozenset[str] = frozenset({
95
+ "Analog", "Poli", "Drift", "Meld",
96
+ })
97
+
98
+ # Drum-role keyword check. Pattern-fired MIDI notes at drum-rack convention
99
+ # pitches (36/38/42) need actual drum sources. A tonal synth there produces
100
+ # wrong-pitched output. The LLM is expected to fire MIDI 36/38/42 for
101
+ # drum layers (standard convention), so the same correctness check applies.
102
+ _DRUM_ROLE_URI_KEYWORDS: dict[str, tuple[str, ...]] = {
103
+ "kick": ("Drums", "Drum", "Kick", "808", "Bd"),
104
+ "snare": ("Drums", "Drum", "Snare", "Clap", "Rim"),
105
+ "hat": ("Drums", "Drum", "Hat", "Cymbal", "Hh"),
106
+ "perc": ("Drums", "Drum", "Perc", "Tom", "Shaker", "Cowbell"),
107
+ "clap": ("Drums", "Drum", "Clap", "Snare"),
108
+ }
109
+
110
+
111
+ def is_viable_instrument_uri(
112
+ uri: str,
113
+ device_name: str = "",
114
+ role: str = "",
115
+ ) -> bool:
116
+ """True when this URI loads an audible, role-appropriate sound source."""
117
+ if not uri:
118
+ return False
119
+ if not any(uri.startswith(p) for p in _INSTRUMENT_URI_PREFIXES):
120
+ return False
121
+ if device_name in _SILENT_WITHOUT_CONFIG:
122
+ return False
123
+ if device_name in _BANNED_DEFAULT_DEVICES:
124
+ return False
125
+ if role in _DRUM_ROLE_URI_KEYWORDS:
126
+ keywords = _DRUM_ROLE_URI_KEYWORDS[role]
127
+ haystack = (uri + " " + device_name).lower()
128
+ if not any(k.lower() in haystack for k in keywords):
129
+ return False
130
+ return True
131
+
132
+
133
+ # ── Atlas tag-based instrument picker (returns top-N for the brief) ──
134
+
135
+ _ROLE_TAGS: dict[str, tuple[str, ...]] = {
136
+ "kick": ("kick", "drum", "808"),
137
+ "snare": ("snare", "clap", "drum"),
138
+ "hat": ("hihat", "hi-hat", "hat", "cymbal"),
139
+ "perc": ("perc", "percussion", "drum"),
140
+ "clap": ("clap", "snare"),
141
+ "bass": ("bass", "sub_bass", "sub"),
142
+ "pad": ("pad", "texture", "atmos", "ambient"),
143
+ "lead": ("lead", "synth_lead", "pluck"),
144
+ "atmos": ("atmos", "ambient", "drone", "texture"),
145
+ "vox": ("vocal", "vox", "voice"),
146
+ }
147
+
148
+
149
+ def pick_instrument_uri(suggestions: list[dict], role: str = "") -> tuple[str, str]:
150
+ """Walk atlas suggestions and return the first viable (uri, device_name)."""
151
+ for s in suggestions or []:
152
+ uri = s.get("uri") or ""
153
+ name = s.get("device_name") or ""
154
+ if is_viable_instrument_uri(uri, name, role):
155
+ return uri, name
156
+ return "", ""
157
+
158
+
159
+ # Per-role sonic-description queries used by the atlas_search fallback
160
+ # when _by_tag returns no candidates (BUG-K caught 2026-05-01: many user
161
+ # atlases don't tag canonical "kick"/"hat"/"bass"/"pad" — but atlas_search
162
+ # with sonic queries finds the same devices via character_tags / use_cases).
163
+ _ROLE_SONIC_QUERIES: dict[str, str] = {
164
+ "kick": "punchy techno kick drum sub bass anchor",
165
+ "snare": "snare clap drum",
166
+ "hat": "crisp closed hihat hi-hat metal",
167
+ "perc": "percussion shaker tom cowbell",
168
+ "clap": "clap snare drum",
169
+ "bass": "warm techno bass sub low end",
170
+ "pad": "evolving pad atmospheric texture warm",
171
+ "lead": "lead synth pluck arp",
172
+ "atmos": "drone atmosphere texture ambient",
173
+ "vox": "vocal voice",
174
+ }
175
+
176
+
177
+ def get_role_candidates(
178
+ atlas: Any,
179
+ role: str,
180
+ genre: str = "",
181
+ top_n: int = 5,
182
+ exclude_names: Optional[set[str]] = None,
183
+ *,
184
+ ableton: Any = None,
185
+ excluded_uris: Optional[set[str]] = None,
186
+ ) -> list[dict]:
187
+ """Return up to top_n viable instrument candidates for a role.
188
+
189
+ v1.24 hunt order (Phase 4 Task 18a-followup — §1 fix):
190
+ Step 1: sounds/ — curated .adg/.adv presets (Tier-A, always preferred)
191
+ Step 1b: drums/ — raw samples for drum roles (Tier-A)
192
+ Step 2: atlas — Tier-B fallback:
193
+ - drum-specific synths (DS Kick etc.) → allowed bare for drum roles
194
+ - melodic synths (Operator/Wavetable etc.) → ONLY if Step 1 returned
195
+ nothing for a melodic role (§1 hard rule)
196
+ - Tier-C containers → NEVER
197
+
198
+ Args:
199
+ atlas: atlas object for tag/search fallback
200
+ role: "kick", "bass", "pad", etc.
201
+ genre: genre hint for sorting
202
+ top_n: max candidates to return
203
+ exclude_names: set of device names to filter (anti-repeat by name)
204
+ ableton: optional ableton client; if provided, Steps 1/1b fire
205
+ excluded_uris: set of recently-used preset URIs to filter (anti-repeat by URI)
206
+
207
+ Each candidate dict has: uri, name, tier, tags, pack, genre_affinity, source.
208
+ """
209
+ if atlas is None and ableton is None:
210
+ return []
211
+ exclude_names = exclude_names or set()
212
+ excluded_uris = excluded_uris or set()
213
+
214
+ role_lower = role.lower()
215
+ is_drum = role_lower in DRUM_ROLES
216
+ candidates: list[dict] = []
217
+
218
+ # ── Step 1: sounds/ — curated presets (always Tier-A) ────────────────
219
+ if ableton is not None:
220
+ sounds_term = ROLE_SEARCH_TERMS.get(role_lower, {}).get("sounds_term", role_lower)
221
+ try:
222
+ sounds_results = ableton.send_command("search_browser", {
223
+ "path": "sounds", "name_filter": sounds_term,
224
+ "max_depth": 4, "max_results": 8, "loadable_only": True,
225
+ })
226
+ for item in (sounds_results or {}).get("items", []):
227
+ uri = item.get("uri", "")
228
+ name = item.get("name", "")
229
+ if not name or not uri:
230
+ continue
231
+ if name in exclude_names or uri in excluded_uris:
232
+ continue
233
+ name_lower = name.lower()
234
+ if item.get("is_loadable") and (
235
+ name_lower.endswith(".adg") or name_lower.endswith(".adv")
236
+ ):
237
+ candidates.append({
238
+ "uri": uri, "name": name,
239
+ "tier": TIER_A_CURATED_PRESET, "source": "sounds/",
240
+ "tags": [], "pack": "", "genre_affinity": {},
241
+ })
242
+ except Exception as exc:
243
+ logger.debug("get_role_candidates sounds/ search failed for role=%s: %s", role, exc)
244
+
245
+ # ── Step 1b: drums/ — raw samples for drum roles (Tier-A) ────────
246
+ if is_drum:
247
+ drums_term = ROLE_SEARCH_TERMS.get(role_lower, {}).get("drums_term")
248
+ if drums_term:
249
+ try:
250
+ drums_results = ableton.send_command("search_browser", {
251
+ "path": "drums", "name_filter": drums_term,
252
+ "max_depth": 6, "max_results": 8, "loadable_only": True,
253
+ })
254
+ for item in (drums_results or {}).get("items", []):
255
+ uri = item.get("uri", "")
256
+ name = item.get("name", "")
257
+ if not name or not uri:
258
+ continue
259
+ if name in exclude_names or uri in excluded_uris:
260
+ continue
261
+ name_lower = name.lower()
262
+ if item.get("is_loadable") and name_lower.endswith(
263
+ (".aif", ".wav", ".adg", ".adv")
264
+ ):
265
+ candidates.append({
266
+ "uri": uri, "name": name,
267
+ "tier": TIER_A_DRUM_SAMPLE, "source": "drums/",
268
+ "tags": [], "pack": "", "genre_affinity": {},
269
+ })
270
+ except Exception as exc:
271
+ logger.debug("get_role_candidates drums/ search failed for role=%s: %s", role, exc)
272
+
273
+ # has_tier_a: whether Steps 1/1b returned any curated candidates
274
+ has_tier_a = len(candidates) > 0
275
+
276
+ # ── Step 2: atlas — Tier-B fallback ──────────────────────────────────
277
+ # Collect atlas candidates (tag-based, then sonic query)
278
+ if atlas is not None:
279
+ atlas_candidates: list[dict] = []
280
+ seen_uris: set[str] = set()
281
+
282
+ # Stage 2a: tag-based lookup
283
+ by_tag = getattr(atlas, "_by_tag", None)
284
+ if by_tag is not None:
285
+ tags = _ROLE_TAGS.get(role_lower, (role_lower,))
286
+ for tag in tags:
287
+ for dev in by_tag.get(tag.lower(), []):
288
+ uri = dev.get("uri") or ""
289
+ if uri and uri not in seen_uris:
290
+ seen_uris.add(uri)
291
+ dev_copy = dict(dev)
292
+ dev_copy["__source"] = "tag"
293
+ atlas_candidates.append(dev_copy)
294
+
295
+ # Stage 2b: sonic-description query fallback
296
+ if len(atlas_candidates) < top_n and hasattr(atlas, "search"):
297
+ sonic_query = _ROLE_SONIC_QUERIES.get(role_lower, role_lower)
298
+ try:
299
+ search_results = atlas.search(sonic_query, category="instruments", limit=top_n * 2)
300
+ except Exception:
301
+ search_results = []
302
+ for r in search_results or []:
303
+ dev_data = r.get("device") if isinstance(r, dict) and "device" in r else r
304
+ if not isinstance(dev_data, dict):
305
+ continue
306
+ uri = dev_data.get("uri") or ""
307
+ if uri and uri not in seen_uris:
308
+ seen_uris.add(uri)
309
+ dev_copy = dict(dev_data)
310
+ dev_copy["__source"] = "search"
311
+ atlas_candidates.append(dev_copy)
312
+
313
+ # Filter + classify atlas candidates
314
+ for dev in atlas_candidates:
315
+ uri = dev.get("uri") or ""
316
+ instrument_name = dev.get("name") or ""
317
+
318
+ if instrument_name in exclude_names or uri in excluded_uris:
319
+ continue
320
+
321
+ # Tier-C container: NEVER include
322
+ if instrument_name in CONTAINERS_NEEDING_PRESETS:
323
+ continue
324
+
325
+ # Viability gate (§1 ban on Analog/Poli/Drift/Meld + drum keyword)
326
+ if not is_viable_instrument_uri(uri, instrument_name, role_lower):
327
+ continue
328
+
329
+ # Drum-specific synths: always allowed for drum roles (their default IS the drum)
330
+ if is_drum_specific_synth(instrument_name):
331
+ if is_drum:
332
+ candidates.append({
333
+ "uri": uri, "name": instrument_name,
334
+ "tier": TIER_B_DRUM_SYNTH, "source": "atlas/",
335
+ "tags": dev.get("character_tags") or dev.get("tags") or [],
336
+ "pack": dev.get("pack") or "",
337
+ "genre_affinity": dev.get("genre_affinity") or dev.get("genres") or {},
338
+ })
339
+ # For melodic roles, drum-specific synths don't make sense — skip
340
+ continue
341
+
342
+ # Known generic melodic synths (Operator, Wavetable, etc.):
343
+ # §1 hard rule — ONLY include as fallback when Tier-A is empty for this role.
344
+ # These are the "generic AI synth" defaults the user has rejected repeatedly.
345
+ if instrument_name in MELODIC_AUDIBLE_DEFAULTS:
346
+ if not has_tier_a:
347
+ candidates.append({
348
+ "uri": uri, "name": instrument_name,
349
+ "tier": TIER_B_AUDIBLE_DEFAULT_VALUE,
350
+ "source": dev.get("__source", "atlas/"),
351
+ "tags": dev.get("character_tags") or dev.get("tags") or [],
352
+ "pack": dev.get("pack") or "",
353
+ "genre_affinity": dev.get("genre_affinity") or dev.get("genres") or {},
354
+ "fallback_warning": (
355
+ "no curated preset found in sounds/ for this role; "
356
+ "bare-synth default may sound generic"
357
+ ),
358
+ })
359
+ # If has_tier_a, SKIP this Tier-B melodic synth entirely (§1 fix)
360
+ continue
361
+
362
+ # Unknown classification — custom atlas device (named preset, curated device).
363
+ # These are NOT generic defaults — they have specific character via atlas.
364
+ # Include as Tier-B; viability gate already ran above.
365
+ candidates.append({
366
+ "uri": uri, "name": instrument_name,
367
+ "tier": TIER_B_AUDIBLE_DEFAULT_VALUE, "source": dev.get("__source", "atlas/"),
368
+ "tags": dev.get("character_tags") or dev.get("tags") or [],
369
+ "pack": dev.get("pack") or "",
370
+ "genre_affinity": dev.get("genre_affinity") or dev.get("genres") or {},
371
+ })
372
+
373
+ # ── Sort: Tier-A first, then Tier-B; within same tier, genre affinity ──
374
+ _TIER_ORDER = {
375
+ TIER_A_CURATED_PRESET: 0,
376
+ TIER_A_DRUM_SAMPLE: 1,
377
+ TIER_B_DRUM_SYNTH: 2,
378
+ TIER_B_AUDIBLE_DEFAULT_VALUE: 3,
379
+ # Legacy alias — sort same as B_audible_default
380
+ "B_audible_default": 3,
381
+ }
382
+ gl = (genre or "").lower()
383
+
384
+ def _genre_score(c: dict) -> int:
385
+ if not gl:
386
+ return 0
387
+ aff = c.get("genre_affinity") or {}
388
+ if isinstance(aff, dict):
389
+ primary = [str(g).lower() for g in (aff.get("primary") or [])]
390
+ secondary = [str(g).lower() for g in (aff.get("secondary") or [])]
391
+ if gl in primary:
392
+ return 2
393
+ if gl in secondary:
394
+ return 1
395
+ return 0
396
+
397
+ candidates.sort(
398
+ key=lambda c: (_TIER_ORDER.get(c.get("tier", ""), 99), -_genre_score(c))
399
+ )
400
+
401
+ return candidates[:top_n]
402
+
403
+
404
+ # Legacy name — kept for callers that still want a single pick (e.g. unit
405
+ # tests checking the deterministic path with top_n=1).
406
+ def pick_by_role_tag(
407
+ atlas: Any,
408
+ role: str,
409
+ genre: str = "",
410
+ top_n: int = 5,
411
+ rng: Optional[random.Random] = None,
412
+ ) -> tuple[str, str]:
413
+ """Single pick from atlas tag-based candidates with weighted random."""
414
+ candidates = get_role_candidates(atlas, role, genre=genre, top_n=top_n)
415
+ if not candidates:
416
+ return "", ""
417
+ if len(candidates) == 1 or top_n == 1:
418
+ return candidates[0]["uri"], candidates[0]["name"]
419
+ rng = rng or random.Random()
420
+ weights = list(range(len(candidates), 0, -1))
421
+ pick = rng.choices(candidates, weights=weights, k=1)[0]
422
+ return pick["uri"], pick["name"]
423
+
424
+
425
+ # ── Role → simpler-role mapping for load_browser_item ────────────────
426
+
427
+
428
+ def simpler_role_for(role: str) -> str | None:
429
+ if role in ("kick", "snare", "hat", "perc", "clap"):
430
+ return "drum"
431
+ if role in ("bass", "lead", "pad", "atmos"):
432
+ return "melodic"
433
+ return None
434
+
435
+
436
+ # ── Fresh-project detection ──────────────────────────────────────────
437
+
438
+ _DEFAULT_TRACK_NAME_RE = re.compile(
439
+ r"^\s*\d*[-\s]*(?:midi|audio)\s*\d*\s*$",
440
+ re.IGNORECASE,
441
+ )
442
+
443
+
444
+ def is_default_track_name(name: str) -> bool:
445
+ if not name:
446
+ return False
447
+ return bool(_DEFAULT_TRACK_NAME_RE.match(name))
448
+
449
+
450
+ def detect_fresh_project(session_info: dict) -> bool:
451
+ tracks = session_info.get("tracks", []) or []
452
+ if not tracks or len(tracks) > 4:
453
+ return False
454
+ return all(is_default_track_name(t.get("name", "") or "") for t in tracks)
455
+
456
+
457
+ def track_is_empty(track_info: dict) -> bool:
458
+ if not track_info:
459
+ return True
460
+ has_clips = any(s.get("has_clip") for s in (track_info.get("clip_slots") or []))
461
+ has_devices = bool(track_info.get("devices"))
462
+ return not has_clips and not has_devices
463
+
464
+
465
+ # ── Key parser + scale-degree math (helper math the LLM may reference) ─
466
+
467
+ _NOTE_TO_OFFSET = {
468
+ "C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3,
469
+ "E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8,
470
+ "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11,
471
+ }
472
+
473
+ _SCALE_INTERVALS_MINOR = [0, 2, 3, 5, 7, 8, 10] # Aeolian
474
+ _SCALE_INTERVALS_MAJOR = [0, 2, 4, 5, 7, 9, 11] # Ionian
475
+
476
+
477
+ def parse_key(key_str: str) -> tuple[int, str]:
478
+ """Parse "Cm" / "Am" / "F#m" / "C" / "G" → (root_offset_0_to_11, mode_str)."""
479
+ if not key_str:
480
+ return 0, "minor"
481
+ s = key_str.strip()
482
+ mode = "minor" if s.endswith("m") else "major"
483
+ note_part = s.rstrip("m")
484
+ if note_part not in _NOTE_TO_OFFSET:
485
+ return 0, "minor"
486
+ return _NOTE_TO_OFFSET[note_part], mode
487
+
488
+
489
+ def degree_to_pitch(degree: int, key_root: int, octave: int, mode: str) -> int:
490
+ scale = _SCALE_INTERVALS_MINOR if mode == "minor" else _SCALE_INTERVALS_MAJOR
491
+ octaves_up = (degree - 1) // 7
492
+ in_octave = (degree - 1) % 7
493
+ return 12 * (octave + octaves_up) + key_root + scale[in_octave]
494
+
495
+
496
+ def chord_at_degree(degree: int, key_root: int, octave: int, mode: str) -> list[int]:
497
+ return [
498
+ degree_to_pitch(degree, key_root, octave, mode),
499
+ degree_to_pitch(degree + 2, key_root, octave, mode),
500
+ degree_to_pitch(degree + 4, key_root, octave, mode),
501
+ ]
502
+
503
+
504
+ def scale_pitches_in_octave(key_root: int, octave: int, mode: str) -> list[int]:
505
+ """Return the 7 scale pitches for the LLM's reference (octave starts on tonic)."""
506
+ return [degree_to_pitch(d, key_root, octave, mode) for d in range(1, 8)]
507
+
508
+
509
+ # ── Genre creative guidance (Phase A — structured palettes, NOT templates) ─
510
+ #
511
+ # Each genre entry includes:
512
+ # - rhythmic_feel (text): high-level groove description
513
+ # - harmonic_palette (structured): suggested chord progressions with
514
+ # scale degrees + color-tone hints (modal mixture, secondary dom, etc.)
515
+ # - rhythmic_palette (structured): named gestures the agent can pick from
516
+ # (4-on-floor, 2-step, polyrhythm 3-against-4, etc.) WITH swing %
517
+ # - articulation_targets (structured): velocity stddev, ghost-note range,
518
+ # duration variation count, swing % — the agent should hit these
519
+ # - effect_chain_hints (structured): genre-typical effect chains per role
520
+ # - knowledge_search_queries (list): suggested queries for the agent to
521
+ # run against the Ableton Knowledge MCP for producer-voice inspiration
522
+
523
+ GENRE_CREATIVE_GUIDANCE: dict[str, dict] = {
524
+ "techno": {
525
+ "rhythmic_feel": "Driving 4-on-floor on the kick OR syncopated minimal kick. Hats on offbeats are signature; vary 8th/16th density per layer.",
526
+ "harmonic_palette": {
527
+ "summary": "Natural minor, modal — i-VI-VII or static i drones common. Avoid functional V-i.",
528
+ "progressions": [
529
+ {"name": "modal i-VI-VII", "degrees": [1, 6, 7], "feel": "ascending, hopeful within minor"},
530
+ {"name": "static i drone", "degrees": [1, 1, 1, 1], "feel": "hypnotic, single-chord vamp"},
531
+ {"name": "dorian i-IV", "degrees": [1, 4], "feel": "modal mixture, slight tension"},
532
+ {"name": "i-bVI-bIII", "degrees": [1, 6, 3], "feel": "chromatic mediant moves, dark"},
533
+ {"name": "i-VII alternation", "degrees": [1, 7], "feel": "2-bar cycle, Basic Channel adjacent"},
534
+ ],
535
+ "color_tones": ["b9 over the i for tension", "11 over the iv for openness"],
536
+ },
537
+ "rhythmic_palette": [
538
+ {"name": "4_on_floor", "kick_pattern": "every quarter", "swing_pct": 50},
539
+ {"name": "syncopated_minimal", "kick_pattern": "1, 3, and-of-3 only", "swing_pct": 50},
540
+ {"name": "polyrhythm_3_4", "kick_pattern": "3-against-4 hat against quarter kick", "swing_pct": 55},
541
+ {"name": "swung_offbeats", "kick_pattern": "4OTF + offbeat hats with swing", "swing_pct": 58},
542
+ ],
543
+ "articulation_targets": {
544
+ "velocity_stddev_min": 8,
545
+ "ghost_note_velocity_range": [25, 45],
546
+ "duration_variation_count_min": 3,
547
+ "swing_pct": 50,
548
+ "humanization_timing_ms": 5,
549
+ },
550
+ "effect_chain_hints": {
551
+ "kick": [
552
+ {"device": "Saturator", "params": {"Drive": 0.4}},
553
+ {"device": "Compressor", "params": {"Threshold": 0.75, "Ratio": 0.75}},
554
+ ],
555
+ "bass": [
556
+ {"device": "Saturator", "params": {"Drive": 0.5}},
557
+ {"device": "Compressor", "params": {"Threshold": 0.7, "Ratio": 0.75}},
558
+ ],
559
+ "hat": [
560
+ {"device": "EQ Eight", "params": {}},
561
+ ],
562
+ "pad": [
563
+ {"device": "Auto Filter", "params": {}},
564
+ {"device": "Reverb", "params": {}},
565
+ ],
566
+ },
567
+ "send_hints": {
568
+ "hat": [{"return_name": "A-Reverb", "value": 0.15}],
569
+ "pad": [{"return_name": "A-Reverb", "value": 0.4}, {"return_name": "B-Delay", "value": 0.2}],
570
+ },
571
+ "knowledge_search_queries": [
572
+ "minimal techno production technique",
573
+ "techno bass design saturation",
574
+ "techno hi-hat swing",
575
+ ],
576
+ "spacing_advice": "Carve room for the kick — keep bass fundamental above 100Hz or use a sub layer. Pad usually long-released, sits behind.",
577
+ "production_hints": "Long pads, sidechain pumping under the kick, careful EQ around 200Hz mud.",
578
+ },
579
+ "dub techno": {
580
+ "rhythmic_feel": "Minimal kick (bar-1 or 4OTF). Off-beat chord stabs (and-of-2, and-of-4). Sparse percussion. Silence is the content.",
581
+ "harmonic_palette": {
582
+ "summary": "i-VII repeating cycle (Basic Channel signature). Held drone chords. Two-bar changes. Single-pitch ostinati.",
583
+ "progressions": [
584
+ {"name": "i-VII cycle", "degrees": [1, 7], "feel": "the canonical Basic Channel move, 2 bars per chord"},
585
+ {"name": "i-iv modal", "degrees": [1, 4], "feel": "dorian flavor, breathing"},
586
+ {"name": "single i drone", "degrees": [1, 1, 1, 1], "feel": "absolute stasis, all atmosphere"},
587
+ {"name": "i-bVI", "degrees": [1, 6], "feel": "chromatic mediant, slow descent"},
588
+ ],
589
+ "color_tones": ["maj7 over the i for openness", "9 added to chord stabs"],
590
+ },
591
+ "rhythmic_palette": [
592
+ {"name": "minimal_kick_bar_1", "kick_pattern": "kick on bar-1 only, every other bar", "swing_pct": 50},
593
+ {"name": "4_on_floor_minimal", "kick_pattern": "4OTF, but quiet (vel 60-80)", "swing_pct": 50},
594
+ {"name": "kick_off", "kick_pattern": "no kick, just texture and chord", "swing_pct": 50},
595
+ ],
596
+ "articulation_targets": {
597
+ "velocity_stddev_min": 6,
598
+ "ghost_note_velocity_range": [20, 35],
599
+ "duration_variation_count_min": 2,
600
+ "swing_pct": 52,
601
+ "humanization_timing_ms": 8,
602
+ },
603
+ "effect_chain_hints": {
604
+ "kick": [
605
+ {"device": "Compressor", "params": {"Threshold": 0.8, "Ratio": 0.5}},
606
+ {"device": "EQ Eight", "params": {}},
607
+ ],
608
+ "pad": [
609
+ {"device": "Auto Filter", "params": {}},
610
+ {"device": "Echo", "params": {}},
611
+ ],
612
+ "perc": [
613
+ {"device": "Echo", "params": {}},
614
+ ],
615
+ "atmos": [
616
+ {"device": "Auto Filter", "params": {}},
617
+ ],
618
+ },
619
+ "send_hints": {
620
+ "pad": [{"return_name": "A-Reverb", "value": 0.6}, {"return_name": "B-Delay", "value": 0.4}],
621
+ "perc": [{"return_name": "A-Reverb", "value": 0.5}, {"return_name": "B-Delay", "value": 0.3}],
622
+ "atmos": [{"return_name": "A-Reverb", "value": 0.7}],
623
+ },
624
+ "knowledge_search_queries": [
625
+ "dub techno production",
626
+ "Basic Channel production technique",
627
+ "dub chord stab Auto Filter",
628
+ ],
629
+ "spacing_advice": "TRUST silence. 4-bar loops can be 80% empty. Atmosphere IS the content.",
630
+ "production_hints": "Long reverb tails (4-8 sec), low-pass filter sweeps, dub delay 1/8 with ~50% feedback.",
631
+ },
632
+ "house": {
633
+ "rhythmic_feel": "4OTF kick, claps on 2 and 4, hats offbeats. Bass often pumps with sidechain — space on beat 1.",
634
+ "harmonic_palette": {
635
+ "summary": "Minor or major; i-VI-iv-V common. Soulful chord extensions (maj7, m7). 4-bar cycles.",
636
+ "progressions": [
637
+ {"name": "i-VI-iv-V", "degrees": [1, 6, 4, 5], "feel": "classic house cycle"},
638
+ {"name": "i-iv-VII-III", "degrees": [1, 4, 7, 3], "feel": "deeper, modal"},
639
+ {"name": "ii-V-i", "degrees": [2, 5, 1], "feel": "jazz-house resolution"},
640
+ {"name": "i-VII-VI-V", "degrees": [1, 7, 6, 5], "feel": "Andalusian descending"},
641
+ ],
642
+ "color_tones": ["m7 chord extensions", "9 and 11 added to pad chords"],
643
+ },
644
+ "rhythmic_palette": [
645
+ {"name": "4OTF_classic", "kick_pattern": "every quarter, full vel", "swing_pct": 50},
646
+ {"name": "4OTF_with_swing", "kick_pattern": "every quarter + 16ths swung", "swing_pct": 60},
647
+ {"name": "syncopated_house", "kick_pattern": "kicks 1, 2.5, 3, 3.5", "swing_pct": 55},
648
+ ],
649
+ "articulation_targets": {
650
+ "velocity_stddev_min": 7,
651
+ "ghost_note_velocity_range": [30, 50],
652
+ "duration_variation_count_min": 3,
653
+ "swing_pct": 55,
654
+ "humanization_timing_ms": 5,
655
+ },
656
+ "effect_chain_hints": {
657
+ "kick": [
658
+ {"device": "Compressor", "params": {"Threshold": 0.75, "Ratio": 0.75}},
659
+ {"device": "EQ Eight", "params": {}},
660
+ ],
661
+ "bass": [
662
+ {"device": "Saturator", "params": {"Drive": 0.45}},
663
+ {"device": "Compressor", "params": {"Threshold": 0.7, "Ratio": 0.85}},
664
+ {"device": "EQ Eight", "params": {}},
665
+ ],
666
+ "clap": [
667
+ {"device": "EQ Eight", "params": {}},
668
+ ],
669
+ "pad": [
670
+ {"device": "Compressor", "params": {"Threshold": 0.7, "Ratio": 0.75}},
671
+ {"device": "Chorus-Ensemble", "params": {}},
672
+ ],
673
+ },
674
+ "send_hints": {
675
+ "clap": [{"return_name": "A-Reverb", "value": 0.3}],
676
+ "pad": [{"return_name": "A-Reverb", "value": 0.3}],
677
+ },
678
+ "knowledge_search_queries": [
679
+ "deep house production",
680
+ "house bass sidechain technique",
681
+ "house chord progression jazzy",
682
+ ],
683
+ "spacing_advice": "Bass leaves room on beat 1 for kick (sidechain). Hats fill offbeats. Pad held, lead carries melody.",
684
+ "production_hints": "Sidechain on bass + pad triggered by kick. Warm tape saturation. Reverb on snare/clap.",
685
+ },
686
+ "hip hop": {
687
+ "rhythmic_feel": "Boom-bap kick (1, 2.5, 3, 3.75) OR trap-sparse kicks. Snare/clap on 2 and 4 (locked). Hats 8ths or rolling 16ths.",
688
+ "harmonic_palette": {
689
+ "summary": "Minor often, jazzy chord extensions (m7, maj7, m9). Sample-based sometimes — progressions less strict.",
690
+ "progressions": [
691
+ {"name": "i-VI-ii°-V", "degrees": [1, 6, 2, 5], "feel": "jazzy minor"},
692
+ {"name": "i-iv-bVII-bIII", "degrees": [1, 4, 7, 3], "feel": "modal mixture, soulful"},
693
+ {"name": "i-bVI-bIII-bVII", "degrees": [1, 6, 3, 7], "feel": "all minor, all chromatic mediant"},
694
+ {"name": "static i with passing", "degrees": [1, 1, 1, 1], "feel": "hold on i, agent adds passing tones"},
695
+ ],
696
+ "color_tones": ["maj7 chord extensions on i", "9 and 11 added (jazz voicing)"],
697
+ },
698
+ "rhythmic_palette": [
699
+ {"name": "boom_bap", "kick_pattern": "1, 2.5, 3, 3.75 (offbeat 8ths)", "swing_pct": 58},
700
+ {"name": "trap_sparse", "kick_pattern": "1, 1.75, 2.5, 3.5 (sparse syncopation)", "swing_pct": 50},
701
+ {"name": "j_dilla_swung", "kick_pattern": "1, 2.5+30ms, 3 (Dilla swung)", "swing_pct": 65},
702
+ ],
703
+ "articulation_targets": {
704
+ "velocity_stddev_min": 10,
705
+ "ghost_note_velocity_range": [30, 50],
706
+ "duration_variation_count_min": 4,
707
+ "swing_pct": 58,
708
+ "humanization_timing_ms": 8,
709
+ },
710
+ "effect_chain_hints": {
711
+ "kick": [
712
+ {"device": "Saturator", "params": {"Drive": 0.3}},
713
+ {"device": "Compressor", "params": {"Threshold": 0.8, "Ratio": 0.85}},
714
+ {"device": "EQ Eight", "params": {}},
715
+ ],
716
+ "snare": [
717
+ {"device": "Compressor", "params": {"Threshold": 0.75, "Ratio": 0.75}},
718
+ {"device": "EQ Eight", "params": {}},
719
+ ],
720
+ "bass": [
721
+ {"device": "Saturator", "params": {"Drive": 0.4}},
722
+ {"device": "EQ Eight", "params": {}},
723
+ ],
724
+ "pad": [
725
+ {"device": "Auto Filter", "params": {}},
726
+ {"device": "Chorus-Ensemble", "params": {}},
727
+ ],
728
+ },
729
+ "send_hints": {
730
+ "snare": [{"return_name": "A-Reverb", "value": 0.2}],
731
+ "pad": [{"return_name": "A-Reverb", "value": 0.25}],
732
+ },
733
+ "knowledge_search_queries": [
734
+ "boom bap production technique",
735
+ "lo-fi hip hop chord voicing",
736
+ "J Dilla swing humanization",
737
+ ],
738
+ "spacing_advice": "Bass on the down — leave kick room. Pad/keys often jazzy chord stabs not held pads.",
739
+ "production_hints": "Lo-fi tape saturation, vinyl crackle (sparingly), warm low-pass filter.",
740
+ },
741
+ "drum and bass": {
742
+ "rhythmic_feel": "Kick on 1 and 2.75. Snare strict 2 and 4. Hats fast 16ths with strong velocity arc.",
743
+ "harmonic_palette": {
744
+ "summary": "Minor, often single-chord vamps. Reese basses chromatic. Ambient pads above the chaos.",
745
+ "progressions": [
746
+ {"name": "static i", "degrees": [1, 1, 1, 1], "feel": "hold on tonic, drums dominate"},
747
+ {"name": "i-bVI", "degrees": [1, 6], "feel": "chromatic mediant, dark"},
748
+ {"name": "chromatic walk", "degrees": [1, 1, 1, 1], "feel": "bass walks chromatically over static chord"},
749
+ ],
750
+ "color_tones": ["b5 over chromatic walks (Phrygian flavor)"],
751
+ },
752
+ "rhythmic_palette": [
753
+ {"name": "amen_break_inspired", "kick_pattern": "1, 2.75, snare 2&4 with ghost rolls", "swing_pct": 50},
754
+ {"name": "two_step", "kick_pattern": "1 and 2.75 only", "swing_pct": 50},
755
+ {"name": "neurofunk", "kick_pattern": "1, 2.5, 3, 3.75 with double-time", "swing_pct": 50},
756
+ ],
757
+ "articulation_targets": {
758
+ "velocity_stddev_min": 12,
759
+ "ghost_note_velocity_range": [25, 50],
760
+ "duration_variation_count_min": 4,
761
+ "swing_pct": 50,
762
+ "humanization_timing_ms": 3,
763
+ },
764
+ "effect_chain_hints": {
765
+ "kick": [
766
+ {"device": "Saturator", "params": {"Drive": 0.6}},
767
+ {"device": "Compressor", "params": {"Threshold": 0.85, "Ratio": 0.9}},
768
+ {"device": "EQ Eight", "params": {}},
769
+ ],
770
+ "snare": [
771
+ {"device": "Compressor", "params": {"Threshold": 0.8, "Ratio": 0.9}},
772
+ {"device": "Saturator", "params": {"Drive": 0.4}},
773
+ ],
774
+ "bass": [
775
+ {"device": "Auto Filter", "params": {}},
776
+ {"device": "Saturator", "params": {"Drive": 0.6}},
777
+ {"device": "EQ Eight", "params": {}},
778
+ ],
779
+ "hat": [
780
+ {"device": "EQ Eight", "params": {}},
781
+ ],
782
+ },
783
+ "send_hints": {
784
+ "snare": [{"return_name": "A-Reverb", "value": 0.3}],
785
+ "hat": [{"return_name": "A-Reverb", "value": 0.1}],
786
+ },
787
+ "knowledge_search_queries": [
788
+ "drum and bass production",
789
+ "reese bass design",
790
+ "amen break programming",
791
+ ],
792
+ "spacing_advice": "Drums dominate. Bass and pad carry harmonic content but stay out of drum frequencies.",
793
+ "production_hints": "Reese bass = detuned saw stack with movement. Pad above the noise. Tight compression on drums.",
794
+ },
795
+ "ambient": {
796
+ "rhythmic_feel": "Often rhythmless, or sparse percussive accents. NO drums needed unless explicit.",
797
+ "harmonic_palette": {
798
+ "summary": "Slow chord changes (4-8 bars per chord). Modal/static. Pedal points, drones.",
799
+ "progressions": [
800
+ {"name": "single chord drone", "degrees": [1, 1, 1, 1], "feel": "16-32 bar held chord"},
801
+ {"name": "i-bVI very slow", "degrees": [1, 6], "feel": "8 bars per chord, chromatic mediant"},
802
+ {"name": "modal cycle slow", "degrees": [1, 4, 1, 5], "feel": "8 bars per chord, breathing"},
803
+ ],
804
+ "color_tones": ["maj7, 9, 11 — every color tone available"],
805
+ },
806
+ "rhythmic_palette": [
807
+ {"name": "no_drums", "kick_pattern": "(skip drum layers entirely)", "swing_pct": 50},
808
+ {"name": "sparse_perc", "kick_pattern": "occasional bell or bowl, no kick", "swing_pct": 50},
809
+ ],
810
+ "articulation_targets": {
811
+ "velocity_stddev_min": 4,
812
+ "ghost_note_velocity_range": [20, 40],
813
+ "duration_variation_count_min": 2,
814
+ "swing_pct": 50,
815
+ "humanization_timing_ms": 15,
816
+ },
817
+ "effect_chain_hints": {
818
+ "pad": [
819
+ {"device": "Auto Filter", "params": {}},
820
+ {"device": "Reverb", "params": {}},
821
+ {"device": "Chorus-Ensemble", "params": {}},
822
+ ],
823
+ "atmos": [
824
+ {"device": "Echo", "params": {}},
825
+ {"device": "Erosion", "params": {}},
826
+ ],
827
+ },
828
+ "send_hints": {
829
+ "atmos": [{"return_name": "A-Reverb", "value": 0.7}],
830
+ },
831
+ "knowledge_search_queries": [
832
+ "ambient texture design",
833
+ "drone production technique",
834
+ "Brian Eno ambient",
835
+ ],
836
+ "spacing_advice": "Massive space. Single notes can carry whole bars. Reverb is the rhythm.",
837
+ "production_hints": "Long reverb (10s+), evolving texture, granular sample movement.",
838
+ },
839
+ "lo-fi": {
840
+ "rhythmic_feel": "Boom-bap-ish kick, snare on 2/4, hats with swing. Slight tempo wobble feel.",
841
+ "harmonic_palette": {
842
+ "summary": "Jazzy minor or major 7th chords. Borrowed chords, Em7-A7-Dmaj7-style progressions.",
843
+ "progressions": [
844
+ {"name": "ii-V-I", "degrees": [2, 5, 1], "feel": "jazz turnaround"},
845
+ {"name": "i-bIII-VI-IV", "degrees": [1, 3, 6, 4], "feel": "modal-mixture lo-fi"},
846
+ {"name": "i-iv-bVII-bIII", "degrees": [1, 4, 7, 3], "feel": "soulful"},
847
+ ],
848
+ "color_tones": ["maj7, m7, 9, 13 (jazz voicings)"],
849
+ },
850
+ "rhythmic_palette": [
851
+ {"name": "boom_bap_swung", "kick_pattern": "1, 2.5, 3, 3.75 with heavy swing", "swing_pct": 62},
852
+ {"name": "j_dilla", "kick_pattern": "1, off-by-30ms, 3", "swing_pct": 65},
853
+ ],
854
+ "articulation_targets": {
855
+ "velocity_stddev_min": 12,
856
+ "ghost_note_velocity_range": [30, 55],
857
+ "duration_variation_count_min": 4,
858
+ "swing_pct": 62,
859
+ "humanization_timing_ms": 12,
860
+ },
861
+ "effect_chain_hints": {
862
+ "kick": [
863
+ {"device": "Saturator", "params": {"Drive": 0.35}},
864
+ {"device": "EQ Eight", "params": {}},
865
+ ],
866
+ "bass": [
867
+ {"device": "Saturator", "params": {"Drive": 0.4}},
868
+ {"device": "EQ Eight", "params": {}},
869
+ ],
870
+ "pad": [
871
+ {"device": "Auto Filter", "params": {}},
872
+ {"device": "Chorus-Ensemble", "params": {}},
873
+ {"device": "Vinyl Distortion", "params": {}},
874
+ ],
875
+ },
876
+ "send_hints": {
877
+ "pad": [{"return_name": "A-Reverb", "value": 0.25}],
878
+ },
879
+ "knowledge_search_queries": [
880
+ "lo-fi hip hop production",
881
+ "jazz chord voicing for lo-fi",
882
+ "tape saturation technique",
883
+ ],
884
+ "spacing_advice": "Mid-density. Lots of room for the warmth/dust to breathe.",
885
+ "production_hints": "Tape saturation HEAVY. Vinyl crackle LOW. Detune chord pads slightly.",
886
+ },
887
+ "trap": {
888
+ "rhythmic_feel": "Sparse trap kick. Snare/clap on 2 and 4. Hats rolling 16ths/32nds with rolls.",
889
+ "harmonic_palette": {
890
+ "summary": "Minor, often single-chord. Cinematic, dark. 808 sub bass.",
891
+ "progressions": [
892
+ {"name": "static i", "degrees": [1, 1, 1, 1], "feel": "hold on tonic, drums + 808 dominate"},
893
+ {"name": "i-bVI", "degrees": [1, 6], "feel": "cinematic chromatic mediant"},
894
+ {"name": "i-iv", "degrees": [1, 4], "feel": "modal, dark"},
895
+ ],
896
+ "color_tones": ["b5, m7"],
897
+ },
898
+ "rhythmic_palette": [
899
+ {"name": "sparse_trap", "kick_pattern": "1, 1.75, 2.5, 3, 3.75 (5 hits/bar)", "swing_pct": 50},
900
+ {"name": "808_long_sustain", "kick_pattern": "1 + bass 808 sustains", "swing_pct": 50},
901
+ ],
902
+ "articulation_targets": {
903
+ "velocity_stddev_min": 8,
904
+ "ghost_note_velocity_range": [30, 55],
905
+ "duration_variation_count_min": 5,
906
+ "swing_pct": 50,
907
+ "humanization_timing_ms": 4,
908
+ },
909
+ "effect_chain_hints": {
910
+ "kick": [
911
+ {"device": "Saturator", "params": {"Drive": 0.55}},
912
+ {"device": "EQ Eight", "params": {}},
913
+ ],
914
+ "bass": [
915
+ {"device": "Saturator", "params": {"Drive": 0.7}},
916
+ {"device": "Compressor", "params": {}},
917
+ {"device": "Auto Filter", "params": {}},
918
+ ],
919
+ "hat": [
920
+ {"device": "EQ Eight", "params": {}},
921
+ ],
922
+ "snare": [
923
+ {"device": "Reverb", "params": {}},
924
+ ],
925
+ },
926
+ "send_hints": {
927
+ "snare": [{"return_name": "A-Reverb", "value": 0.22}],
928
+ "hat": [{"return_name": "A-Reverb", "value": 0.10}],
929
+ },
930
+ "knowledge_search_queries": [
931
+ "trap 808 design",
932
+ "trap hi-hat rolls programming",
933
+ "trap production",
934
+ ],
935
+ "spacing_advice": "Drums are dense; bass holds the harmonic foundation. Pad/melody (if used) sparse.",
936
+ "production_hints": "808 sub long sustains. Tight kick. Saturated hats. Reverb on snare/clap.",
937
+ },
938
+ }
939
+
940
+
941
+ # ── Ableton Knowledge integration: per-genre-role query templates ───
942
+ #
943
+ # Queries fired against the Ableton Knowledge MCP (search_transcripts +
944
+ # search_live_manual + search_videos) to surface producer-voice technique
945
+ # snippets per role. The brief includes these as `recommended_searches`;
946
+ # the agent fires them inline before designing and attributes techniques
947
+ # in the apply plan.
948
+ #
949
+ # Coverage strategy: 1-2 queries per (genre × role) targeting the
950
+ # search_transcripts tool (the gold mine — semantic search over Ableton's
951
+ # YouTube tutorial transcripts, which is where producer-voice context lives).
952
+
953
+ _DEVICE_FOR_ROLE: dict[str, list[str]] = {
954
+ "kick": ["Saturator", "Drum Buss", "Compressor"],
955
+ "snare": ["Drum Buss", "Compressor", "Reverb"],
956
+ "hat": ["EQ Eight", "Reverb"],
957
+ "perc": ["Echo", "Reverb"],
958
+ "clap": ["Reverb", "Drum Buss"],
959
+ "bass": ["Saturator", "Auto Filter", "Operator", "Wavetable"],
960
+ "pad": ["Auto Filter", "Reverb", "Chorus-Ensemble"],
961
+ "lead": ["Operator", "Wavetable", "Auto Filter"],
962
+ "atmos": ["Reverb", "Auto Filter", "Granulator III"],
963
+ }
964
+
965
+
966
+ def _build_genre_role_queries(genre: str, role: str) -> list[dict]:
967
+ """BUG-L (2026-05-01): Ableton's tutorial corpus is feature/device-named,
968
+ NOT producer-technique-named. The previous queries ("techno kick design
969
+ saturation transient") returned 0 results because the corpus indexes by
970
+ "Saturator", "Drum Buss", etc. — Live's actual feature names.
971
+
972
+ This builder generates queries that reliably hit the corpus:
973
+ 1. Device-name searches against the Live manual (authoritative, dense)
974
+ 2. User-question-shape transcript searches (broader hit rate)
975
+ 3. One genre-named video search (catches "Made in Ableton" producer content)
976
+ """
977
+ devices = _DEVICE_FOR_ROLE.get(role, ["EQ Eight"])
978
+ queries: list[dict] = []
979
+
980
+ # 1. Device-name in manual (always returns dense, high-quality content)
981
+ if devices:
982
+ queries.append({"tool": "search_live_manual", "query": devices[0]})
983
+
984
+ # 2. User-question shape transcript search
985
+ if role in ("kick", "snare", "hat", "perc", "clap"):
986
+ queries.append({
987
+ "tool": "search_transcripts",
988
+ "query": f"how to mix {role}",
989
+ })
990
+ elif role == "bass":
991
+ queries.append({"tool": "search_transcripts", "query": "bass synthesis low end"})
992
+ elif role == "pad":
993
+ queries.append({"tool": "search_transcripts", "query": "pad sound design"})
994
+ else:
995
+ queries.append({"tool": "search_transcripts", "query": f"{role} design"})
996
+
997
+ # 3. Genre-keyword video search (catches "Made in Ableton" producer content)
998
+ g = (genre or "").strip()
999
+ if g:
1000
+ queries.append({"tool": "search_videos", "query": f"{g} {role}"})
1001
+
1002
+ return queries
1003
+
1004
+
1005
+ def get_knowledge_queries_for_role(genre: str, role: str) -> list[dict]:
1006
+ """Return the recommended search queries for a (genre, role) pair.
1007
+
1008
+ Built dynamically from device-name lookups + user-question patterns
1009
+ so we hit Ableton's actual tutorial/manual corpus (BUG-L).
1010
+ """
1011
+ return _build_genre_role_queries(genre or "", role)
1012
+
1013
+
1014
+ # Kept for backward compat — referenced by tests + reportable from
1015
+ # brief metadata if needed.
1016
+ GENRE_KNOWLEDGE_QUERIES: dict[str, dict[str, list[dict]]] = {
1017
+ genre: {role: _build_genre_role_queries(genre, role) for role in _DEVICE_FOR_ROLE}
1018
+ for genre in ("techno", "dub techno", "house", "hip hop",
1019
+ "drum and bass", "ambient", "lo-fi", "trap")
1020
+ }
1021
+
1022
+
1023
+ def reference_artist_queries(artist: str, genre: str = "") -> list[dict]:
1024
+ """Return search queries for a reference-artist composition.
1025
+
1026
+ Tier 2: when the user says compose(reference="Ricardo Villalobos"),
1027
+ the brief includes these searches so the agent designs USING that
1028
+ artist's signature techniques.
1029
+ """
1030
+ if not artist:
1031
+ return []
1032
+ a = artist.strip()
1033
+ queries = [
1034
+ {"tool": "search_videos", "query": f"{a} production technique"},
1035
+ {"tool": "search_transcripts", "query": f"{a} {genre or ''}".strip()},
1036
+ {"tool": "search_transcripts", "query": f"{a} signature sound"},
1037
+ ]
1038
+ return queries
1039
+
1040
+
1041
+ # ── Creative seeds (random aesthetic bias per call) ──────────────────
1042
+ # The brief picks one randomly per call. The agent reads it and tilts
1043
+ # the design that way. Same prompt + different seed → different feel.
1044
+
1045
+ CREATIVE_SEEDS: list[dict] = [
1046
+ {"label": "spacious / minimal", "directive": "Leave more silence than you think you should. ~70% of the loop should breathe. Single notes can carry full bars."},
1047
+ {"label": "dense / driving", "directive": "Pack the rhythm — overlapping perc, busy hats, syncopated bass. Push the energy."},
1048
+ {"label": "fragmented / glitchy", "directive": "Break up the pulse — start mid-phrase, cut notes short, stutter rhythms. Embrace asymmetry."},
1049
+ {"label": "warm / dusty", "directive": "Soften everything — gentle velocities, smooth durations, no sharp transients. Tape-saturation feel even without effects."},
1050
+ {"label": "hypnotic / repetitive", "directive": "Lock into a single 1-bar phrase and repeat. Tiny micro-variations across bars 2-4. Trance-inducing."},
1051
+ {"label": "off-balance / asymmetric", "directive": "Use 5-bar phrases, polyrhythm 3-against-4, displaced downbeats. Make it feel WRONG in a good way."},
1052
+ {"label": "spacious dub", "directive": "Treat reverb and delay as instruments. Notes are just excitations of the space."},
1053
+ {"label": "cinematic / brooding", "directive": "Long sustains, slow chord changes, low-velocity layers. Atmosphere is the content."},
1054
+ {"label": "playful / bouncy", "directive": "Stagger note placements off-grid by tens of ms. Velocity ramps and dips. Make it dance."},
1055
+ {"label": "monochromatic / focused", "directive": "Stick to 2-3 pitches max across all melodic layers. The single repeated motif is the hook."},
1056
+ ]
1057
+
1058
+
1059
+ # ── Anti-defaults per genre (random anti-pattern bias per call) ──────
1060
+ # Forces creative reach by explicitly forbidding the most-common pattern
1061
+ # choice for the genre on this call. Picks 1-2 randomly.
1062
+
1063
+ ANTI_DEFAULTS_BY_GENRE: dict[str, list[str]] = {
1064
+ "techno": [
1065
+ "no four-on-floor — try a syncopated kick instead",
1066
+ "no held-chord pad — use stabs or evolving texture",
1067
+ "no bass on every 8th — leave space",
1068
+ "no minor key — try Phrygian or Locrian for this call",
1069
+ ],
1070
+ "dub techno": [
1071
+ "no four-on-floor kick — minimal kick or no kick",
1072
+ "no on-the-grid chord stabs — push them off-beat or polyrhythmic",
1073
+ "no major-7 pad — use sus2/sus4 or single-pitch drone",
1074
+ ],
1075
+ "house": [
1076
+ "no plain four-on-floor — add ghost kicks or syncopated 16ths",
1077
+ "no claps strictly on 2 and 4 — try displaced clap on 2.5",
1078
+ "no 4-bar progression — try 8-bar or 5-bar phrasing",
1079
+ ],
1080
+ "hip hop": [
1081
+ "no boom-bap kick — try trap-sparse or off-grid placement",
1082
+ "no snare/clap strictly on 2 and 4 — push or pull by 30ms",
1083
+ "no held pad — use jazzy chord stabs",
1084
+ ],
1085
+ "drum and bass": [
1086
+ "no straight 2-step — try amen-break-inspired ghost rolls",
1087
+ "no chromatic reese — try modal vamping",
1088
+ "no 4-bar phrase — try 6 or 8",
1089
+ ],
1090
+ "ambient": [
1091
+ "no perfect cadence — avoid V-I",
1092
+ "no 4-bar phrasing — extend chord changes to 8-16 bars",
1093
+ "no static velocity — slow swells and dips",
1094
+ ],
1095
+ "lo-fi": [
1096
+ "no straight boom-bap — push the swing past 65%",
1097
+ "no on-grid chord changes — anticipate by an 8th",
1098
+ "no major-key happy progression — keep it minor and jazzy",
1099
+ ],
1100
+ "trap": [
1101
+ "no kick exactly on 1 — anticipate by 16th or push by 16th",
1102
+ "no 16th hat rolls — try 32nd or 64th with varying velocity arcs",
1103
+ "no minor pad — try a single 808 with vibrato as the only melodic content",
1104
+ ],
1105
+ "_default": [
1106
+ "avoid the most obvious rhythmic choice for this genre",
1107
+ "leave at least one beat fully silent across the loop",
1108
+ "use a non-diatonic color tone at least once",
1109
+ ],
1110
+ }
1111
+
1112
+
1113
+ def get_creative_guidance(genre: str, sub_genre: str = "") -> dict:
1114
+ """Return the creative-guidance dict for a genre, falling back to the
1115
+ closest match if the exact genre isn't in our guidance map."""
1116
+ g = (genre or "").lower().strip()
1117
+ if g in GENRE_CREATIVE_GUIDANCE:
1118
+ return GENRE_CREATIVE_GUIDANCE[g]
1119
+ s = (sub_genre or "").lower().strip()
1120
+ if s in GENRE_CREATIVE_GUIDANCE:
1121
+ return GENRE_CREATIVE_GUIDANCE[s]
1122
+ # Generic fallback (must include all the structured fields too)
1123
+ return {
1124
+ "rhythmic_feel": "Match the prompt's vibe; favor groove + space over density.",
1125
+ "harmonic_palette": {
1126
+ "summary": "Use the requested key. Minor by default unless prompt suggests major.",
1127
+ "progressions": [
1128
+ {"name": "static i", "degrees": [1, 1, 1, 1], "feel": "hold on tonic"},
1129
+ {"name": "i-IV-V-i", "degrees": [1, 4, 5, 1], "feel": "classic functional"},
1130
+ ],
1131
+ "color_tones": ["maj7", "9"],
1132
+ },
1133
+ "rhythmic_palette": [
1134
+ {"name": "generic_4OTF", "kick_pattern": "every quarter", "swing_pct": 50},
1135
+ ],
1136
+ "articulation_targets": {
1137
+ "velocity_stddev_min": 6,
1138
+ "ghost_note_velocity_range": [25, 45],
1139
+ "duration_variation_count_min": 2,
1140
+ "swing_pct": 50,
1141
+ "humanization_timing_ms": 5,
1142
+ },
1143
+ "effect_chain_hints": {},
1144
+ "send_hints": {},
1145
+ "knowledge_search_queries": [],
1146
+ "spacing_advice": "Leave room between layers. More space = more impact.",
1147
+ "production_hints": "Velocity humanization, voice-leading, vary durations.",
1148
+ }
1149
+
1150
+
1151
+ def pick_creative_seed(rng: Optional[random.Random] = None) -> dict:
1152
+ """Pick a random creative seed for THIS call. Different per call =
1153
+ different aesthetic bias = different sound from same prompt."""
1154
+ rng = rng or random.Random()
1155
+ return rng.choice(CREATIVE_SEEDS)
1156
+
1157
+
1158
+ def pick_anti_defaults(
1159
+ genre: str, count: int = 2, rng: Optional[random.Random] = None
1160
+ ) -> list[str]:
1161
+ """Pick `count` random anti-defaults for the genre. Forces the agent
1162
+ to reach beyond the obvious genre-default move on each call."""
1163
+ rng = rng or random.Random()
1164
+ g = (genre or "").lower().strip()
1165
+ pool = ANTI_DEFAULTS_BY_GENRE.get(g) or ANTI_DEFAULTS_BY_GENRE.get("_default") or []
1166
+ if not pool:
1167
+ return []
1168
+ n = min(count, len(pool))
1169
+ return rng.sample(pool, n)
1170
+
1171
+
1172
+ # ── Brief builder — the heart of the new fast mode ───────────────────
1173
+
1174
+
1175
+ # Recommended MIDI octave per role — prevents the agent from designing
1176
+ # bass at A1 (sub-bass territory, often inaudible or muddy) or pad at
1177
+ # octave 6 (too thin). User feedback 2026-05-01: "the drums and bass
1178
+ # everything is super low pitched". The brief now explicitly tells the
1179
+ # agent which octaves work for each role.
1180
+ RECOMMENDED_OCTAVES_PER_ROLE: dict[str, dict] = {
1181
+ "kick": {"midi_pitch": 36, "note": "C1", "rationale": "drum-rack convention; sample plays at natural pitch"},
1182
+ "snare": {"midi_pitch": 38, "note": "D1", "rationale": "drum-rack convention"},
1183
+ "hat": {"midi_pitch": 42, "note": "F#1", "rationale": "drum-rack closed hat convention"},
1184
+ "perc": {"midi_pitch": 39, "note": "Eb1", "rationale": "drum-rack perc convention"},
1185
+ "clap": {"midi_pitch": 39, "note": "Eb1", "rationale": "drum-rack clap convention"},
1186
+ "bass": {"recommended_octave": 2, "midi_range": [33, 50], "rationale": "bass synth presence — A2-Bb2 range. AVOID octave 1 (A1=33) for techno bass; sub-territory becomes inaudible/muddy."},
1187
+ "pad": {"recommended_octave": 4, "midi_range": [55, 76], "rationale": "mid-range pad sits behind kick/bass. C4=60, A4=69 typical."},
1188
+ "lead": {"recommended_octave": 5, "midi_range": [60, 84], "rationale": "lead cuts through; octaves 5-6 typical."},
1189
+ "atmos": {"recommended_octave": 5, "midi_range": [60, 84], "rationale": "atmos floats above the mix. Layered drones can use multiple octaves."},
1190
+ "vox": {"recommended_octave": 4, "midi_range": [55, 76], "rationale": "vocal range C3-C5."},
1191
+ }
1192
+
1193
+
1194
+ def _extract_loaded_device_names(session_info: dict) -> set[str]:
1195
+ """Anti-repeat helper: extract names of devices currently loaded in the
1196
+ session, so the brief picker can bias AWAY from already-used devices.
1197
+
1198
+ This solves the "Tree Tone always wins for pad" repetition the user
1199
+ called out — once Tree Tone is loaded on track 3, the next call's
1200
+ pad picker will see it in exclude_names and pick something else.
1201
+ """
1202
+ names: set[str] = set()
1203
+ for track in (session_info.get("tracks") or []):
1204
+ # Some session_info shapes carry track devices; if not, the brief
1205
+ # builder has to look up via get_track_info per track. The cheap
1206
+ # path is fine for now — read whatever names are available.
1207
+ for dev in (track.get("devices") or []):
1208
+ n = dev.get("name") or ""
1209
+ if n:
1210
+ names.add(n)
1211
+ return names
1212
+
1213
+
1214
+ def _extract_loaded_preset_uris(session_info: dict) -> set[str]:
1215
+ """Anti-repeat helper: extract browser URIs of curated presets already
1216
+ loaded in the session.
1217
+
1218
+ Looks for device names ending in .adg or .adv (these are curated presets
1219
+ that were loaded via load_browser_item and carry a browser URI). Adds the
1220
+ URI so get_role_candidates can filter them via excluded_uris.
1221
+
1222
+ Also extracts sample paths from Simpler/Sampler devices when available.
1223
+ """
1224
+ uris: set[str] = set()
1225
+ for track in (session_info.get("tracks") or []):
1226
+ for dev in (track.get("devices") or []):
1227
+ uri = dev.get("uri") or dev.get("browser_uri") or ""
1228
+ if uri:
1229
+ uris.add(uri)
1230
+ # Also capture sample paths stored on Simpler devices
1231
+ sample_path = dev.get("sample_path") or dev.get("file_path") or ""
1232
+ if sample_path:
1233
+ uris.add(sample_path)
1234
+ return uris
1235
+
1236
+
1237
+ def build_creative_brief(
1238
+ intent: CompositionIntent,
1239
+ atlas: Any,
1240
+ fresh_project_state: dict,
1241
+ bars: int = 4,
1242
+ candidates_per_role: int = 12,
1243
+ rng: Optional[random.Random] = None,
1244
+ reference: Optional[str] = None,
1245
+ exclude_loaded_device_names: Optional[set[str]] = None,
1246
+ *,
1247
+ ableton: Any = None,
1248
+ excluded_preset_uris: Optional[set[str]] = None,
1249
+ ) -> dict:
1250
+ """Build the creative brief returned by compose(mode="fast").
1251
+
1252
+ Phase-A enrichments (2026-05-01):
1253
+ - 12 atlas candidates per role (was 5) → more atlas variety surfaced
1254
+ - Creative seed (random aesthetic bias per call from CREATIVE_SEEDS)
1255
+ - Anti-defaults (random anti-pattern bias per call)
1256
+ - Structured harmonic_palette: chord progressions w/ degrees + color tones
1257
+ - Structured rhythmic_palette: named gestures w/ swing %
1258
+ - Articulation targets: velocity stddev / ghost-note range / etc.
1259
+ - Effect chain hints per role (genre-typical processing)
1260
+ - knowledge_search_queries: queries the agent can run via Ableton Knowledge
1261
+ MCP (search_transcripts / search_videos / search_live_manual) for
1262
+ producer-voice technique inspiration
1263
+
1264
+ Phase-4 Task 18a-followup (§1 fix):
1265
+ - ableton: if provided, fires search_browser("sounds/") before atlas so
1266
+ curated .adg/.adv presets are Tier-A candidates for every role.
1267
+ - excluded_preset_uris: URIs of recently-loaded curated presets — filtered
1268
+ out so the agent can't re-pick the same .adv twice in a row.
1269
+
1270
+ The agent reads this, designs creatively, submits to compose_fast_apply.
1271
+ """
1272
+ rng = rng or random.Random()
1273
+ key_root, mode_str = parse_key(intent.key)
1274
+ bpm = intent.tempo or 120
1275
+
1276
+ # Scale pitches at multiple octaves
1277
+ scale_pitches = {
1278
+ "tonic_at_octave": {
1279
+ str(o): degree_to_pitch(1, key_root, o, mode_str) for o in range(1, 6)
1280
+ },
1281
+ "scale_at_octave_4": scale_pitches_in_octave(key_root, 4, mode_str),
1282
+ "scale_at_octave_2": scale_pitches_in_octave(key_root, 2, mode_str),
1283
+ "diatonic_chord_roots_octave_4": [
1284
+ chord_at_degree(d, key_root, 4, mode_str)[0] for d in range(1, 8)
1285
+ ],
1286
+ }
1287
+
1288
+ # Atlas + sounds/ instrument candidates — 12 per role with anti-repeat exclusion.
1289
+ # (NEW 2026-05-01: pass currently-loaded device names so candidates bias toward
1290
+ # variety across calls instead of repeating same picks.)
1291
+ # (NEW 18a-followup: pass ableton + excluded_preset_uris for sounds/ hunt.)
1292
+ suggested_roles = _suggest_layer_roles(intent)
1293
+ instruments_by_role: dict[str, list[dict]] = {}
1294
+ for role in suggested_roles:
1295
+ candidates = get_role_candidates(
1296
+ atlas, role,
1297
+ genre=intent.genre or "",
1298
+ top_n=candidates_per_role,
1299
+ exclude_names=exclude_loaded_device_names,
1300
+ ableton=ableton,
1301
+ excluded_uris=excluded_preset_uris,
1302
+ )
1303
+ instruments_by_role[role] = candidates
1304
+
1305
+ # Per-role octave recommendations (NEW: prevents A1 bass or octave-6 pad)
1306
+ octaves_by_role: dict[str, dict] = {
1307
+ role: RECOMMENDED_OCTAVES_PER_ROLE[role]
1308
+ for role in suggested_roles
1309
+ if role in RECOMMENDED_OCTAVES_PER_ROLE
1310
+ }
1311
+
1312
+ # Genre creative guidance (structured palettes + text advice + effect chains)
1313
+ guidance = get_creative_guidance(intent.genre or "", intent.sub_genre or "")
1314
+
1315
+ # Phase A — random per-call creative seed + anti-defaults force surprise
1316
+ creative_seed = pick_creative_seed(rng=rng)
1317
+ anti_defaults = pick_anti_defaults(
1318
+ intent.genre or "", count=2, rng=rng,
1319
+ )
1320
+
1321
+ # Tier-1A: Per-role recommended searches against Ableton Knowledge MCP.
1322
+ # BUG-O fix (2026-05-01): cap at ONE recommended search per role (the
1323
+ # device-name manual lookup, which is the most reliable corpus). The
1324
+ # earlier 3-per-role design produced 12 searches × ~1-2s each = 24s of
1325
+ # extra latency the agent is unlikely to honor anyway. The remaining
1326
+ # queries are surfaced in `optional_searches` for the agent to fire
1327
+ # only when it has time.
1328
+ recommended_searches: list[dict] = []
1329
+ optional_searches: list[dict] = []
1330
+ for role in suggested_roles:
1331
+ role_queries = get_knowledge_queries_for_role(intent.genre or "", role)
1332
+ if not role_queries:
1333
+ continue
1334
+ # Most useful query per role goes in recommended (manual or first)
1335
+ primary = role_queries[0]
1336
+ recommended_searches.append({
1337
+ "role": role,
1338
+ "tool": primary["tool"],
1339
+ "query": primary["query"],
1340
+ })
1341
+ # Rest go in optional
1342
+ for q in role_queries[1:]:
1343
+ optional_searches.append({
1344
+ "role": role,
1345
+ "tool": q["tool"],
1346
+ "query": q["query"],
1347
+ })
1348
+
1349
+ # Tier-2: Reference-artist queries when caller passes a reference.
1350
+ reference_searches: list[dict] = []
1351
+ if reference:
1352
+ for q in reference_artist_queries(reference, genre=intent.genre or ""):
1353
+ reference_searches.append({
1354
+ "role": None, # global, not role-specific
1355
+ "tool": q["tool"],
1356
+ "query": q["query"],
1357
+ })
1358
+
1359
+ return {
1360
+ "phase": "brief",
1361
+ "mode": "fast",
1362
+ "phase_version": "A",
1363
+ "intent": intent.to_dict(),
1364
+ "tempo": bpm,
1365
+ "key": {
1366
+ "key_str": intent.key or "",
1367
+ "key_root": key_root,
1368
+ "mode": mode_str,
1369
+ },
1370
+ "scale_pitches": scale_pitches,
1371
+ "creative_guidance": guidance,
1372
+ "creative_seed": creative_seed,
1373
+ "anti_defaults": anti_defaults,
1374
+ "recommended_searches": recommended_searches,
1375
+ "optional_searches": optional_searches,
1376
+ "reference_artist": reference,
1377
+ "reference_searches": reference_searches,
1378
+ "instruments_by_role": instruments_by_role,
1379
+ "octaves_by_role": octaves_by_role,
1380
+ "excluded_recently_used": sorted(exclude_loaded_device_names or set()),
1381
+ "suggested_layer_count": len(suggested_roles),
1382
+ "suggested_roles": suggested_roles,
1383
+ "bars": bars,
1384
+ "fresh_project_state": fresh_project_state,
1385
+ "next_step": (
1386
+ "READ THIS BRIEF, THEN DESIGN A FRESH LAYER PLAN.\n"
1387
+ "1. Honor the creative_seed['directive'] — let it tilt your design.\n"
1388
+ "2. Honor the anti_defaults — DO NOT use the patterns listed on this call.\n"
1389
+ "3. **OCTAVE DISCIPLINE**: For tonal layers, design notes within the\n"
1390
+ " `octaves_by_role[role].midi_range`. Tonal-range traps to avoid:\n"
1391
+ " bass at A1=33 is sub-territory and inaudible/muddy on most systems —\n"
1392
+ " use A2=45 or higher. Pad at octave 6 is too thin — stay around\n"
1393
+ " octave 4-5. The brief gives you the exact ranges per role.\n"
1394
+ "4. **ANTI-REPEAT**: `excluded_recently_used` lists device names already\n"
1395
+ " loaded in the session — the candidate list ALREADY has them filtered\n"
1396
+ " out, so just pick from `instruments_by_role[role]` confidently. If\n"
1397
+ " the list looks short for a role, that's because we're forcing variety.\n"
1398
+ " **TIER A vs B distinction**: Each candidate in `instruments_by_role[role]`\n"
1399
+ " carries a `tier` field. Tier-A candidates ('A_sample_ready') load with\n"
1400
+ " sound on note-on. Tier-B candidates ('B_audible_default') also produce\n"
1401
+ " sound by default. Both are safe to pick. There is no Tier-C in the brief\n"
1402
+ " — those are filtered out before the brief reaches you.\n"
1403
+ "5. **TIER-1: Fire each search in `recommended_searches` BEFORE designing\n"
1404
+ " that role.** Each entry gives you a (tool, query) pair — call the named\n"
1405
+ " Ableton Knowledge MCP tool. Most queries hit the Live manual or video\n"
1406
+ " tutorials; capture 1 useful snippet per role and apply it.\n"
1407
+ "6. **TIER-2: If `reference_artist` is set**, also fire each search in\n"
1408
+ " `reference_searches` and design USING that artist's signature techniques.\n"
1409
+ "7. For each layer: pick ONE uri from instruments_by_role[role], design\n"
1410
+ " MIDI notes (start_time in beats, pitch, duration, velocity 0-127).\n"
1411
+ " Hit the articulation_targets — velocity stddev, ghost notes, etc.\n"
1412
+ "8. Pick ONE progression from creative_guidance.harmonic_palette.progressions\n"
1413
+ " (or invent your own with color tones).\n"
1414
+ "9. **PHASE B: EFFECT CHAIN PER LAYER** — for each layer, include an\n"
1415
+ " `effects` array of native Live devices (insert_device-compatible).\n"
1416
+ " `creative_guidance.effect_chain_hints[role]` gives you the canonical\n"
1417
+ " genre-typical chain: [{device: 'Saturator', params: {Drive: 0.4}}, ...]\n"
1418
+ " You can ADAPT values to fit the prompt mood (heavier in trap, subtler\n"
1419
+ " in ambient) — but anchor on the hint. Saturator/Compressor params are\n"
1420
+ " normalized 0-1; EQ Eight uses absolute Hz/dB. Leave params={} when\n"
1421
+ " you only want the device with no specific param overrides.\n"
1422
+ "10. **PHASE B: SENDS PER LAYER** — for layers that benefit from spatial\n"
1423
+ " depth (pad, vox, snare), include `sends`: [{return_name: 'A-Reverb',\n"
1424
+ " value: 0.25}]. `creative_guidance.send_hints[role]` gives you the\n"
1425
+ " starting point. Skip the layer if no return tracks exist (kicks rarely\n"
1426
+ " need send reverb).\n"
1427
+ "11. **TIER-1: Each layer in your plan MUST include `applied_technique`** —\n"
1428
+ " the snippet + source you used. compose_fast_apply will surface these.\n"
1429
+ "12. Submit the complete plan via compose_fast_apply(plan={layers:[...]}).\n"
1430
+ "13. NEVER use a template. Make every call genuinely different."
1431
+ ),
1432
+ "apply_schema_hint": {
1433
+ "layers": [
1434
+ {
1435
+ "role": "string (kick/snare/hat/perc/bass/pad/lead/atmos/etc.)",
1436
+ "uri": "atlas URI from instruments_by_role[role]",
1437
+ "track_name": "optional display name",
1438
+ "notes": [
1439
+ {"pitch": "int 0-127", "start_time": "float beats from clip start",
1440
+ "duration": "float beats", "velocity": "int 0-127"}
1441
+ ],
1442
+ "effects": [
1443
+ {"device": "native Live device name (e.g. Saturator, EQ Eight, Reverb)",
1444
+ "params": "dict of {parameter_name: value}; {} for none"}
1445
+ ],
1446
+ "sends": [
1447
+ {"return_name": "case-insensitive return track name (e.g. A-Reverb)",
1448
+ "value": "float 0-1"}
1449
+ ],
1450
+ "applied_technique": {
1451
+ "snippet": "the producer-voice snippet your design honored",
1452
+ "source": "video title or manual section",
1453
+ "source_url": "url if available",
1454
+ "applied_in": "how you applied it (e.g. '4-bar root sustain with sidechain LFO modulation')",
1455
+ },
1456
+ }
1457
+ ],
1458
+ "scene_index": "int or null (auto-pick)",
1459
+ "tempo": "int (overrides intent.tempo if set)",
1460
+ },
1461
+ }
1462
+
1463
+
1464
+ def _suggest_layer_roles(intent: CompositionIntent) -> list[str]:
1465
+ """Suggest which roles to fill based on genre, leaving the LLM free to
1466
+ pick fewer or rearrange. This is GUIDANCE, not prescription."""
1467
+ genre = (intent.genre or "").lower()
1468
+ if genre == "ambient":
1469
+ return ["pad", "atmos"]
1470
+ if genre == "trap":
1471
+ return ["kick", "snare", "hat", "bass"]
1472
+ if genre == "drum and bass":
1473
+ return ["kick", "snare", "hat", "bass"]
1474
+ if genre in ("dub techno", "techno"):
1475
+ return ["kick", "hat", "bass", "pad"]
1476
+ if genre in ("house", "hip hop", "lo-fi"):
1477
+ return ["kick", "snare", "hat", "bass", "pad"]
1478
+ # Default: a balanced 4-piece
1479
+ return ["kick", "hat", "bass", "pad"]