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