livepilot 1.23.6 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +59 -13
  3. package/mcp_server/__init__.py +1 -1
  4. package/mcp_server/atlas/__init__.py +17 -3
  5. package/mcp_server/audit/__init__.py +6 -0
  6. package/mcp_server/audit/checks.py +618 -0
  7. package/mcp_server/audit/tools.py +232 -0
  8. package/mcp_server/composer/branch_producer.py +5 -2
  9. package/mcp_server/composer/develop/__init__.py +19 -0
  10. package/mcp_server/composer/develop/apply.py +217 -0
  11. package/mcp_server/composer/develop/brief_builder.py +269 -0
  12. package/mcp_server/composer/develop/seed_introspector.py +195 -0
  13. package/mcp_server/composer/engine.py +15 -521
  14. package/mcp_server/composer/fast/__init__.py +62 -0
  15. package/mcp_server/composer/fast/apply.py +533 -0
  16. package/mcp_server/composer/fast/brief_builder.py +1479 -0
  17. package/mcp_server/composer/fast/tier_classification.py +159 -0
  18. package/mcp_server/composer/framework/__init__.py +0 -0
  19. package/mcp_server/composer/framework/applier.py +179 -0
  20. package/mcp_server/composer/framework/artist_loader.py +63 -0
  21. package/mcp_server/composer/framework/brief.py +79 -0
  22. package/mcp_server/composer/framework/event_lexicon.py +71 -0
  23. package/mcp_server/composer/framework/genre_loader.py +77 -0
  24. package/mcp_server/composer/framework/intent_source.py +137 -0
  25. package/mcp_server/composer/framework/knowledge_pack.py +49 -0
  26. package/mcp_server/composer/framework/plan_compiler.py +10 -0
  27. package/mcp_server/composer/full/__init__.py +10 -0
  28. package/mcp_server/composer/full/apply.py +1139 -0
  29. package/mcp_server/composer/full/brief_builder.py +144 -0
  30. package/mcp_server/composer/full/engine.py +541 -0
  31. package/mcp_server/composer/full/layer_planner.py +491 -0
  32. package/mcp_server/composer/layer_planner.py +19 -465
  33. package/mcp_server/composer/sample_resolver.py +80 -7
  34. package/mcp_server/composer/tools.py +626 -28
  35. package/mcp_server/server.py +1 -0
  36. package/mcp_server/splice_client/client.py +7 -0
  37. package/mcp_server/tools/_analyzer_engine/sample.py +162 -6
  38. package/mcp_server/tools/_planner_engine.py +25 -63
  39. package/mcp_server/tools/analyzer.py +10 -4
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/server.json +3 -3
@@ -1,467 +1,21 @@
1
- """Layer plannerconvert CompositionIntent into LayerSpec list.
1
+ """Backward-compat shimlayer_planner has moved to full/layer_planner.py.
2
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.
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
- 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
- _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
- score -= 5.0 # heavy penalty: filename is about a different role
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). Stops on first success.
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
- per_page=5,
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
- # 1. Prefer already-local Splice hits (zero credit spend)
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 samples:
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)