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
|
@@ -1,467 +1,21 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Backward-compat shim — layer_planner has moved to full/layer_planner.py.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Existing code that imports from ``mcp_server.composer.layer_planner`` continues
|
|
4
|
+
to work unchanged. New code should import from
|
|
5
|
+
``mcp_server.composer.full.layer_planner`` directly.
|
|
5
6
|
"""
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
_ROLE_TEMPLATES: dict[str, dict] = {
|
|
50
|
-
"drums": {
|
|
51
|
-
"query_template": "{genre} drums {tempo}bpm",
|
|
52
|
-
"sample_type": "loop",
|
|
53
|
-
"technique_id": "slice_and_sequence",
|
|
54
|
-
"processing": [
|
|
55
|
-
{"name": "EQ Eight", "params": {"1 Filter Type A": "highpass", "1 Frequency A": 30.0}},
|
|
56
|
-
{"name": "Compressor", "params": {"Threshold": -12.0, "Ratio": 4.0}},
|
|
57
|
-
],
|
|
58
|
-
"volume_db": -3.0,
|
|
59
|
-
"pan": 0.0,
|
|
60
|
-
"priority": 1,
|
|
61
|
-
},
|
|
62
|
-
"bass": {
|
|
63
|
-
"query_template": "{genre} bass {key} oneshot",
|
|
64
|
-
"sample_type": "oneshot",
|
|
65
|
-
"technique_id": "key_matched_layer",
|
|
66
|
-
"processing": [
|
|
67
|
-
{"name": "Saturator", "params": {"Drive": 6.0}},
|
|
68
|
-
{"name": "EQ Eight", "params": {"1 Filter Type A": "highpass", "1 Frequency A": 30.0}},
|
|
69
|
-
],
|
|
70
|
-
"volume_db": -5.0,
|
|
71
|
-
"pan": 0.0,
|
|
72
|
-
"priority": 2,
|
|
73
|
-
},
|
|
74
|
-
"lead": {
|
|
75
|
-
"query_template": "{genre} {mood} melody {key}",
|
|
76
|
-
"sample_type": "loop",
|
|
77
|
-
"technique_id": "counterpoint_from_chops",
|
|
78
|
-
"processing": [
|
|
79
|
-
{"name": "Auto Filter", "params": {"Frequency": 2000.0, "Resonance": 0.3}},
|
|
80
|
-
{"name": "Delay", "params": {"Feedback": 0.35}},
|
|
81
|
-
],
|
|
82
|
-
"volume_db": -6.0,
|
|
83
|
-
"pan": 0.0,
|
|
84
|
-
"priority": 4,
|
|
85
|
-
},
|
|
86
|
-
"pad": {
|
|
87
|
-
"query_template": "{mood} pad {key}",
|
|
88
|
-
"sample_type": "loop",
|
|
89
|
-
"technique_id": "extreme_stretch",
|
|
90
|
-
"processing": [
|
|
91
|
-
{"name": "Reverb", "params": {"Decay Time": 4.0, "Dry/Wet": 0.6}},
|
|
92
|
-
{"name": "Chorus-Ensemble", "params": {"Rate 1": 0.5}},
|
|
93
|
-
],
|
|
94
|
-
"volume_db": -10.0,
|
|
95
|
-
"pan": 0.0,
|
|
96
|
-
"priority": 5,
|
|
97
|
-
},
|
|
98
|
-
"texture": {
|
|
99
|
-
"query_template": "{mood} texture ambient",
|
|
100
|
-
"sample_type": "loop",
|
|
101
|
-
"technique_id": "granular_scatter",
|
|
102
|
-
"processing": [
|
|
103
|
-
{"name": "Grain Delay", "params": {"Frequency": 1000.0, "Dry/Wet": 0.5}},
|
|
104
|
-
{"name": "Reverb", "params": {"Decay Time": 6.0, "Dry/Wet": 0.7}},
|
|
105
|
-
],
|
|
106
|
-
"volume_db": -15.0,
|
|
107
|
-
"pan": 0.0,
|
|
108
|
-
"priority": 6,
|
|
109
|
-
},
|
|
110
|
-
"vocal": {
|
|
111
|
-
"query_template": "vocal {mood} {key}",
|
|
112
|
-
"sample_type": "loop",
|
|
113
|
-
"technique_id": "vocal_chop_rhythm",
|
|
114
|
-
"processing": [
|
|
115
|
-
{"name": "Auto Filter", "params": {"Frequency": 3000.0}},
|
|
116
|
-
{"name": "Reverb", "params": {"Decay Time": 2.5, "Dry/Wet": 0.4}},
|
|
117
|
-
],
|
|
118
|
-
"volume_db": -8.0,
|
|
119
|
-
"pan": 0.0,
|
|
120
|
-
"priority": 7,
|
|
121
|
-
},
|
|
122
|
-
"percussion": {
|
|
123
|
-
"query_template": "{genre} percussion loop",
|
|
124
|
-
"sample_type": "loop",
|
|
125
|
-
"technique_id": "ghost_note_texture",
|
|
126
|
-
"processing": [
|
|
127
|
-
{"name": "EQ Eight", "params": {"1 Filter Type A": "highpass", "1 Frequency A": 200.0}},
|
|
128
|
-
{"name": "Compressor", "params": {"Threshold": -15.0, "Ratio": 3.0}},
|
|
129
|
-
],
|
|
130
|
-
"volume_db": -12.0,
|
|
131
|
-
"pan": 0.0,
|
|
132
|
-
"priority": 3,
|
|
133
|
-
},
|
|
134
|
-
"fx": {
|
|
135
|
-
"query_template": "{genre} riser fx",
|
|
136
|
-
"sample_type": "oneshot",
|
|
137
|
-
"technique_id": "one_sample_challenge",
|
|
138
|
-
"processing": [],
|
|
139
|
-
"volume_db": -6.0,
|
|
140
|
-
"pan": 0.0,
|
|
141
|
-
"priority": 8,
|
|
142
|
-
},
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
# ── Role Selection per Genre + Energy ──────────────────────────────
|
|
147
|
-
# Define which roles appear at different energy levels per genre.
|
|
148
|
-
|
|
149
|
-
_GENRE_ROLE_PRIORITY: dict[str, list[str]] = {
|
|
150
|
-
# Roles listed in order of priority (first added, last dropped)
|
|
151
|
-
"techno": ["drums", "bass", "percussion", "lead", "texture", "vocal", "fx"],
|
|
152
|
-
"house": ["drums", "bass", "lead", "pad", "vocal", "texture"],
|
|
153
|
-
"hip hop": ["drums", "bass", "lead", "vocal", "texture", "fx"],
|
|
154
|
-
"ambient": ["pad", "texture", "vocal", "lead", "percussion"],
|
|
155
|
-
"drum and bass": ["drums", "bass", "lead", "percussion", "texture", "vocal", "fx"],
|
|
156
|
-
"trap": ["drums", "bass", "lead", "vocal", "fx", "texture"],
|
|
157
|
-
"lo-fi": ["drums", "bass", "pad", "texture", "vocal"],
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
_DEFAULT_ROLE_PRIORITY = ["drums", "bass", "lead", "pad", "texture", "vocal", "percussion", "fx"]
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
# ── Section Templates ──────────────────────────────────────────────
|
|
164
|
-
# Each section: name, bar count, which roles play (with optional volume offset)
|
|
165
|
-
|
|
166
|
-
SECTION_TEMPLATES: dict[str, list[dict]] = {
|
|
167
|
-
"techno": [
|
|
168
|
-
{"name": "Intro", "bars": 8, "layers": ["drums:-6dB", "texture"]},
|
|
169
|
-
{"name": "Build", "bars": 8, "layers": ["drums", "bass", "percussion"]},
|
|
170
|
-
{"name": "Drop", "bars": 16, "layers": ["drums", "bass", "lead", "percussion", "texture"]},
|
|
171
|
-
{"name": "Breakdown", "bars": 8, "layers": ["texture", "vocal", "pad"]},
|
|
172
|
-
{"name": "Drop 2", "bars": 16, "layers": ["drums", "bass", "lead", "percussion", "vocal", "texture"]},
|
|
173
|
-
{"name": "Outro", "bars": 8, "layers": ["drums:-6dB", "texture", "pad"]},
|
|
174
|
-
],
|
|
175
|
-
# Dub techno — continuous-evolution aesthetic. No Drop structure, no
|
|
176
|
-
# Build/Break cycle. Section names reflect dub-techno arrangement idioms:
|
|
177
|
-
# slow reveal, subtraction before addition, return deeper not louder.
|
|
178
|
-
# Source: concepts/artists/basic-channel.yaml arrangement_idioms +
|
|
179
|
-
# live-verification finding from v1.18.0 CHANGELOG #2. v1.18.1 #2 fix.
|
|
180
|
-
"dub techno": [
|
|
181
|
-
{"name": "Dawn", "bars": 16, "layers": ["texture:-12dB"]},
|
|
182
|
-
{"name": "Pulse", "bars": 16, "layers": ["drums:-6dB", "texture"]},
|
|
183
|
-
{"name": "Chord", "bars": 32, "layers": ["drums", "bass", "pad:-6dB", "texture"]},
|
|
184
|
-
{"name": "Depth", "bars": 32, "layers": ["drums", "bass", "pad", "texture"]},
|
|
185
|
-
{"name": "Withdraw", "bars": 16, "layers": ["pad:-6dB", "texture:-6dB"]},
|
|
186
|
-
{"name": "Return", "bars": 16, "layers": ["texture:-12dB"]},
|
|
187
|
-
],
|
|
188
|
-
"house": [
|
|
189
|
-
{"name": "Intro", "bars": 8, "layers": ["drums:-6dB", "pad"]},
|
|
190
|
-
{"name": "Verse", "bars": 16, "layers": ["drums", "bass", "pad"]},
|
|
191
|
-
{"name": "Drop", "bars": 16, "layers": ["drums", "bass", "lead", "vocal"]},
|
|
192
|
-
{"name": "Breakdown", "bars": 8, "layers": ["pad", "texture", "vocal"]},
|
|
193
|
-
{"name": "Drop 2", "bars": 16, "layers": ["drums", "bass", "lead", "vocal", "texture"]},
|
|
194
|
-
{"name": "Outro", "bars": 8, "layers": ["drums:-6dB", "pad"]},
|
|
195
|
-
],
|
|
196
|
-
"hip hop": [
|
|
197
|
-
{"name": "Intro", "bars": 4, "layers": ["texture"]},
|
|
198
|
-
{"name": "Verse", "bars": 16, "layers": ["drums", "bass", "texture"]},
|
|
199
|
-
{"name": "Hook", "bars": 8, "layers": ["drums", "bass", "lead", "vocal"]},
|
|
200
|
-
{"name": "Verse 2", "bars": 16, "layers": ["drums", "bass", "percussion", "texture"]},
|
|
201
|
-
{"name": "Hook 2", "bars": 8, "layers": ["drums", "bass", "lead", "vocal", "fx"]},
|
|
202
|
-
{"name": "Outro", "bars": 4, "layers": ["texture", "vocal:-10dB"]},
|
|
203
|
-
],
|
|
204
|
-
"ambient": [
|
|
205
|
-
{"name": "Opening", "bars": 16, "layers": ["pad", "texture"]},
|
|
206
|
-
{"name": "Evolve", "bars": 16, "layers": ["pad", "texture", "vocal"]},
|
|
207
|
-
{"name": "Peak", "bars": 16, "layers": ["pad", "texture", "vocal", "lead"]},
|
|
208
|
-
{"name": "Dissolve", "bars": 16, "layers": ["pad", "texture"]},
|
|
209
|
-
],
|
|
210
|
-
"drum and bass": [
|
|
211
|
-
{"name": "Intro", "bars": 8, "layers": ["texture", "percussion:-6dB"]},
|
|
212
|
-
{"name": "Build", "bars": 8, "layers": ["drums:-6dB", "bass", "percussion"]},
|
|
213
|
-
{"name": "Drop", "bars": 16, "layers": ["drums", "bass", "lead", "percussion", "texture"]},
|
|
214
|
-
{"name": "Breakdown", "bars": 8, "layers": ["texture", "vocal", "pad"]},
|
|
215
|
-
{"name": "Drop 2", "bars": 16, "layers": ["drums", "bass", "lead", "percussion", "vocal", "fx"]},
|
|
216
|
-
{"name": "Outro", "bars": 8, "layers": ["drums:-6dB", "texture"]},
|
|
217
|
-
],
|
|
218
|
-
"trap": [
|
|
219
|
-
{"name": "Intro", "bars": 4, "layers": ["texture"]},
|
|
220
|
-
{"name": "Verse", "bars": 16, "layers": ["drums", "bass", "texture"]},
|
|
221
|
-
{"name": "Drop", "bars": 8, "layers": ["drums", "bass", "lead", "vocal", "fx"]},
|
|
222
|
-
{"name": "Verse 2", "bars": 16, "layers": ["drums", "bass", "texture", "vocal"]},
|
|
223
|
-
{"name": "Drop 2", "bars": 8, "layers": ["drums", "bass", "lead", "vocal", "fx"]},
|
|
224
|
-
{"name": "Outro", "bars": 4, "layers": ["texture:-6dB"]},
|
|
225
|
-
],
|
|
226
|
-
"lo-fi": [
|
|
227
|
-
{"name": "Intro", "bars": 4, "layers": ["texture", "pad"]},
|
|
228
|
-
{"name": "A", "bars": 16, "layers": ["drums", "bass", "pad", "texture"]},
|
|
229
|
-
{"name": "B", "bars": 16, "layers": ["drums", "bass", "pad", "vocal"]},
|
|
230
|
-
{"name": "A2", "bars": 16, "layers": ["drums", "bass", "pad", "texture"]},
|
|
231
|
-
{"name": "Outro", "bars": 8, "layers": ["pad", "texture"]},
|
|
232
|
-
],
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
# Fallback template for unknown genres
|
|
236
|
-
_DEFAULT_SECTION_TEMPLATE: list[dict] = [
|
|
237
|
-
{"name": "Intro", "bars": 8, "layers": ["texture"]},
|
|
238
|
-
{"name": "Build", "bars": 8, "layers": ["drums", "bass"]},
|
|
239
|
-
{"name": "Main", "bars": 16, "layers": ["drums", "bass", "lead", "texture"]},
|
|
240
|
-
{"name": "Breakdown", "bars": 8, "layers": ["pad", "texture"]},
|
|
241
|
-
{"name": "Main 2", "bars": 16, "layers": ["drums", "bass", "lead", "vocal", "texture"]},
|
|
242
|
-
{"name": "Outro", "bars": 8, "layers": ["drums:-6dB", "texture"]},
|
|
243
|
-
]
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
# ── Planner Functions ──────────────────────────────────────────────
|
|
247
|
-
|
|
248
|
-
def _build_search_query(template: str, intent: CompositionIntent) -> str:
|
|
249
|
-
"""Fill a query template with intent fields."""
|
|
250
|
-
return template.format(
|
|
251
|
-
genre=intent.genre or "electronic",
|
|
252
|
-
mood=intent.mood or "",
|
|
253
|
-
key=intent.key or "",
|
|
254
|
-
tempo=intent.tempo or 120,
|
|
255
|
-
).strip()
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def _build_splice_filters(
|
|
259
|
-
intent: CompositionIntent,
|
|
260
|
-
sample_type: str,
|
|
261
|
-
) -> dict:
|
|
262
|
-
"""Build Splice filter dict from intent."""
|
|
263
|
-
filters: dict = {}
|
|
264
|
-
|
|
265
|
-
# Key → Splice format (lowercase root, separate chord_type)
|
|
266
|
-
if intent.key:
|
|
267
|
-
key = intent.key
|
|
268
|
-
if key.endswith("m") and len(key) >= 2:
|
|
269
|
-
root = key[:-1].lower()
|
|
270
|
-
filters["chord_type"] = "minor"
|
|
271
|
-
else:
|
|
272
|
-
root = key.lower()
|
|
273
|
-
filters["chord_type"] = "major"
|
|
274
|
-
filters["key"] = root
|
|
275
|
-
|
|
276
|
-
# BPM range (+-5)
|
|
277
|
-
if intent.tempo:
|
|
278
|
-
filters["bpm_min"] = max(1, intent.tempo - 5)
|
|
279
|
-
filters["bpm_max"] = intent.tempo + 5
|
|
280
|
-
|
|
281
|
-
if intent.genre:
|
|
282
|
-
filters["genre"] = intent.genre
|
|
283
|
-
|
|
284
|
-
if sample_type:
|
|
285
|
-
filters["sample_type"] = sample_type
|
|
286
|
-
|
|
287
|
-
return filters
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def _select_roles(intent: CompositionIntent) -> list[str]:
|
|
291
|
-
"""Select which roles to include based on genre, energy, and explicit elements."""
|
|
292
|
-
role_priority = _GENRE_ROLE_PRIORITY.get(intent.genre, _DEFAULT_ROLE_PRIORITY)
|
|
293
|
-
|
|
294
|
-
# How many layers to pick
|
|
295
|
-
count = intent.layer_count or 5
|
|
296
|
-
|
|
297
|
-
# Start with the top N roles by priority
|
|
298
|
-
roles = list(role_priority[:count])
|
|
299
|
-
|
|
300
|
-
# Add any explicitly requested elements as roles
|
|
301
|
-
element_to_role = {
|
|
302
|
-
"vocal": "vocal",
|
|
303
|
-
"808": "bass",
|
|
304
|
-
"bass": "bass",
|
|
305
|
-
"drums": "drums",
|
|
306
|
-
"percussion": "percussion",
|
|
307
|
-
"pad": "pad",
|
|
308
|
-
"texture": "texture",
|
|
309
|
-
"fx": "fx",
|
|
310
|
-
"strings": "pad", # strings map to pad role
|
|
311
|
-
"piano": "lead", # piano maps to lead role
|
|
312
|
-
"guitar": "lead",
|
|
313
|
-
"brass": "lead",
|
|
314
|
-
"synth": "lead",
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
for element in intent.explicit_elements:
|
|
318
|
-
role = element_to_role.get(element)
|
|
319
|
-
if role and role not in roles:
|
|
320
|
-
roles.append(role)
|
|
321
|
-
|
|
322
|
-
return roles
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def plan_layers(intent: CompositionIntent) -> list[LayerSpec]:
|
|
326
|
-
"""Convert a CompositionIntent into a list of LayerSpec.
|
|
327
|
-
|
|
328
|
-
Each LayerSpec describes one track to create: what to search for,
|
|
329
|
-
which technique to use, processing chain, and mix settings.
|
|
330
|
-
"""
|
|
331
|
-
roles = _select_roles(intent)
|
|
332
|
-
sections = plan_sections(intent)
|
|
333
|
-
section_names = [s["name"] for s in sections]
|
|
334
|
-
|
|
335
|
-
layers: list[LayerSpec] = []
|
|
336
|
-
|
|
337
|
-
for role in roles:
|
|
338
|
-
template = _ROLE_TEMPLATES.get(role)
|
|
339
|
-
if not template:
|
|
340
|
-
continue
|
|
341
|
-
|
|
342
|
-
# Build search query
|
|
343
|
-
query = _build_search_query(template["query_template"], intent)
|
|
344
|
-
|
|
345
|
-
# Add descriptors to query for richer searches
|
|
346
|
-
if intent.descriptors:
|
|
347
|
-
query += " " + " ".join(intent.descriptors[:2])
|
|
348
|
-
|
|
349
|
-
# Build Splice filters
|
|
350
|
-
splice_filters = _build_splice_filters(intent, template["sample_type"])
|
|
351
|
-
|
|
352
|
-
# Determine which sections this role appears in
|
|
353
|
-
role_sections: list[str] = []
|
|
354
|
-
for section in sections:
|
|
355
|
-
section_layers = section.get("layers", [])
|
|
356
|
-
for layer_ref in section_layers:
|
|
357
|
-
# Parse "drums:-6dB" → "drums"
|
|
358
|
-
layer_role = layer_ref.split(":")[0]
|
|
359
|
-
if layer_role == role:
|
|
360
|
-
role_sections.append(section["name"])
|
|
361
|
-
break
|
|
362
|
-
# If no section template match, include in all sections
|
|
363
|
-
if not role_sections:
|
|
364
|
-
role_sections = section_names
|
|
365
|
-
|
|
366
|
-
# Pan spread for stereo width
|
|
367
|
-
pan = _compute_pan(role, intent.energy)
|
|
368
|
-
|
|
369
|
-
layer = LayerSpec(
|
|
370
|
-
role=role,
|
|
371
|
-
search_query=query,
|
|
372
|
-
splice_filters=splice_filters,
|
|
373
|
-
technique_id=template["technique_id"],
|
|
374
|
-
processing=list(template["processing"]), # copy
|
|
375
|
-
volume_db=template["volume_db"],
|
|
376
|
-
pan=pan,
|
|
377
|
-
sections=role_sections,
|
|
378
|
-
priority=template["priority"],
|
|
379
|
-
)
|
|
380
|
-
|
|
381
|
-
layers.append(layer)
|
|
382
|
-
|
|
383
|
-
# Sort by priority (drums first, fx last)
|
|
384
|
-
layers.sort(key=lambda l: l.priority)
|
|
385
|
-
|
|
386
|
-
return layers
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def plan_sections(intent: CompositionIntent) -> list[dict]:
|
|
390
|
-
"""Plan arrangement sections based on genre and duration.
|
|
391
|
-
|
|
392
|
-
Returns a list of dicts: {name, bars, layers, start_bar}.
|
|
393
|
-
"""
|
|
394
|
-
template = SECTION_TEMPLATES.get(intent.genre, _DEFAULT_SECTION_TEMPLATE)
|
|
395
|
-
|
|
396
|
-
# Scale sections to fit duration_bars
|
|
397
|
-
total_template_bars = sum(s["bars"] for s in template)
|
|
398
|
-
if total_template_bars == 0:
|
|
399
|
-
total_template_bars = 64
|
|
400
|
-
|
|
401
|
-
scale = intent.duration_bars / total_template_bars
|
|
402
|
-
|
|
403
|
-
sections: list[dict] = []
|
|
404
|
-
current_bar = 0
|
|
405
|
-
|
|
406
|
-
for section in template:
|
|
407
|
-
scaled_bars = max(4, round(section["bars"] * scale))
|
|
408
|
-
# Round to nearest 4 bars
|
|
409
|
-
scaled_bars = max(4, (scaled_bars // 4) * 4)
|
|
410
|
-
|
|
411
|
-
sections.append({
|
|
412
|
-
"name": section["name"],
|
|
413
|
-
"bars": scaled_bars,
|
|
414
|
-
"layers": list(section["layers"]),
|
|
415
|
-
"start_bar": current_bar,
|
|
416
|
-
})
|
|
417
|
-
current_bar += scaled_bars
|
|
418
|
-
|
|
419
|
-
# Clamp overshoot. Rounding each section up to the nearest 4 bars plus
|
|
420
|
-
# the min-of-4-bars floor means a short duration_bars (e.g. 16) against
|
|
421
|
-
# a 6-section template could produce 24+ bars of sections — a 50%
|
|
422
|
-
# overshoot that pushed arrangement clips into unexpected territory.
|
|
423
|
-
# Trim from the longest non-intro section until we fit.
|
|
424
|
-
total_placed = sum(s["bars"] for s in sections)
|
|
425
|
-
overshoot = total_placed - intent.duration_bars
|
|
426
|
-
if overshoot > 0 and sections:
|
|
427
|
-
# Sort indices by section length desc, skipping the first section
|
|
428
|
-
# (usually intro) which we'd rather preserve at its snapped length.
|
|
429
|
-
trimmable = sorted(
|
|
430
|
-
range(1, len(sections)),
|
|
431
|
-
key=lambda i: -sections[i]["bars"],
|
|
432
|
-
) or [0]
|
|
433
|
-
i = 0
|
|
434
|
-
while overshoot > 0 and i < len(trimmable) * 4:
|
|
435
|
-
idx = trimmable[i % len(trimmable)]
|
|
436
|
-
if sections[idx]["bars"] > 4:
|
|
437
|
-
sections[idx]["bars"] -= 4
|
|
438
|
-
overshoot -= 4
|
|
439
|
-
i += 1
|
|
440
|
-
# Recompute start_bar values after any trim
|
|
441
|
-
running = 0
|
|
442
|
-
for s in sections:
|
|
443
|
-
s["start_bar"] = running
|
|
444
|
-
running += s["bars"]
|
|
445
|
-
|
|
446
|
-
return sections
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
def _compute_pan(role: str, energy: float) -> float:
|
|
450
|
-
"""Compute pan position for a role.
|
|
451
|
-
|
|
452
|
-
Core elements (drums, bass) stay centered.
|
|
453
|
-
Support elements get wider spread at higher energy.
|
|
454
|
-
"""
|
|
455
|
-
_PAN_MAP = {
|
|
456
|
-
"drums": 0.0,
|
|
457
|
-
"bass": 0.0,
|
|
458
|
-
"lead": 0.0,
|
|
459
|
-
"pad": 0.0,
|
|
460
|
-
"vocal": 0.0,
|
|
461
|
-
"percussion": 0.3,
|
|
462
|
-
"texture": -0.3,
|
|
463
|
-
"fx": 0.4,
|
|
464
|
-
}
|
|
465
|
-
base_pan = _PAN_MAP.get(role, 0.0)
|
|
466
|
-
# Widen slightly with energy
|
|
467
|
-
return base_pan * (0.5 + 0.5 * energy)
|
|
7
|
+
from .full.layer_planner import ( # noqa: F401
|
|
8
|
+
LayerSpec,
|
|
9
|
+
plan_layers,
|
|
10
|
+
plan_sections,
|
|
11
|
+
_ROLE_TEMPLATES,
|
|
12
|
+
_GENRE_ROLE_PRIORITY,
|
|
13
|
+
_DEFAULT_ROLE_PRIORITY,
|
|
14
|
+
# v1.24: _DEFAULT_SECTION_TEMPLATE removed per vocabulary-not-form principle (Task 12).
|
|
15
|
+
_build_search_query,
|
|
16
|
+
_ROLE_INSTRUMENT,
|
|
17
|
+
_NON_TONAL_ROLES,
|
|
18
|
+
_build_splice_filters,
|
|
19
|
+
_select_roles,
|
|
20
|
+
_compute_pan,
|
|
21
|
+
)
|
|
@@ -44,7 +44,7 @@ import re
|
|
|
44
44
|
from pathlib import Path
|
|
45
45
|
from typing import Optional, Tuple
|
|
46
46
|
|
|
47
|
-
from .layer_planner import LayerSpec
|
|
47
|
+
from .full.layer_planner import LayerSpec
|
|
48
48
|
import logging
|
|
49
49
|
|
|
50
50
|
logger = logging.getLogger(__name__)
|
|
@@ -142,7 +142,12 @@ def _score_candidate(path: Path, layer: LayerSpec, query_tempos: set[str]) -> fl
|
|
|
142
142
|
if _role_matches(primary, role):
|
|
143
143
|
score += 1.5 # bonus: filename is "about" this layer's role
|
|
144
144
|
else:
|
|
145
|
-
|
|
145
|
+
# v1.24 BUG-FULL-MODE-16 fix: when role word is literally in the
|
|
146
|
+
# filename, the role-word presence is authoritative — soften the
|
|
147
|
+
# primary-mismatch penalty so synth_bass_* is not hard-rejected.
|
|
148
|
+
role_in_name = role and role in name
|
|
149
|
+
mismatch_penalty = 2.0 if role_in_name else 5.0
|
|
150
|
+
score -= mismatch_penalty # penalty: filename is about a different role
|
|
146
151
|
|
|
147
152
|
# 3. Role-adjacent hint words in filename
|
|
148
153
|
hints = _ROLE_HINTS.get(role, frozenset())
|
|
@@ -210,15 +215,41 @@ async def _splice_resolve(
|
|
|
210
215
|
"""Query Splice for the layer. Returns (path, source) or (None, 'unresolved').
|
|
211
216
|
|
|
212
217
|
Tries local hits first (free), then remote downloads (1 credit each,
|
|
213
|
-
respecting the hard floor).
|
|
218
|
+
respecting the hard floor).
|
|
219
|
+
|
|
220
|
+
BUG-FULL-MODE-9 (2026-05-01): forwards `layer.splice_filters` to the
|
|
221
|
+
server-side search (key, chord_type, BPM range, genre, sample_type,
|
|
222
|
+
instrument). Pre-fix this passed only `query` + `per_page`, which made
|
|
223
|
+
Splice degrade to text-matching on the search-query string — leading
|
|
224
|
+
to e.g. `Piano_OneShot_PianoPhrase_Am.wav` winning the bass slot
|
|
225
|
+
because the filename contains "OneShot" + "Am".
|
|
226
|
+
|
|
227
|
+
BUG-FULL-MODE-10 (2026-05-01): scores Splice candidates by role-fit
|
|
228
|
+
using the same `_score_candidate` heuristic applied to filesystem
|
|
229
|
+
results. Required because Splice's server-side scoring still doesn't
|
|
230
|
+
know which result is best for THIS role — a "synth" instrument filter
|
|
231
|
+
on the lead slot can return both vocals and pad samples; the
|
|
232
|
+
role-fit score sorts them. Candidates with score ≤ 0 are skipped.
|
|
214
233
|
"""
|
|
215
234
|
if splice_client is None or not getattr(splice_client, "connected", False):
|
|
216
235
|
return None, "unresolved"
|
|
217
236
|
|
|
237
|
+
# BUG-FULL-MODE-9: forward the per-layer filter dict that the planner
|
|
238
|
+
# already built (key, chord_type, bpm_min/max, genre, sample_type,
|
|
239
|
+
# instrument). Pre-fix this was silently dropped — Splice never
|
|
240
|
+
# received any filtering criteria beyond the free-text query.
|
|
241
|
+
f = layer.splice_filters or {}
|
|
218
242
|
try:
|
|
219
243
|
result = await splice_client.search_samples(
|
|
220
244
|
query=layer.search_query,
|
|
221
|
-
|
|
245
|
+
key=f.get("key", ""),
|
|
246
|
+
chord_type=f.get("chord_type", ""),
|
|
247
|
+
bpm_min=int(f.get("bpm_min", 0)),
|
|
248
|
+
bpm_max=int(f.get("bpm_max", 0)),
|
|
249
|
+
genre=f.get("genre", ""),
|
|
250
|
+
sample_type=f.get("sample_type", ""),
|
|
251
|
+
instrument=f.get("instrument", ""),
|
|
252
|
+
per_page=10, # bumped from 5 to give scorer more options
|
|
222
253
|
)
|
|
223
254
|
except Exception as exc:
|
|
224
255
|
logger.debug("_splice_resolve failed: %s", exc)
|
|
@@ -227,14 +258,52 @@ async def _splice_resolve(
|
|
|
227
258
|
if not samples:
|
|
228
259
|
return None, "unresolved"
|
|
229
260
|
|
|
230
|
-
#
|
|
261
|
+
# BUG-FULL-MODE-10: score every candidate by role-fit using filename
|
|
262
|
+
# heuristics. Score against Splice's `filename` field (authoritative
|
|
263
|
+
# metadata — usually the original asset name with role hints baked in
|
|
264
|
+
# like "Piano_OneShot_PianoPhrase_Am.wav"), NOT against `local_path`
|
|
265
|
+
# which is the cached file location and can be an arbitrary hash or
|
|
266
|
+
# bootstrap name like "splice_local.wav". When filename is empty,
|
|
267
|
+
# fall back to local_path so we don't drop legitimately-cached hits.
|
|
268
|
+
query_tempos = _extract_query_tempos(layer.search_query)
|
|
269
|
+
|
|
270
|
+
def _scoring_path(sample) -> Optional[Path]:
|
|
271
|
+
fn = getattr(sample, "filename", "") or getattr(sample, "name", "") or ""
|
|
272
|
+
if fn:
|
|
273
|
+
return Path(fn)
|
|
274
|
+
lp = getattr(sample, "local_path", "") or ""
|
|
275
|
+
if lp:
|
|
276
|
+
return Path(lp)
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
scored: list[tuple[float, object]] = []
|
|
231
280
|
for sample in samples:
|
|
281
|
+
path = _scoring_path(sample)
|
|
282
|
+
if path is None:
|
|
283
|
+
continue
|
|
284
|
+
score = _score_candidate(path, layer, query_tempos)
|
|
285
|
+
scored.append((score, sample))
|
|
286
|
+
|
|
287
|
+
# Sort high-to-low; samples with score ≤ 0 are ambiguous (no role
|
|
288
|
+
# signal) — keep them as fallback but prefer scoring positives first.
|
|
289
|
+
scored.sort(key=lambda t: t[0], reverse=True)
|
|
290
|
+
|
|
291
|
+
# 1. Prefer already-local Splice hits (zero credit spend), in score order.
|
|
292
|
+
for score, sample in scored:
|
|
293
|
+
if score <= 0:
|
|
294
|
+
continue # fail-fast on negative-scored (wrong-role) matches
|
|
232
295
|
lp = getattr(sample, "local_path", "") or ""
|
|
233
296
|
if lp and Path(lp).exists():
|
|
297
|
+
logger.debug(
|
|
298
|
+
"_splice_resolve: local hit '%s' for role=%s score=%.1f",
|
|
299
|
+
lp, layer.role, score,
|
|
300
|
+
)
|
|
234
301
|
return lp, "splice_local"
|
|
235
302
|
|
|
236
|
-
# 2. Remote download — respect the credit hard floor
|
|
237
|
-
for sample in
|
|
303
|
+
# 2. Remote download — respect the credit hard floor, score order.
|
|
304
|
+
for score, sample in scored:
|
|
305
|
+
if score <= 0:
|
|
306
|
+
continue # don't download wrong-role candidates
|
|
238
307
|
if getattr(sample, "local_path", ""):
|
|
239
308
|
continue # already handled above
|
|
240
309
|
file_hash = getattr(sample, "file_hash", "")
|
|
@@ -246,6 +315,10 @@ async def _splice_resolve(
|
|
|
246
315
|
break # credit floor hit — stop trying, don't try next sample
|
|
247
316
|
downloaded = await splice_client.download_sample(file_hash)
|
|
248
317
|
if downloaded and Path(downloaded).exists():
|
|
318
|
+
logger.debug(
|
|
319
|
+
"_splice_resolve: downloaded '%s' for role=%s score=%.1f",
|
|
320
|
+
downloaded, layer.role, score,
|
|
321
|
+
)
|
|
249
322
|
return downloaded, "splice_remote"
|
|
250
323
|
except Exception as exc:
|
|
251
324
|
logger.debug("_splice_resolve failed: %s", exc)
|