livepilot 1.9.13 → 1.9.15

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 (105) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +51 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +7 -7
  6. package/bin/livepilot.js +32 -8
  7. package/installer/install.js +21 -2
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
  11. package/livepilot/skills/livepilot-core/SKILL.md +81 -6
  12. package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
  13. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  14. package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
  15. package/livepilot/skills/livepilot-release/SKILL.md +13 -13
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +6 -3
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/curves.py +11 -3
  20. package/mcp_server/evaluation/__init__.py +1 -0
  21. package/mcp_server/evaluation/fabric.py +575 -0
  22. package/mcp_server/evaluation/feature_extractors.py +84 -0
  23. package/mcp_server/evaluation/policy.py +67 -0
  24. package/mcp_server/evaluation/tools.py +53 -0
  25. package/mcp_server/memory/__init__.py +11 -2
  26. package/mcp_server/memory/anti_memory.py +78 -0
  27. package/mcp_server/memory/promotion.py +94 -0
  28. package/mcp_server/memory/session_memory.py +108 -0
  29. package/mcp_server/memory/taste_memory.py +158 -0
  30. package/mcp_server/memory/technique_store.py +2 -1
  31. package/mcp_server/memory/tools.py +112 -0
  32. package/mcp_server/mix_engine/__init__.py +1 -0
  33. package/mcp_server/mix_engine/critics.py +299 -0
  34. package/mcp_server/mix_engine/models.py +152 -0
  35. package/mcp_server/mix_engine/planner.py +103 -0
  36. package/mcp_server/mix_engine/state_builder.py +316 -0
  37. package/mcp_server/mix_engine/tools.py +214 -0
  38. package/mcp_server/performance_engine/__init__.py +1 -0
  39. package/mcp_server/performance_engine/models.py +148 -0
  40. package/mcp_server/performance_engine/planner.py +267 -0
  41. package/mcp_server/performance_engine/safety.py +162 -0
  42. package/mcp_server/performance_engine/tools.py +183 -0
  43. package/mcp_server/project_brain/__init__.py +6 -0
  44. package/mcp_server/project_brain/arrangement_graph.py +64 -0
  45. package/mcp_server/project_brain/automation_graph.py +72 -0
  46. package/mcp_server/project_brain/builder.py +123 -0
  47. package/mcp_server/project_brain/capability_graph.py +64 -0
  48. package/mcp_server/project_brain/models.py +282 -0
  49. package/mcp_server/project_brain/refresh.py +80 -0
  50. package/mcp_server/project_brain/role_graph.py +103 -0
  51. package/mcp_server/project_brain/session_graph.py +51 -0
  52. package/mcp_server/project_brain/tools.py +144 -0
  53. package/mcp_server/reference_engine/__init__.py +1 -0
  54. package/mcp_server/reference_engine/gap_analyzer.py +239 -0
  55. package/mcp_server/reference_engine/models.py +105 -0
  56. package/mcp_server/reference_engine/profile_builder.py +149 -0
  57. package/mcp_server/reference_engine/tactic_router.py +117 -0
  58. package/mcp_server/reference_engine/tools.py +235 -0
  59. package/mcp_server/runtime/__init__.py +1 -0
  60. package/mcp_server/runtime/action_ledger.py +117 -0
  61. package/mcp_server/runtime/action_ledger_models.py +84 -0
  62. package/mcp_server/runtime/action_tools.py +57 -0
  63. package/mcp_server/runtime/capability_state.py +218 -0
  64. package/mcp_server/runtime/safety_kernel.py +339 -0
  65. package/mcp_server/runtime/safety_tools.py +42 -0
  66. package/mcp_server/runtime/tools.py +64 -0
  67. package/mcp_server/server.py +23 -1
  68. package/mcp_server/sound_design/__init__.py +1 -0
  69. package/mcp_server/sound_design/critics.py +297 -0
  70. package/mcp_server/sound_design/models.py +147 -0
  71. package/mcp_server/sound_design/planner.py +104 -0
  72. package/mcp_server/sound_design/tools.py +297 -0
  73. package/mcp_server/tools/_agent_os_engine.py +947 -0
  74. package/mcp_server/tools/_composition_engine.py +1530 -0
  75. package/mcp_server/tools/_conductor.py +199 -0
  76. package/mcp_server/tools/_conductor_budgets.py +222 -0
  77. package/mcp_server/tools/_evaluation_contracts.py +91 -0
  78. package/mcp_server/tools/_form_engine.py +416 -0
  79. package/mcp_server/tools/_motif_engine.py +351 -0
  80. package/mcp_server/tools/_planner_engine.py +516 -0
  81. package/mcp_server/tools/_research_engine.py +542 -0
  82. package/mcp_server/tools/_research_provider.py +185 -0
  83. package/mcp_server/tools/_snapshot_normalizer.py +49 -0
  84. package/mcp_server/tools/agent_os.py +440 -0
  85. package/mcp_server/tools/analyzer.py +18 -0
  86. package/mcp_server/tools/automation.py +25 -10
  87. package/mcp_server/tools/composition.py +563 -0
  88. package/mcp_server/tools/motif.py +104 -0
  89. package/mcp_server/tools/planner.py +144 -0
  90. package/mcp_server/tools/research.py +223 -0
  91. package/mcp_server/tools/tracks.py +18 -3
  92. package/mcp_server/tools/transport.py +10 -2
  93. package/mcp_server/transition_engine/__init__.py +6 -0
  94. package/mcp_server/transition_engine/archetypes.py +167 -0
  95. package/mcp_server/transition_engine/critics.py +340 -0
  96. package/mcp_server/transition_engine/models.py +90 -0
  97. package/mcp_server/transition_engine/tools.py +291 -0
  98. package/mcp_server/translation_engine/__init__.py +5 -0
  99. package/mcp_server/translation_engine/critics.py +297 -0
  100. package/mcp_server/translation_engine/models.py +27 -0
  101. package/mcp_server/translation_engine/tools.py +74 -0
  102. package/package.json +2 -2
  103. package/remote_script/LivePilot/__init__.py +1 -1
  104. package/remote_script/LivePilot/arrangement.py +12 -2
  105. package/requirements.txt +1 -1
@@ -0,0 +1,516 @@
1
+ """Planner Engine — loop-to-song arrangement planning and orchestration.
2
+
3
+ Transforms a single loop analysis into a full arrangement blueprint:
4
+ section sequence, element reveal order, gesture automation, and
5
+ orchestration plan.
6
+
7
+ Zero external dependencies beyond stdlib. The MCP tool wrappers in
8
+ planner.py handle data fetching; this module handles computation.
9
+
10
+ Design: spec at docs/specs/2026-04-08-phase2-4-roadmap.md, Round 3 (3.3).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import asdict, dataclass, field
16
+ from typing import Optional
17
+
18
+ from ._composition_engine import (
19
+ GestureIntent,
20
+ GesturePlan,
21
+ RoleNode,
22
+ RoleType,
23
+ SectionNode,
24
+ SectionType,
25
+ plan_gesture,
26
+ )
27
+
28
+
29
+ # ── Section Templates ────────────────────────────────────────────────
30
+
31
+ # Prototypical section sequences by style. Each entry:
32
+ # (section_type, energy_target, density_target, typical_bars)
33
+ STYLE_TEMPLATES: dict[str, list[tuple[SectionType, float, float, int]]] = {
34
+ "electronic": [
35
+ (SectionType.INTRO, 0.2, 0.2, 16),
36
+ (SectionType.VERSE, 0.5, 0.5, 16),
37
+ (SectionType.BUILD, 0.6, 0.6, 8),
38
+ (SectionType.DROP, 0.9, 0.9, 16),
39
+ (SectionType.BREAKDOWN, 0.3, 0.3, 8),
40
+ (SectionType.BUILD, 0.7, 0.7, 8),
41
+ (SectionType.DROP, 1.0, 1.0, 16),
42
+ (SectionType.OUTRO, 0.2, 0.2, 16),
43
+ ],
44
+ "hiphop": [
45
+ (SectionType.INTRO, 0.3, 0.3, 8),
46
+ (SectionType.VERSE, 0.6, 0.6, 16),
47
+ (SectionType.CHORUS, 0.8, 0.8, 8),
48
+ (SectionType.VERSE, 0.6, 0.6, 16),
49
+ (SectionType.CHORUS, 0.8, 0.8, 8),
50
+ (SectionType.BRIDGE, 0.5, 0.4, 8),
51
+ (SectionType.CHORUS, 0.9, 0.9, 8),
52
+ (SectionType.OUTRO, 0.3, 0.3, 8),
53
+ ],
54
+ "pop": [
55
+ (SectionType.INTRO, 0.3, 0.3, 8),
56
+ (SectionType.VERSE, 0.5, 0.5, 16),
57
+ (SectionType.PRE_CHORUS, 0.6, 0.6, 8),
58
+ (SectionType.CHORUS, 0.8, 0.8, 8),
59
+ (SectionType.VERSE, 0.5, 0.5, 16),
60
+ (SectionType.PRE_CHORUS, 0.6, 0.6, 8),
61
+ (SectionType.CHORUS, 0.9, 0.9, 8),
62
+ (SectionType.BRIDGE, 0.4, 0.4, 8),
63
+ (SectionType.CHORUS, 1.0, 1.0, 8),
64
+ (SectionType.OUTRO, 0.3, 0.3, 8),
65
+ ],
66
+ "ambient": [
67
+ (SectionType.INTRO, 0.1, 0.1, 32),
68
+ (SectionType.VERSE, 0.3, 0.3, 32),
69
+ (SectionType.VERSE, 0.5, 0.5, 32),
70
+ (SectionType.BREAKDOWN, 0.2, 0.2, 16),
71
+ (SectionType.VERSE, 0.4, 0.4, 32),
72
+ (SectionType.OUTRO, 0.1, 0.1, 32),
73
+ ],
74
+ "techno": [
75
+ (SectionType.INTRO, 0.3, 0.3, 16),
76
+ (SectionType.VERSE, 0.6, 0.6, 32),
77
+ (SectionType.BUILD, 0.7, 0.7, 8),
78
+ (SectionType.DROP, 1.0, 1.0, 32),
79
+ (SectionType.BREAKDOWN, 0.3, 0.3, 16),
80
+ (SectionType.BUILD, 0.8, 0.8, 8),
81
+ (SectionType.DROP, 1.0, 1.0, 32),
82
+ (SectionType.OUTRO, 0.3, 0.3, 16),
83
+ ],
84
+ }
85
+
86
+ VALID_STYLES = frozenset(STYLE_TEMPLATES.keys())
87
+
88
+
89
+ # ── Loop Identity ────────────────────────────────────────────────────
90
+
91
+ @dataclass
92
+ class LoopIdentity:
93
+ """What makes this loop recognizable — its core musical DNA."""
94
+ track_count: int
95
+ foreground_tracks: list[int] # track indices of lead/hook elements
96
+ rhythm_tracks: list[int] # kick, hats, perc
97
+ harmonic_tracks: list[int] # pads, chords, bass
98
+ texture_tracks: list[int] # fx, atmosphere
99
+ energy: float # 0-1
100
+ density: float # 0-1
101
+ estimated_bars: int
102
+
103
+ def to_dict(self) -> dict:
104
+ return asdict(self)
105
+
106
+
107
+ def analyze_loop_identity(
108
+ roles: list[RoleNode],
109
+ sections: list[SectionNode],
110
+ ) -> LoopIdentity:
111
+ """Identify the musical DNA of a loop from its role and section analysis.
112
+
113
+ Works with a single-section (loop) or multi-section input.
114
+ """
115
+ # Use first section as the loop
116
+ section = sections[0] if sections else None
117
+ track_count = len(section.tracks_active) if section else 0
118
+ energy = section.energy if section else 0.5
119
+ density = section.density if section else 0.5
120
+ bars = section.length_bars() if section else 8
121
+
122
+ # Classify tracks by role
123
+ foreground = []
124
+ rhythm = []
125
+ harmonic = []
126
+ texture = []
127
+
128
+ section_id = section.section_id if section else ""
129
+ for role in roles:
130
+ if role.section_id != section_id:
131
+ continue
132
+ if role.role in (RoleType.LEAD, RoleType.HOOK):
133
+ foreground.append(role.track_index)
134
+ elif role.role in (RoleType.KICK_ANCHOR, RoleType.RHYTHMIC_TEXTURE):
135
+ rhythm.append(role.track_index)
136
+ elif role.role in (RoleType.BASS_ANCHOR, RoleType.HARMONY_BED):
137
+ harmonic.append(role.track_index)
138
+ elif role.role in (RoleType.TEXTURE_WASH, RoleType.TRANSITION_FX):
139
+ texture.append(role.track_index)
140
+
141
+ return LoopIdentity(
142
+ track_count=track_count,
143
+ foreground_tracks=foreground,
144
+ rhythm_tracks=rhythm,
145
+ harmonic_tracks=harmonic,
146
+ texture_tracks=texture,
147
+ energy=energy,
148
+ density=density,
149
+ estimated_bars=bars,
150
+ )
151
+
152
+
153
+ # ── Arrangement Plan ─────────────────────────────────────────────────
154
+
155
+ @dataclass
156
+ class SectionPlan:
157
+ """Planned section in the arrangement."""
158
+ section_type: SectionType
159
+ start_bar: int
160
+ end_bar: int
161
+ energy_target: float
162
+ density_target: float
163
+ tracks_active: list[int] # which tracks should play
164
+ tracks_entering: list[int] # new elements introduced in this section
165
+ tracks_exiting: list[int] # elements removed in this section
166
+
167
+ def length_bars(self) -> int:
168
+ return self.end_bar - self.start_bar
169
+
170
+ def to_dict(self) -> dict:
171
+ d = asdict(self)
172
+ d["section_type"] = self.section_type.value
173
+ d["length_bars"] = self.length_bars()
174
+ return d
175
+
176
+
177
+ @dataclass
178
+ class ArrangementPlan:
179
+ """Full arrangement blueprint from a loop."""
180
+ style: str
181
+ total_bars: int
182
+ sections: list[SectionPlan]
183
+ gesture_plan: list[dict] # gesture template suggestions per transition
184
+ reveal_order: list[dict] # [{track_index, enters_at_section, role}]
185
+ notes: list[str]
186
+
187
+ def to_dict(self) -> dict:
188
+ return {
189
+ "style": self.style,
190
+ "total_bars": self.total_bars,
191
+ "sections": [s.to_dict() for s in self.sections],
192
+ "section_count": len(self.sections),
193
+ "gesture_plan": self.gesture_plan,
194
+ "reveal_order": self.reveal_order,
195
+ "notes": self.notes,
196
+ }
197
+
198
+
199
+ # ── Core Planner ─────────────────────────────────────────────────────
200
+
201
+ def plan_arrangement_from_loop(
202
+ loop_identity: LoopIdentity,
203
+ target_duration_bars: int = 128,
204
+ style: str = "electronic",
205
+ ) -> ArrangementPlan:
206
+ """Transform a loop identity into a full arrangement blueprint.
207
+
208
+ 1. Select section template based on style
209
+ 2. Scale section lengths to target duration
210
+ 3. Plan element reveal order (what enters/exits when)
211
+ 4. Suggest gesture automation for transitions
212
+ """
213
+ if style not in STYLE_TEMPLATES:
214
+ raise ValueError(f"Unknown style '{style}'. Valid: {sorted(VALID_STYLES)}")
215
+
216
+ template = STYLE_TEMPLATES[style]
217
+
218
+ # 1. Scale sections to target duration
219
+ template_bars = sum(s[3] for s in template)
220
+ scale_factor = target_duration_bars / template_bars
221
+
222
+ sections: list[SectionPlan] = []
223
+ current_bar = 0
224
+
225
+ for stype, energy_target, density_target, base_bars in template:
226
+ # Scale but keep bar counts as multiples of 4
227
+ scaled_bars = max(4, round(base_bars * scale_factor / 4) * 4)
228
+ end_bar = current_bar + scaled_bars
229
+
230
+ sections.append(SectionPlan(
231
+ section_type=stype,
232
+ start_bar=current_bar,
233
+ end_bar=end_bar,
234
+ energy_target=energy_target,
235
+ density_target=density_target,
236
+ tracks_active=[], # Filled in by reveal planning
237
+ tracks_entering=[],
238
+ tracks_exiting=[],
239
+ ))
240
+ current_bar = end_bar
241
+
242
+ # 2. Plan element reveal order
243
+ all_tracks = (
244
+ loop_identity.rhythm_tracks
245
+ + loop_identity.harmonic_tracks
246
+ + loop_identity.foreground_tracks
247
+ + loop_identity.texture_tracks
248
+ )
249
+
250
+ reveal_order = _plan_reveal_order(
251
+ sections, loop_identity, all_tracks,
252
+ )
253
+
254
+ # Apply reveal order to section active tracks
255
+ active_set: set[int] = set()
256
+ for i, section in enumerate(sections):
257
+ # Add entering tracks
258
+ for entry in reveal_order:
259
+ if entry["enters_at_section"] == i:
260
+ active_set.add(entry["track_index"])
261
+ section.tracks_entering.append(entry["track_index"])
262
+
263
+ # Remove exiting tracks (for breakdowns/bridges)
264
+ if section.section_type in (SectionType.BREAKDOWN, SectionType.BRIDGE):
265
+ # Keep only rhythm + harmony (strip foreground + texture)
266
+ to_remove = [t for t in active_set
267
+ if t in loop_identity.foreground_tracks
268
+ or t in loop_identity.texture_tracks]
269
+ for t in to_remove:
270
+ active_set.discard(t)
271
+ section.tracks_exiting.append(t)
272
+
273
+ section.tracks_active = sorted(active_set)
274
+
275
+ # 3. Suggest gesture templates for transitions
276
+ gesture_plan = _plan_transition_gestures(sections)
277
+
278
+ # 4. Notes
279
+ total_bars = sections[-1].end_bar if sections else 0
280
+ notes = []
281
+ if total_bars != target_duration_bars:
282
+ notes.append(f"Actual duration: {total_bars} bars (target was {target_duration_bars})")
283
+ if not loop_identity.foreground_tracks:
284
+ notes.append("No clear foreground element detected — consider adding a lead/hook")
285
+ if not loop_identity.rhythm_tracks:
286
+ notes.append("No rhythm tracks detected — arrangement may feel floaty without groove")
287
+
288
+ return ArrangementPlan(
289
+ style=style,
290
+ total_bars=total_bars,
291
+ sections=sections,
292
+ gesture_plan=gesture_plan,
293
+ reveal_order=reveal_order,
294
+ notes=notes,
295
+ )
296
+
297
+
298
+ def _plan_reveal_order(
299
+ sections: list[SectionPlan],
300
+ loop_identity: LoopIdentity,
301
+ all_tracks: list[int],
302
+ ) -> list[dict]:
303
+ """Plan when each track enters the arrangement.
304
+
305
+ Strategy: stagger element introductions for maximum impact.
306
+ - Intro: minimal (rhythm only, or partial rhythm)
307
+ - First verse/section: add bass + harmony
308
+ - Build/pre-chorus: add texture
309
+ - Drop/chorus: full reveal (foreground enters)
310
+ """
311
+ if not all_tracks or not sections:
312
+ return []
313
+
314
+ reveal: list[dict] = []
315
+ assigned: set[int] = set()
316
+
317
+ # Group tracks by priority
318
+ groups = [
319
+ ("rhythm_foundation", loop_identity.rhythm_tracks[:2]), # Kick + one more
320
+ ("harmonic_base", loop_identity.harmonic_tracks[:2]), # Bass + pad
321
+ ("texture_layer", loop_identity.texture_tracks),
322
+ ("foreground_reveal", loop_identity.foreground_tracks),
323
+ ("remaining_rhythm", loop_identity.rhythm_tracks[2:]),
324
+ ("remaining_harmony", loop_identity.harmonic_tracks[2:]),
325
+ ]
326
+
327
+ # Map groups to section types (when they should enter)
328
+ entry_map: dict[str, list[SectionType]] = {
329
+ "rhythm_foundation": [SectionType.INTRO],
330
+ "harmonic_base": [SectionType.VERSE],
331
+ "texture_layer": [SectionType.BUILD, SectionType.PRE_CHORUS, SectionType.VERSE],
332
+ "foreground_reveal": [SectionType.DROP, SectionType.CHORUS],
333
+ "remaining_rhythm": [SectionType.VERSE, SectionType.BUILD],
334
+ "remaining_harmony": [SectionType.BUILD, SectionType.PRE_CHORUS],
335
+ }
336
+
337
+ for group_name, tracks in groups:
338
+ target_types = entry_map.get(group_name, [SectionType.VERSE])
339
+
340
+ # Find first section matching target type
341
+ target_section_idx = 0
342
+ for i, section in enumerate(sections):
343
+ if section.section_type in target_types:
344
+ target_section_idx = i
345
+ break
346
+
347
+ for track in tracks:
348
+ if track in assigned:
349
+ continue
350
+ reveal.append({
351
+ "track_index": track,
352
+ "enters_at_section": target_section_idx,
353
+ "group": group_name,
354
+ })
355
+ assigned.add(track)
356
+
357
+ # Any remaining unassigned tracks enter at section 1
358
+ for track in all_tracks:
359
+ if track not in assigned:
360
+ reveal.append({
361
+ "track_index": track,
362
+ "enters_at_section": min(1, len(sections) - 1),
363
+ "group": "unassigned",
364
+ })
365
+
366
+ return reveal
367
+
368
+
369
+ def _plan_transition_gestures(sections: list[SectionPlan]) -> list[dict]:
370
+ """Suggest gesture templates for each section transition."""
371
+ gestures = []
372
+
373
+ for i in range(1, len(sections)):
374
+ prev = sections[i - 1]
375
+ curr = sections[i]
376
+
377
+ suggestion = {
378
+ "transition": f"{prev.section_type.value} → {curr.section_type.value}",
379
+ "boundary_bar": curr.start_bar,
380
+ "templates": [],
381
+ }
382
+
383
+ # Select templates based on transition type
384
+ energy_increase = curr.energy_target > prev.energy_target + 0.15
385
+
386
+ if curr.section_type in (SectionType.DROP, SectionType.CHORUS) and energy_increase:
387
+ suggestion["templates"].append("pre_arrival_vacuum")
388
+ if curr.tracks_entering:
389
+ suggestion["templates"].append("re_entry_spotlight")
390
+
391
+ elif curr.section_type == SectionType.BUILD:
392
+ suggestion["templates"].append("tension_ratchet")
393
+
394
+ elif curr.section_type in (SectionType.BREAKDOWN, SectionType.BRIDGE):
395
+ suggestion["templates"].append("sectional_width_bloom")
396
+
397
+ elif curr.section_type == SectionType.OUTRO:
398
+ suggestion["templates"].append("outro_decay_dissolve")
399
+
400
+ elif curr.section_type == SectionType.VERSE and prev.section_type == SectionType.CHORUS:
401
+ suggestion["templates"].append("turnaround_accent")
402
+
403
+ else:
404
+ # Generic transition
405
+ if energy_increase:
406
+ suggestion["templates"].append("harmonic_tint_rise")
407
+ else:
408
+ suggestion["templates"].append("phrase_end_throw")
409
+
410
+ gestures.append(suggestion)
411
+
412
+ return gestures
413
+
414
+
415
+ # ── Orchestration Planner (Round 4) ──────────────────────────────────
416
+
417
+ def plan_orchestration(
418
+ sections: list[SectionNode],
419
+ roles: list[RoleNode],
420
+ motif_count: int = 0,
421
+ ) -> dict:
422
+ """Plan which instruments play in which sections across the full arrangement.
423
+
424
+ Prevents "everything plays everywhere" syndrome by enforcing:
425
+ - No more than 3 foreground voices per section
426
+ - Bass + kick always paired
427
+ - Textures rotate
428
+ - Hook appears in chorus but not every verse
429
+
430
+ Returns: {section_id: {track_index: "active"|"silent"|"reduced"}}
431
+ """
432
+ if not sections:
433
+ return {"orchestration": {}, "notes": []}
434
+
435
+ orchestration: dict[str, dict[int, str]] = {}
436
+ notes: list[str] = []
437
+
438
+ # Collect all track indices and their roles
439
+ all_tracks: set[int] = set()
440
+ role_map: dict[int, RoleType] = {}
441
+ for r in roles:
442
+ all_tracks.add(r.track_index)
443
+ role_map[r.track_index] = r.role
444
+
445
+ # Group tracks by role type
446
+ kick_tracks = [t for t, r in role_map.items() if r == RoleType.KICK_ANCHOR]
447
+ bass_tracks = [t for t, r in role_map.items() if r == RoleType.BASS_ANCHOR]
448
+ lead_tracks = [t for t, r in role_map.items() if r in (RoleType.LEAD, RoleType.HOOK)]
449
+ harmony_tracks = [t for t, r in role_map.items() if r == RoleType.HARMONY_BED]
450
+ texture_tracks = [t for t, r in role_map.items() if r == RoleType.TEXTURE_WASH]
451
+ rhythm_tracks = [t for t, r in role_map.items() if r == RoleType.RHYTHMIC_TEXTURE]
452
+
453
+ for section in sections:
454
+ section_orch: dict[int, str] = {}
455
+ stype = section.section_type
456
+
457
+ for track in all_tracks:
458
+ role = role_map.get(track, RoleType.UNKNOWN)
459
+
460
+ # Default: active
461
+ status = "active"
462
+
463
+ # Rule 1: Intros are sparse
464
+ if stype == SectionType.INTRO:
465
+ if role in (RoleType.LEAD, RoleType.HOOK):
466
+ status = "silent"
467
+ elif role == RoleType.TEXTURE_WASH:
468
+ status = "reduced"
469
+
470
+ # Rule 2: Breakdowns strip foreground
471
+ elif stype in (SectionType.BREAKDOWN, SectionType.BRIDGE):
472
+ if role in (RoleType.LEAD, RoleType.HOOK):
473
+ status = "silent"
474
+ elif role == RoleType.TEXTURE_WASH:
475
+ status = "active" # Textures shine in breakdowns
476
+
477
+ # Rule 3: Outros thin out
478
+ elif stype == SectionType.OUTRO:
479
+ if role in (RoleType.LEAD, RoleType.HOOK):
480
+ status = "reduced"
481
+ elif role == RoleType.RHYTHMIC_TEXTURE:
482
+ status = "reduced"
483
+
484
+ # Rule 4: Builds add tension elements but not full foreground
485
+ elif stype == SectionType.BUILD:
486
+ if role == RoleType.HOOK:
487
+ status = "silent" # Save hook for drop
488
+
489
+ # Rule 5: Bass+kick pairing
490
+ if track in bass_tracks and not any(
491
+ section_orch.get(k) == "active" for k in kick_tracks
492
+ ):
493
+ # If no kick is active yet, bass is fine; they'll pair naturally
494
+ pass
495
+
496
+ section_orch[track] = status
497
+
498
+ # Rule 6: Cap foreground at 3
499
+ active_fg = [t for t in lead_tracks
500
+ if section_orch.get(t) == "active"]
501
+ if len(active_fg) > 3:
502
+ for t in active_fg[3:]:
503
+ section_orch[t] = "reduced"
504
+ notes.append(
505
+ f"Section '{section.name or section.section_id}': "
506
+ f"capped foreground from {len(active_fg)} to 3"
507
+ )
508
+
509
+ orchestration[section.section_id] = section_orch
510
+
511
+ return {
512
+ "orchestration": orchestration,
513
+ "section_count": len(sections),
514
+ "track_count": len(all_tracks),
515
+ "notes": notes,
516
+ }