livepilot 1.9.24 → 1.10.1
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/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +223 -0
- package/CONTRIBUTING.md +2 -2
- package/LICENSE +62 -21
- package/README.md +291 -276
- package/bin/livepilot.js +87 -0
- package/installer/codex.js +147 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-arrangement/SKILL.md +18 -1
- package/livepilot/skills/livepilot-core/SKILL.md +22 -5
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
- package/livepilot/skills/livepilot-core/references/overview.md +13 -9
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
- package/livepilot/skills/livepilot-devices/SKILL.md +39 -4
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +23 -19
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +105 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +17 -0
- package/livepilot.mcpb +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/manifest.json +4 -4
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +357 -0
- package/mcp_server/atlas/device_atlas.json +44067 -0
- package/mcp_server/atlas/enrichments/__init__.py +111 -0
- package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
- package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
- package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
- package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
- package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
- package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
- package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
- package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
- package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
- package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
- package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
- package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
- package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
- package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
- package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
- package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
- package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
- package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
- package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
- package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
- package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
- package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
- package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
- package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
- package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
- package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
- package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
- package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
- package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
- package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
- package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
- package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
- package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
- package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
- package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
- package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
- package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
- package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
- package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
- package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
- package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
- package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
- package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
- package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
- package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
- package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
- package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
- package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
- package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
- package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
- package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
- package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
- package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
- package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
- package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
- package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
- package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
- package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
- package/mcp_server/atlas/scanner.py +236 -0
- package/mcp_server/atlas/tools.py +224 -0
- package/mcp_server/composer/__init__.py +1 -0
- package/mcp_server/composer/engine.py +532 -0
- package/mcp_server/composer/layer_planner.py +427 -0
- package/mcp_server/composer/prompt_parser.py +329 -0
- package/mcp_server/composer/sample_resolver.py +153 -0
- package/mcp_server/composer/tools.py +211 -0
- package/mcp_server/connection.py +53 -8
- package/mcp_server/corpus/__init__.py +377 -0
- package/mcp_server/device_forge/__init__.py +1 -0
- package/mcp_server/device_forge/builder.py +377 -0
- package/mcp_server/device_forge/models.py +142 -0
- package/mcp_server/device_forge/templates.py +483 -0
- package/mcp_server/device_forge/tools.py +162 -0
- package/mcp_server/m4l_bridge.py +1 -0
- package/mcp_server/memory/taste_accessors.py +47 -0
- package/mcp_server/preview_studio/engine.py +9 -2
- package/mcp_server/preview_studio/tools.py +78 -35
- package/mcp_server/project_brain/tools.py +34 -0
- package/mcp_server/runtime/capability_probe.py +21 -2
- package/mcp_server/runtime/execution_router.py +184 -38
- package/mcp_server/runtime/live_version.py +102 -0
- package/mcp_server/runtime/mcp_dispatch.py +46 -0
- package/mcp_server/runtime/remote_commands.py +13 -5
- package/mcp_server/runtime/tools.py +66 -29
- package/mcp_server/sample_engine/__init__.py +1 -0
- package/mcp_server/sample_engine/analyzer.py +216 -0
- package/mcp_server/sample_engine/critics.py +390 -0
- package/mcp_server/sample_engine/models.py +193 -0
- package/mcp_server/sample_engine/moves.py +127 -0
- package/mcp_server/sample_engine/planner.py +186 -0
- package/mcp_server/sample_engine/slice_workflow.py +190 -0
- package/mcp_server/sample_engine/sources.py +540 -0
- package/mcp_server/sample_engine/techniques.py +908 -0
- package/mcp_server/sample_engine/tools.py +545 -0
- package/mcp_server/semantic_moves/__init__.py +3 -0
- package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
- package/mcp_server/semantic_moves/mix_moves.py +8 -8
- package/mcp_server/semantic_moves/models.py +7 -7
- package/mcp_server/semantic_moves/performance_moves.py +4 -4
- package/mcp_server/semantic_moves/sample_compilers.py +377 -0
- package/mcp_server/semantic_moves/sound_design_moves.py +4 -4
- package/mcp_server/semantic_moves/tools.py +63 -10
- package/mcp_server/semantic_moves/transition_moves.py +4 -4
- package/mcp_server/server.py +71 -1
- package/mcp_server/session_continuity/tracker.py +4 -1
- package/mcp_server/sound_design/critics.py +89 -1
- package/mcp_server/splice_client/__init__.py +1 -0
- package/mcp_server/splice_client/client.py +347 -0
- package/mcp_server/splice_client/models.py +96 -0
- package/mcp_server/splice_client/protos/__init__.py +1 -0
- package/mcp_server/splice_client/protos/app_pb2.py +319 -0
- package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
- package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
- package/mcp_server/tools/_conductor.py +16 -0
- package/mcp_server/tools/_planner_engine.py +24 -0
- package/mcp_server/tools/analyzer.py +2 -0
- package/mcp_server/tools/arrangement.py +69 -0
- package/mcp_server/tools/automation.py +15 -2
- package/mcp_server/tools/devices.py +117 -6
- package/mcp_server/tools/notes.py +37 -4
- package/mcp_server/tools/planner.py +3 -0
- package/mcp_server/wonder_mode/diagnosis.py +5 -0
- package/mcp_server/wonder_mode/engine.py +144 -14
- package/mcp_server/wonder_mode/tools.py +33 -1
- package/package.json +14 -4
- package/remote_script/LivePilot/__init__.py +8 -1
- package/remote_script/LivePilot/arrangement.py +114 -0
- package/remote_script/LivePilot/browser.py +56 -1
- package/remote_script/LivePilot/devices.py +246 -6
- package/remote_script/LivePilot/mixing.py +8 -3
- package/remote_script/LivePilot/server.py +5 -1
- package/remote_script/LivePilot/transport.py +3 -0
- package/remote_script/LivePilot/version_detect.py +78 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Prompt parser — natural language → structured CompositionIntent.
|
|
2
|
+
|
|
3
|
+
Extracts genre, mood, tempo, key, descriptors, and explicit element requests
|
|
4
|
+
from free-form text prompts. Pure computation, no I/O.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Data Model ─────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class CompositionIntent:
|
|
18
|
+
"""Structured representation of a composition request."""
|
|
19
|
+
|
|
20
|
+
genre: str = ""
|
|
21
|
+
sub_genre: str = ""
|
|
22
|
+
mood: str = ""
|
|
23
|
+
tempo: int = 0 # 0 = auto-detect from genre
|
|
24
|
+
key: str = "" # "" = auto-pick based on mood
|
|
25
|
+
descriptors: list[str] = field(default_factory=list)
|
|
26
|
+
explicit_elements: list[str] = field(default_factory=list)
|
|
27
|
+
energy: float = 0.5 # 0.0-1.0
|
|
28
|
+
layer_count: int = 0 # 0 = auto (genre determines)
|
|
29
|
+
duration_bars: int = 64 # total arrangement length
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
return {
|
|
33
|
+
"genre": self.genre,
|
|
34
|
+
"sub_genre": self.sub_genre,
|
|
35
|
+
"mood": self.mood,
|
|
36
|
+
"tempo": self.tempo,
|
|
37
|
+
"key": self.key,
|
|
38
|
+
"descriptors": self.descriptors,
|
|
39
|
+
"explicit_elements": self.explicit_elements,
|
|
40
|
+
"energy": self.energy,
|
|
41
|
+
"layer_count": self.layer_count,
|
|
42
|
+
"duration_bars": self.duration_bars,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Genre Defaults ─────────────────────────────────────────────────
|
|
47
|
+
# genre → (default_tempo, default_keys, default_energy, layer_range)
|
|
48
|
+
|
|
49
|
+
GENRE_DEFAULTS: dict[str, dict] = {
|
|
50
|
+
"techno": {
|
|
51
|
+
"tempo": 128, "keys": ["Am", "Cm"], "energy": 0.7,
|
|
52
|
+
"layers_min": 5, "layers_max": 7,
|
|
53
|
+
},
|
|
54
|
+
"house": {
|
|
55
|
+
"tempo": 124, "keys": ["Cm", "Fm"], "energy": 0.6,
|
|
56
|
+
"layers_min": 5, "layers_max": 6,
|
|
57
|
+
},
|
|
58
|
+
"hip hop": {
|
|
59
|
+
"tempo": 90, "keys": ["Cm", "Gm"], "energy": 0.5,
|
|
60
|
+
"layers_min": 4, "layers_max": 6,
|
|
61
|
+
},
|
|
62
|
+
"ambient": {
|
|
63
|
+
"tempo": 80, "keys": ["C", "Am"], "energy": 0.2,
|
|
64
|
+
"layers_min": 3, "layers_max": 5,
|
|
65
|
+
},
|
|
66
|
+
"drum and bass": {
|
|
67
|
+
"tempo": 174, "keys": ["Am", "Em"], "energy": 0.8,
|
|
68
|
+
"layers_min": 5, "layers_max": 7,
|
|
69
|
+
},
|
|
70
|
+
"trap": {
|
|
71
|
+
"tempo": 140, "keys": ["Cm", "Bbm"], "energy": 0.6,
|
|
72
|
+
"layers_min": 4, "layers_max": 6,
|
|
73
|
+
},
|
|
74
|
+
"lo-fi": {
|
|
75
|
+
"tempo": 85, "keys": ["Fm", "Cm"], "energy": 0.3,
|
|
76
|
+
"layers_min": 3, "layers_max": 5,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Aliases that map to canonical genre names
|
|
81
|
+
_GENRE_ALIASES: dict[str, str] = {
|
|
82
|
+
"dnb": "drum and bass",
|
|
83
|
+
"d&b": "drum and bass",
|
|
84
|
+
"jungle": "drum and bass",
|
|
85
|
+
"lofi": "lo-fi",
|
|
86
|
+
"lo fi": "lo-fi",
|
|
87
|
+
"hiphop": "hip hop",
|
|
88
|
+
"hip-hop": "hip hop",
|
|
89
|
+
"deep house": "house",
|
|
90
|
+
"tech house": "house",
|
|
91
|
+
"acid techno": "techno",
|
|
92
|
+
"hard techno": "techno",
|
|
93
|
+
"industrial techno": "techno",
|
|
94
|
+
"minimal techno": "techno",
|
|
95
|
+
"detroit techno": "techno",
|
|
96
|
+
"dub techno": "techno",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Mood Mapping ───────────────────────────────────────────────────
|
|
101
|
+
# mood → (energy_range, key_bias_list)
|
|
102
|
+
|
|
103
|
+
MOOD_MAPPING: dict[str, dict] = {
|
|
104
|
+
"dark": {
|
|
105
|
+
"energy_min": 0.4, "energy_max": 0.6,
|
|
106
|
+
"key_bias": ["Am", "Cm", "Em", "Dm"],
|
|
107
|
+
},
|
|
108
|
+
"euphoric": {
|
|
109
|
+
"energy_min": 0.8, "energy_max": 1.0,
|
|
110
|
+
"key_bias": ["C", "G", "F", "A"],
|
|
111
|
+
},
|
|
112
|
+
"melancholic": {
|
|
113
|
+
"energy_min": 0.2, "energy_max": 0.4,
|
|
114
|
+
"key_bias": ["Fm", "Cm", "Dm", "Bbm"],
|
|
115
|
+
},
|
|
116
|
+
"aggressive": {
|
|
117
|
+
"energy_min": 0.8, "energy_max": 0.9,
|
|
118
|
+
"key_bias": ["Am", "Em", "Bm", "F#m"],
|
|
119
|
+
},
|
|
120
|
+
"dreamy": {
|
|
121
|
+
"energy_min": 0.2, "energy_max": 0.3,
|
|
122
|
+
"key_bias": ["C", "F", "Bb", "Eb"],
|
|
123
|
+
},
|
|
124
|
+
"chill": {
|
|
125
|
+
"energy_min": 0.2, "energy_max": 0.4,
|
|
126
|
+
"key_bias": ["Fm", "Cm", "Gm", "Dm"],
|
|
127
|
+
},
|
|
128
|
+
"hypnotic": {
|
|
129
|
+
"energy_min": 0.5, "energy_max": 0.7,
|
|
130
|
+
"key_bias": ["Am", "Em", "Dm"],
|
|
131
|
+
},
|
|
132
|
+
"ethereal": {
|
|
133
|
+
"energy_min": 0.2, "energy_max": 0.4,
|
|
134
|
+
"key_bias": ["C", "F", "Ab", "Eb"],
|
|
135
|
+
},
|
|
136
|
+
"driving": {
|
|
137
|
+
"energy_min": 0.7, "energy_max": 0.9,
|
|
138
|
+
"key_bias": ["Am", "Em", "Cm"],
|
|
139
|
+
},
|
|
140
|
+
"warm": {
|
|
141
|
+
"energy_min": 0.3, "energy_max": 0.5,
|
|
142
|
+
"key_bias": ["F", "Bb", "Eb", "Ab"],
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── Sub-genre keywords ─────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
_SUB_GENRE_KEYWORDS: list[str] = [
|
|
150
|
+
"minimal", "deep", "acid", "industrial", "detroit", "dub",
|
|
151
|
+
"progressive", "melodic", "hard", "dark", "atmospheric",
|
|
152
|
+
"organic", "analog", "modular", "breakbeat", "uk garage",
|
|
153
|
+
"2-step", "drill", "boom bap", "old school", "new wave",
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── Descriptor keywords (adjectives that color the composition) ────
|
|
158
|
+
|
|
159
|
+
_DESCRIPTOR_KEYWORDS: list[str] = [
|
|
160
|
+
"industrial", "ghostly", "warm", "cold", "metallic", "organic",
|
|
161
|
+
"spacious", "intimate", "raw", "polished", "gritty", "clean",
|
|
162
|
+
"distorted", "saturated", "lush", "sparse", "dense", "airy",
|
|
163
|
+
"punchy", "soft", "crisp", "muddy", "bright", "muted",
|
|
164
|
+
"psychedelic", "glitchy", "cinematic", "underground", "futuristic",
|
|
165
|
+
"retro", "vintage", "modern", "classic", "experimental",
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── Element extraction patterns ────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
_ELEMENT_PATTERNS: list[tuple[str, str]] = [
|
|
172
|
+
# (regex_pattern, element_name)
|
|
173
|
+
(r"\bwith\s+vocals?\b", "vocal"),
|
|
174
|
+
(r"\bwith\s+strings?\b", "strings"),
|
|
175
|
+
(r"\badd\s+strings?\b", "strings"),
|
|
176
|
+
(r"\b808\s*bass\b", "808"),
|
|
177
|
+
(r"\bwith\s+808\b", "808"),
|
|
178
|
+
(r"\bwith\s+synth\b", "synth"),
|
|
179
|
+
(r"\bwith\s+pads?\b", "pad"),
|
|
180
|
+
(r"\bwith\s+piano\b", "piano"),
|
|
181
|
+
(r"\bwith\s+guitar\b", "guitar"),
|
|
182
|
+
(r"\bwith\s+brass\b", "brass"),
|
|
183
|
+
(r"\bwith\s+horns?\b", "brass"),
|
|
184
|
+
(r"\bwith\s+(?:fx|effects?)\b", "fx"),
|
|
185
|
+
(r"\bwith\s+risers?\b", "fx"),
|
|
186
|
+
(r"\bwith\s+(?:perc|percussion)\b", "percussion"),
|
|
187
|
+
(r"\bwith\s+textures?\b", "texture"),
|
|
188
|
+
(r"\bghostly\s+vocals?\b", "vocal"),
|
|
189
|
+
(r"\bvocal\s+chops?\b", "vocal"),
|
|
190
|
+
(r"\bvocal\s+stabs?\b", "vocal"),
|
|
191
|
+
(r"\bsub\s*bass\b", "bass"),
|
|
192
|
+
(r"\breese\s*bass\b", "bass"),
|
|
193
|
+
(r"\bamen\s+break\b", "drums"),
|
|
194
|
+
(r"\bbreakbeat\b", "drums"),
|
|
195
|
+
(r"\bfoley\b", "texture"),
|
|
196
|
+
(r"\bfield\s+recordings?\b", "texture"),
|
|
197
|
+
(r"\batmospheric\b", "texture"),
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── Regex helpers ──────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
_TEMPO_RE = re.compile(r"\b(\d{2,3})\s*bpm\b", re.IGNORECASE)
|
|
204
|
+
|
|
205
|
+
# Key patterns: C, Cm, C#, C# minor, Db, Dbm, F# minor, Bb major
|
|
206
|
+
_KEY_RE = re.compile(
|
|
207
|
+
r"\b([A-Ga-g][#b]?)\s*(minor|major|min|maj|m)?\b"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ── Parser ─────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def parse_prompt(text: str) -> CompositionIntent:
|
|
214
|
+
"""Parse a natural language composition prompt into structured intent.
|
|
215
|
+
|
|
216
|
+
Examples:
|
|
217
|
+
"dark minimal techno 128bpm Cm"
|
|
218
|
+
"euphoric deep house with vocals"
|
|
219
|
+
"lo-fi hip hop 85bpm F minor dreamy"
|
|
220
|
+
"aggressive drum and bass 174bpm Am"
|
|
221
|
+
"""
|
|
222
|
+
intent = CompositionIntent()
|
|
223
|
+
text_lower = text.lower().strip()
|
|
224
|
+
|
|
225
|
+
# 1. Extract tempo
|
|
226
|
+
tempo_match = _TEMPO_RE.search(text)
|
|
227
|
+
if tempo_match:
|
|
228
|
+
intent.tempo = int(tempo_match.group(1))
|
|
229
|
+
|
|
230
|
+
# 2. Extract key (search original text to preserve case)
|
|
231
|
+
key_match = _KEY_RE.search(text)
|
|
232
|
+
if key_match:
|
|
233
|
+
root = key_match.group(1)
|
|
234
|
+
# Normalize root: uppercase first letter
|
|
235
|
+
root = root[0].upper() + root[1:] if len(root) > 1 else root.upper()
|
|
236
|
+
quality = key_match.group(2) or ""
|
|
237
|
+
quality_lower = quality.lower()
|
|
238
|
+
if quality_lower in ("minor", "min", "m"):
|
|
239
|
+
intent.key = f"{root}m"
|
|
240
|
+
elif quality_lower in ("major", "maj"):
|
|
241
|
+
intent.key = root
|
|
242
|
+
else:
|
|
243
|
+
# Standalone note — check if followed by 'm' in the original
|
|
244
|
+
intent.key = root
|
|
245
|
+
|
|
246
|
+
# 3. Match genre (check aliases first, then canonical names)
|
|
247
|
+
# Sort by length descending to match longer aliases first
|
|
248
|
+
all_genres = list(_GENRE_ALIASES.items()) + [
|
|
249
|
+
(g, g) for g in GENRE_DEFAULTS
|
|
250
|
+
]
|
|
251
|
+
all_genres.sort(key=lambda x: -len(x[0]))
|
|
252
|
+
|
|
253
|
+
for alias, canonical in all_genres:
|
|
254
|
+
if alias in text_lower:
|
|
255
|
+
intent.genre = canonical
|
|
256
|
+
# Extract sub-genre from the alias if it differs
|
|
257
|
+
if alias != canonical and " " in alias:
|
|
258
|
+
parts = alias.split()
|
|
259
|
+
for part in parts:
|
|
260
|
+
if part != canonical and part in text_lower:
|
|
261
|
+
intent.sub_genre = part
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
# 4. Check for sub-genre keywords not caught by alias matching
|
|
265
|
+
if not intent.sub_genre:
|
|
266
|
+
for kw in _SUB_GENRE_KEYWORDS:
|
|
267
|
+
if kw in text_lower and kw != intent.genre:
|
|
268
|
+
intent.sub_genre = kw
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
# 5. Match mood
|
|
272
|
+
for mood_name in MOOD_MAPPING:
|
|
273
|
+
if mood_name in text_lower:
|
|
274
|
+
intent.mood = mood_name
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
# 6. Extract descriptors
|
|
278
|
+
for descriptor in _DESCRIPTOR_KEYWORDS:
|
|
279
|
+
if descriptor in text_lower and descriptor != intent.mood and descriptor != intent.sub_genre:
|
|
280
|
+
intent.descriptors.append(descriptor)
|
|
281
|
+
|
|
282
|
+
# 7. Extract explicit elements
|
|
283
|
+
seen_elements: set[str] = set()
|
|
284
|
+
for pattern, element in _ELEMENT_PATTERNS:
|
|
285
|
+
if re.search(pattern, text_lower) and element not in seen_elements:
|
|
286
|
+
intent.explicit_elements.append(element)
|
|
287
|
+
seen_elements.add(element)
|
|
288
|
+
|
|
289
|
+
# 8. Apply genre defaults for missing fields
|
|
290
|
+
genre_info = GENRE_DEFAULTS.get(intent.genre, {})
|
|
291
|
+
|
|
292
|
+
if intent.tempo == 0 and genre_info:
|
|
293
|
+
intent.tempo = genre_info["tempo"]
|
|
294
|
+
|
|
295
|
+
if not intent.key:
|
|
296
|
+
# Use mood bias if available, otherwise genre default
|
|
297
|
+
if intent.mood and intent.mood in MOOD_MAPPING:
|
|
298
|
+
intent.key = MOOD_MAPPING[intent.mood]["key_bias"][0]
|
|
299
|
+
elif genre_info:
|
|
300
|
+
intent.key = genre_info["keys"][0]
|
|
301
|
+
|
|
302
|
+
# 9. Compute energy from mood, fallback to genre
|
|
303
|
+
if intent.mood and intent.mood in MOOD_MAPPING:
|
|
304
|
+
mood_info = MOOD_MAPPING[intent.mood]
|
|
305
|
+
intent.energy = (mood_info["energy_min"] + mood_info["energy_max"]) / 2.0
|
|
306
|
+
elif genre_info:
|
|
307
|
+
intent.energy = genre_info["energy"]
|
|
308
|
+
else:
|
|
309
|
+
intent.energy = 0.5
|
|
310
|
+
|
|
311
|
+
# 10. Determine layer count from genre + energy
|
|
312
|
+
if intent.layer_count == 0 and genre_info:
|
|
313
|
+
base_min = genre_info["layers_min"]
|
|
314
|
+
base_max = genre_info["layers_max"]
|
|
315
|
+
# Higher energy → more layers
|
|
316
|
+
energy_factor = intent.energy
|
|
317
|
+
intent.layer_count = round(
|
|
318
|
+
base_min + (base_max - base_min) * energy_factor
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Fallback defaults
|
|
322
|
+
if intent.tempo == 0:
|
|
323
|
+
intent.tempo = 120
|
|
324
|
+
if not intent.key:
|
|
325
|
+
intent.key = "Am"
|
|
326
|
+
if intent.layer_count == 0:
|
|
327
|
+
intent.layer_count = 5
|
|
328
|
+
|
|
329
|
+
return intent
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Local-first sample resolution for composer plans.
|
|
2
|
+
|
|
3
|
+
Moves sample resolution from execution time (where the old pseudo-tool
|
|
4
|
+
_agent_pick_best_sample was supposed to "figure it out") to plan time.
|
|
5
|
+
|
|
6
|
+
Async because splice_remote downloads real samples over gRPC. Filesystem-only
|
|
7
|
+
callers still work synchronously from an async perspective — the function
|
|
8
|
+
only awaits when it actually has to hit the network.
|
|
9
|
+
|
|
10
|
+
Returns (local_path, source) where source is one of:
|
|
11
|
+
'filesystem' — hit in a provided search_root directory (no network)
|
|
12
|
+
'splice_local' — Splice catalog hit that's already downloaded (no credit spend)
|
|
13
|
+
'splice_remote' — Splice catalog hit that required download (1 credit)
|
|
14
|
+
'browser' — Ableton browser match with a local path
|
|
15
|
+
'unresolved' — no match; caller drops the layer from the plan and warns
|
|
16
|
+
|
|
17
|
+
Preference order is fixed: filesystem > splice_local > splice_remote > browser.
|
|
18
|
+
Filesystem wins even if Splice has a faster hit — local files are free.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from .layer_planner import LayerSpec
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_AUDIO_EXTENSIONS = (".wav", ".aif", ".aiff", ".flac")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _query_tokens(query: str) -> list[str]:
|
|
33
|
+
"""Return lowercase query tokens meaningful for matching (len > 2)."""
|
|
34
|
+
return [t.lower() for t in query.split() if len(t) > 2]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _iter_candidates(root: Path):
|
|
38
|
+
"""Yield all audio-format files beneath root."""
|
|
39
|
+
if not root.exists():
|
|
40
|
+
return
|
|
41
|
+
for ext in _AUDIO_EXTENSIONS:
|
|
42
|
+
yield from root.rglob(f"*{ext}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _filesystem_match(layer: LayerSpec, search_roots: list[Path]) -> Optional[str]:
|
|
46
|
+
"""First filename-substring match on role or any query token.
|
|
47
|
+
|
|
48
|
+
Sync helper — no network, no async needed.
|
|
49
|
+
"""
|
|
50
|
+
tokens = _query_tokens(layer.search_query)
|
|
51
|
+
role = layer.role.lower()
|
|
52
|
+
for root in search_roots:
|
|
53
|
+
for path in _iter_candidates(Path(root)):
|
|
54
|
+
name = path.name.lower()
|
|
55
|
+
if role and role in name:
|
|
56
|
+
return str(path)
|
|
57
|
+
if any(tok in name for tok in tokens):
|
|
58
|
+
return str(path)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _splice_resolve(
|
|
63
|
+
layer: LayerSpec,
|
|
64
|
+
splice_client: object,
|
|
65
|
+
credit_budget: int,
|
|
66
|
+
) -> Tuple[Optional[str], str]:
|
|
67
|
+
"""Query Splice for the layer. Returns (path, source) or (None, 'unresolved').
|
|
68
|
+
|
|
69
|
+
Tries local hits first (free), then remote downloads (1 credit each,
|
|
70
|
+
respecting the hard floor). Stops on first success.
|
|
71
|
+
"""
|
|
72
|
+
if splice_client is None or not getattr(splice_client, "connected", False):
|
|
73
|
+
return None, "unresolved"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
result = await splice_client.search_samples(
|
|
77
|
+
query=layer.search_query,
|
|
78
|
+
per_page=5,
|
|
79
|
+
)
|
|
80
|
+
except Exception:
|
|
81
|
+
return None, "unresolved"
|
|
82
|
+
|
|
83
|
+
samples = list(result.samples) if result and hasattr(result, "samples") else []
|
|
84
|
+
if not samples:
|
|
85
|
+
return None, "unresolved"
|
|
86
|
+
|
|
87
|
+
# 1. Prefer already-local Splice hits (zero credit spend)
|
|
88
|
+
for sample in samples:
|
|
89
|
+
lp = getattr(sample, "local_path", "") or ""
|
|
90
|
+
if lp and Path(lp).exists():
|
|
91
|
+
return lp, "splice_local"
|
|
92
|
+
|
|
93
|
+
# 2. Remote download — respect the credit hard floor
|
|
94
|
+
for sample in samples:
|
|
95
|
+
if getattr(sample, "local_path", ""):
|
|
96
|
+
continue # already handled above
|
|
97
|
+
file_hash = getattr(sample, "file_hash", "")
|
|
98
|
+
if not file_hash:
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
can, _remaining = await splice_client.can_afford(1, credit_budget)
|
|
102
|
+
if not can:
|
|
103
|
+
break # credit floor hit — stop trying, don't try next sample
|
|
104
|
+
downloaded = await splice_client.download_sample(file_hash)
|
|
105
|
+
if downloaded and Path(downloaded).exists():
|
|
106
|
+
return downloaded, "splice_remote"
|
|
107
|
+
except Exception:
|
|
108
|
+
continue # try next hit
|
|
109
|
+
|
|
110
|
+
return None, "unresolved"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def resolve_sample_for_layer(
|
|
114
|
+
layer: LayerSpec,
|
|
115
|
+
search_roots: Optional[list] = None,
|
|
116
|
+
splice_client: object = None,
|
|
117
|
+
browser_client: object = None,
|
|
118
|
+
credit_budget: int = 1,
|
|
119
|
+
) -> Tuple[Optional[str], str]:
|
|
120
|
+
"""Resolve a layer's sample to a concrete local file path.
|
|
121
|
+
|
|
122
|
+
Preference order: filesystem > splice_local > splice_remote > browser.
|
|
123
|
+
Unresolved layers return (None, 'unresolved'); callers drop them from
|
|
124
|
+
the plan and surface a warning.
|
|
125
|
+
|
|
126
|
+
search_roots accepts Path or str entries. Missing dirs are silently
|
|
127
|
+
skipped. None entries are filtered out.
|
|
128
|
+
"""
|
|
129
|
+
roots = [Path(r) for r in (search_roots or []) if r]
|
|
130
|
+
|
|
131
|
+
# 1. Filesystem — always try first, no network
|
|
132
|
+
fs_hit = _filesystem_match(layer, roots)
|
|
133
|
+
if fs_hit:
|
|
134
|
+
return fs_hit, "filesystem"
|
|
135
|
+
|
|
136
|
+
# 2 & 3. Splice (local hits + remote download)
|
|
137
|
+
path, source = await _splice_resolve(layer, splice_client, credit_budget)
|
|
138
|
+
if path is not None:
|
|
139
|
+
return path, source
|
|
140
|
+
|
|
141
|
+
# 4. Browser (sync, optional)
|
|
142
|
+
if browser_client is not None:
|
|
143
|
+
try:
|
|
144
|
+
search = getattr(browser_client, "search", None)
|
|
145
|
+
hits = search(layer.search_query, limit=5) if callable(search) else []
|
|
146
|
+
for hit in hits or []:
|
|
147
|
+
lp = hit.get("file_path") if isinstance(hit, dict) else None
|
|
148
|
+
if lp and Path(lp).exists():
|
|
149
|
+
return lp, "browser"
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
return None, "unresolved"
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Composer Engine MCP tools — 3 tools for auto-composition.
|
|
2
|
+
|
|
3
|
+
compose: full multi-layer composition from text prompt
|
|
4
|
+
augment_with_samples: add layers to existing session
|
|
5
|
+
get_composition_plan: dry run preview
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from fastmcp import Context
|
|
13
|
+
|
|
14
|
+
from ..server import mcp
|
|
15
|
+
from .prompt_parser import parse_prompt
|
|
16
|
+
from .engine import ComposerEngine
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Singleton engine — stateless, safe to reuse
|
|
20
|
+
_engine = ComposerEngine()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_search_roots(ctx: Context) -> list:
|
|
24
|
+
"""Pull sample-search roots from ctx (if the server wired any) plus
|
|
25
|
+
environment fallbacks.
|
|
26
|
+
"""
|
|
27
|
+
roots = []
|
|
28
|
+
try:
|
|
29
|
+
cfg = ctx.lifespan_context.get("sample_search_roots") if hasattr(ctx, "lifespan_context") else None
|
|
30
|
+
if cfg:
|
|
31
|
+
roots.extend(cfg)
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
return roots
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _credit_safety_prelude(splice_client, max_credits: int) -> tuple[int, int | None, list[str]]:
|
|
38
|
+
"""Apply the hard floor / budget trimming rules upfront.
|
|
39
|
+
|
|
40
|
+
Returns (adjusted_max_credits, credits_remaining_or_None, warnings).
|
|
41
|
+
"""
|
|
42
|
+
warnings: list[str] = []
|
|
43
|
+
credits_remaining: int | None = None
|
|
44
|
+
|
|
45
|
+
if splice_client is None or not getattr(splice_client, "connected", False):
|
|
46
|
+
warnings.append(
|
|
47
|
+
"Splice not connected. Plan will use browser/filesystem fallback "
|
|
48
|
+
"for sample search."
|
|
49
|
+
)
|
|
50
|
+
return max_credits, None, warnings
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
info = await splice_client.get_credits()
|
|
54
|
+
credits_remaining = getattr(info, "credits", None)
|
|
55
|
+
except Exception:
|
|
56
|
+
credits_remaining = None
|
|
57
|
+
|
|
58
|
+
if credits_remaining is None:
|
|
59
|
+
return max_credits, None, warnings
|
|
60
|
+
|
|
61
|
+
if credits_remaining <= 5:
|
|
62
|
+
warnings.append(
|
|
63
|
+
f"Splice credits critically low ({credits_remaining}). "
|
|
64
|
+
f"Using downloaded samples only."
|
|
65
|
+
)
|
|
66
|
+
max_credits = 0
|
|
67
|
+
elif max_credits > credits_remaining - 5:
|
|
68
|
+
safe_budget = max(0, credits_remaining - 5)
|
|
69
|
+
warnings.append(
|
|
70
|
+
f"Budget capped at {safe_budget} credits "
|
|
71
|
+
f"(remaining: {credits_remaining}, floor: 5)."
|
|
72
|
+
)
|
|
73
|
+
max_credits = safe_budget
|
|
74
|
+
|
|
75
|
+
return max_credits, credits_remaining, warnings
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@mcp.tool()
|
|
79
|
+
async def compose(
|
|
80
|
+
ctx: Context,
|
|
81
|
+
prompt: str,
|
|
82
|
+
max_credits: int = 50,
|
|
83
|
+
dry_run: bool = False,
|
|
84
|
+
) -> dict:
|
|
85
|
+
"""Plan a full multi-layer composition from a text prompt.
|
|
86
|
+
|
|
87
|
+
Parses the prompt into genre/mood/tempo/key, plans layers using role
|
|
88
|
+
templates, and compiles an executable plan of tool calls. Does NOT
|
|
89
|
+
execute — returns the plan for the agent to step through.
|
|
90
|
+
|
|
91
|
+
prompt: "dark minimal techno 128bpm with industrial textures and ghostly vocals"
|
|
92
|
+
max_credits: maximum Splice credits budget for the plan (default 50, 0 = downloaded only)
|
|
93
|
+
dry_run: if True, return the plan without credit checks
|
|
94
|
+
|
|
95
|
+
Returns a compiled plan with step-by-step tool calls. The agent
|
|
96
|
+
executes each step by calling the referenced tools in sequence.
|
|
97
|
+
"""
|
|
98
|
+
intent = parse_prompt(prompt)
|
|
99
|
+
|
|
100
|
+
splice_client = ctx.lifespan_context.get("splice_client") if hasattr(ctx, "lifespan_context") else None
|
|
101
|
+
search_roots = _get_search_roots(ctx)
|
|
102
|
+
|
|
103
|
+
max_credits, credits_remaining, warnings = await _credit_safety_prelude(splice_client, max_credits)
|
|
104
|
+
|
|
105
|
+
result = await _engine.compose(
|
|
106
|
+
intent,
|
|
107
|
+
dry_run=dry_run,
|
|
108
|
+
max_credits=max_credits,
|
|
109
|
+
search_roots=search_roots,
|
|
110
|
+
splice_client=splice_client,
|
|
111
|
+
)
|
|
112
|
+
result.warnings.extend(warnings)
|
|
113
|
+
|
|
114
|
+
output = result.to_dict()
|
|
115
|
+
output["prompt"] = prompt
|
|
116
|
+
|
|
117
|
+
if credits_remaining is not None:
|
|
118
|
+
output["credits_remaining"] = credits_remaining
|
|
119
|
+
output["credits_budget"] = max_credits
|
|
120
|
+
|
|
121
|
+
return output
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@mcp.tool()
|
|
125
|
+
async def augment_with_samples(
|
|
126
|
+
ctx: Context,
|
|
127
|
+
request: str,
|
|
128
|
+
max_credits: int = 10,
|
|
129
|
+
max_layers: int = 3,
|
|
130
|
+
) -> dict:
|
|
131
|
+
"""Plan sample-based layers to add to the existing session.
|
|
132
|
+
|
|
133
|
+
Parses the request and builds a plan for new tracks with sample
|
|
134
|
+
search queries, processing techniques, and volume/pan settings.
|
|
135
|
+
Does NOT execute — returns the plan for the agent to step through.
|
|
136
|
+
|
|
137
|
+
request: "add organic textures" or "layer a vocal chop over the verse"
|
|
138
|
+
max_credits: maximum Splice credits budget for the plan (default 10)
|
|
139
|
+
max_layers: maximum number of new tracks in the plan (default 3)
|
|
140
|
+
|
|
141
|
+
Returns a compiled plan with step-by-step tool calls.
|
|
142
|
+
"""
|
|
143
|
+
splice_client = ctx.lifespan_context.get("splice_client") if hasattr(ctx, "lifespan_context") else None
|
|
144
|
+
search_roots = _get_search_roots(ctx)
|
|
145
|
+
|
|
146
|
+
max_credits, credits_remaining, warnings = await _credit_safety_prelude(splice_client, max_credits)
|
|
147
|
+
|
|
148
|
+
# Pull current session info for tempo context
|
|
149
|
+
session_context: dict = {}
|
|
150
|
+
try:
|
|
151
|
+
ableton = ctx.lifespan_context.get("ableton")
|
|
152
|
+
if ableton:
|
|
153
|
+
info = ableton.send_command("get_session_info", {})
|
|
154
|
+
session_context["tempo"] = info.get("tempo", 120)
|
|
155
|
+
session_context["track_count"] = info.get("track_count", 0)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
result = await _engine.augment(
|
|
160
|
+
request=request,
|
|
161
|
+
max_credits=max_credits,
|
|
162
|
+
max_layers=max_layers,
|
|
163
|
+
search_roots=search_roots,
|
|
164
|
+
splice_client=splice_client,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if session_context.get("tempo"):
|
|
168
|
+
result.intent.tempo = int(session_context["tempo"])
|
|
169
|
+
|
|
170
|
+
result.warnings.extend(warnings)
|
|
171
|
+
|
|
172
|
+
output = result.to_dict()
|
|
173
|
+
output["request"] = request
|
|
174
|
+
|
|
175
|
+
if session_context:
|
|
176
|
+
output["session_context"] = session_context
|
|
177
|
+
if credits_remaining is not None:
|
|
178
|
+
output["credits_remaining"] = credits_remaining
|
|
179
|
+
output["credits_budget"] = max_credits
|
|
180
|
+
|
|
181
|
+
return output
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@mcp.tool()
|
|
185
|
+
async def get_composition_plan(
|
|
186
|
+
ctx: Context,
|
|
187
|
+
prompt: str,
|
|
188
|
+
) -> dict:
|
|
189
|
+
"""Preview what compose would do without executing or spending credits.
|
|
190
|
+
|
|
191
|
+
Returns the full layer plan with search queries, technique selections,
|
|
192
|
+
processing chains, and arrangement sections. Use to review before
|
|
193
|
+
committing to a full composition.
|
|
194
|
+
|
|
195
|
+
prompt: "dark minimal techno 128bpm with industrial textures"
|
|
196
|
+
"""
|
|
197
|
+
intent = parse_prompt(prompt)
|
|
198
|
+
splice_client = ctx.lifespan_context.get("splice_client") if hasattr(ctx, "lifespan_context") else None
|
|
199
|
+
search_roots = _get_search_roots(ctx)
|
|
200
|
+
plan = await _engine.get_plan(
|
|
201
|
+
intent,
|
|
202
|
+
search_roots=search_roots,
|
|
203
|
+
splice_client=splice_client,
|
|
204
|
+
)
|
|
205
|
+
plan["prompt"] = prompt
|
|
206
|
+
plan["note"] = (
|
|
207
|
+
"This is a dry run. No samples searched or loaded. "
|
|
208
|
+
"Use compose() to get the full plan with credit checks, "
|
|
209
|
+
"then step through each tool call in sequence."
|
|
210
|
+
)
|
|
211
|
+
return plan
|