livepilot 1.13.0 → 1.14.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 +137 -0
- package/README.md +6 -6
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/branches/__init__.py +2 -0
- package/mcp_server/branches/types.py +57 -1
- package/mcp_server/composer/__init__.py +2 -2
- package/mcp_server/composer/branch_producer.py +120 -0
- package/mcp_server/composer/tools.py +58 -1
- package/mcp_server/evaluation/policy.py +98 -0
- package/mcp_server/experiment/models.py +40 -1
- package/mcp_server/experiment/tools.py +283 -15
- package/mcp_server/server.py +1 -0
- package/mcp_server/synthesis_brain/adapters/analog.py +158 -52
- package/mcp_server/synthesis_brain/adapters/drift.py +156 -51
- package/mcp_server/synthesis_brain/adapters/meld.py +150 -40
- package/mcp_server/synthesis_brain/adapters/operator.py +137 -14
- package/mcp_server/synthesis_brain/adapters/wavetable.py +156 -20
- package/mcp_server/synthesis_brain/tools.py +231 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
"""Operator adapter — native-synth-aware branch production for Ableton's Operator.
|
|
2
2
|
|
|
3
3
|
FM synthesis is defined by operator ratios + algorithm topology + per-op
|
|
4
|
-
envelopes. PR9
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
envelopes. PR9 always targeted Oscillator B because modulator role was
|
|
5
|
+
unknown; PR2/v2 decodes Algorithm → which oscillators are actually
|
|
6
|
+
carriers vs modulators, and targets the modulator with the highest Level
|
|
7
|
+
so the ratio shift produces a real timbral change rather than changing
|
|
8
|
+
an inaudible operator.
|
|
9
|
+
|
|
10
|
+
Strategy: ratio_shift_<operator>. Each seed's producer_payload captures:
|
|
11
|
+
{schema_version, device_name, track_index, device_index,
|
|
12
|
+
strategy: "ratio_shift_<op>",
|
|
13
|
+
topology_hint: {algorithm, carriers, modulators, targeted_op,
|
|
14
|
+
current_coarse, new_coarse}}
|
|
15
|
+
|
|
16
|
+
so later render-verification can confirm the shift actually altered the
|
|
17
|
+
modulated carrier's spectrum.
|
|
7
18
|
"""
|
|
8
19
|
|
|
9
20
|
from __future__ import annotations
|
|
@@ -42,6 +53,81 @@ _KNOWN_PARAMS = {
|
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
|
|
56
|
+
# Static topology table for Ableton Operator's 11 algorithms. Each entry
|
|
57
|
+
# names the ops that act as carriers (audible) and modulators (FM sources).
|
|
58
|
+
# Source: Ableton Operator manual, DX7-compatible topologies. The exact
|
|
59
|
+
# modulation routing within an algorithm (who modulates whom) matters less
|
|
60
|
+
# for adapter targeting than the carrier/modulator role — what we need to
|
|
61
|
+
# know is "which op's Coarse, when shifted, produces an audible timbral
|
|
62
|
+
# change?" Answer: any op acting as a modulator.
|
|
63
|
+
#
|
|
64
|
+
# Algorithm numbering follows Ableton's 0-based display order.
|
|
65
|
+
_ALGO_TOPOLOGY: dict[int, dict] = {
|
|
66
|
+
0: {"carriers": ["D"], "modulators": ["A", "B", "C"]},
|
|
67
|
+
1: {"carriers": ["B", "D"], "modulators": ["A", "C"]},
|
|
68
|
+
2: {"carriers": ["B", "C", "D"], "modulators": ["A"]},
|
|
69
|
+
3: {"carriers": ["D"], "modulators": ["A", "B", "C"]},
|
|
70
|
+
4: {"carriers": ["C", "D"], "modulators": ["A", "B"]},
|
|
71
|
+
5: {"carriers": ["A", "B", "C", "D"], "modulators": []},
|
|
72
|
+
6: {"carriers": ["B", "D"], "modulators": ["A", "C"]},
|
|
73
|
+
7: {"carriers": ["D"], "modulators": ["A", "B", "C"]},
|
|
74
|
+
8: {"carriers": ["B", "C", "D"], "modulators": ["A"]},
|
|
75
|
+
9: {"carriers": ["A", "B", "C", "D"], "modulators": []},
|
|
76
|
+
10: {"carriers": ["B", "C", "D"], "modulators": ["A"]},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _topology_for_algorithm(algorithm: int) -> dict:
|
|
81
|
+
"""Look up carrier/modulator roles for an Operator algorithm index."""
|
|
82
|
+
# Default to algorithm 0 (classic serial chain) if unknown.
|
|
83
|
+
return _ALGO_TOPOLOGY.get(int(algorithm or 0), _ALGO_TOPOLOGY[0])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pick_target_modulator(
|
|
87
|
+
topology: dict,
|
|
88
|
+
parameter_state: dict,
|
|
89
|
+
) -> Optional[str]:
|
|
90
|
+
"""Pick the modulator with the highest Level — the best shift target.
|
|
91
|
+
|
|
92
|
+
Returns the operator letter ("A".."D") or None when the algorithm has
|
|
93
|
+
no modulators (purely additive algos 5 and 9). Level-based selection
|
|
94
|
+
ensures the Coarse shift produces an audible change; shifting an
|
|
95
|
+
op whose Level is 0 is a no-op.
|
|
96
|
+
"""
|
|
97
|
+
candidates = []
|
|
98
|
+
for op in topology.get("modulators", []):
|
|
99
|
+
level_key = f"Oscillator {op} Level"
|
|
100
|
+
level = float(parameter_state.get(level_key, 0.0) or 0.0)
|
|
101
|
+
candidates.append((level, op))
|
|
102
|
+
if not candidates:
|
|
103
|
+
return None
|
|
104
|
+
candidates.sort(reverse=True) # highest level first
|
|
105
|
+
top_level, top_op = candidates[0]
|
|
106
|
+
# If every modulator is silent, still return the first one — the shift
|
|
107
|
+
# primes the patch for a future Level bump. Better than no branch.
|
|
108
|
+
return top_op
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _fallback_carrier_target(
|
|
112
|
+
topology: dict,
|
|
113
|
+
parameter_state: dict,
|
|
114
|
+
) -> Optional[str]:
|
|
115
|
+
"""Fallback when algorithm has no modulators (additive algos).
|
|
116
|
+
|
|
117
|
+
Picks the carrier with the highest Level; shifting its Coarse changes
|
|
118
|
+
the fundamental spectrum rather than FM depth.
|
|
119
|
+
"""
|
|
120
|
+
candidates = []
|
|
121
|
+
for op in topology.get("carriers", []):
|
|
122
|
+
level_key = f"Oscillator {op} Level"
|
|
123
|
+
level = float(parameter_state.get(level_key, 0.0) or 0.0)
|
|
124
|
+
candidates.append((level, op))
|
|
125
|
+
if not candidates:
|
|
126
|
+
return None
|
|
127
|
+
candidates.sort(reverse=True)
|
|
128
|
+
return candidates[0][1]
|
|
129
|
+
|
|
130
|
+
|
|
45
131
|
@register_adapter
|
|
46
132
|
class OperatorAdapter:
|
|
47
133
|
"""Adapter for Ableton's native Operator."""
|
|
@@ -117,20 +203,50 @@ class OperatorAdapter:
|
|
|
117
203
|
|
|
118
204
|
results: list[tuple[BranchSeed, dict]] = []
|
|
119
205
|
|
|
120
|
-
# ── Branch A: ratio_shift
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
|
|
206
|
+
# ── Branch A: algorithm-aware ratio_shift ────────────────────
|
|
207
|
+
# Decode the current Algorithm, pick the real modulator (highest
|
|
208
|
+
# Level among the algorithm's modulator ops), and shift its
|
|
209
|
+
# Coarse. When the algorithm is purely additive (no modulators),
|
|
210
|
+
# fall back to shifting the dominant carrier — changes the
|
|
211
|
+
# fundamental spectrum instead of FM depth.
|
|
212
|
+
algorithm = int(profile.parameter_state.get("Algorithm", 0) or 0)
|
|
213
|
+
topology = _topology_for_algorithm(algorithm)
|
|
214
|
+
targeted_op = _pick_target_modulator(topology, profile.parameter_state)
|
|
215
|
+
target_role = "modulator"
|
|
216
|
+
|
|
217
|
+
if targeted_op is None:
|
|
218
|
+
targeted_op = _fallback_carrier_target(topology, profile.parameter_state)
|
|
219
|
+
target_role = "carrier"
|
|
220
|
+
if targeted_op is None:
|
|
221
|
+
# No usable operators — skip the branch rather than emit
|
|
222
|
+
# something that can't produce a timbral change.
|
|
223
|
+
return results
|
|
224
|
+
|
|
225
|
+
coarse_key = f"Oscillator {targeted_op} Coarse"
|
|
226
|
+
current_coarse = int(profile.parameter_state.get(coarse_key, 1) or 1)
|
|
126
227
|
step = 1 if freshness < 0.5 else 2
|
|
127
228
|
new_coarse = min(24, current_coarse + step)
|
|
128
229
|
if new_coarse == current_coarse:
|
|
129
230
|
new_coarse = max(1, current_coarse - step)
|
|
231
|
+
|
|
232
|
+
strategy = f"ratio_shift_{targeted_op}"
|
|
233
|
+
topology_hint = {
|
|
234
|
+
"algorithm": algorithm,
|
|
235
|
+
"carriers": list(topology.get("carriers", [])),
|
|
236
|
+
"modulators": list(topology.get("modulators", [])),
|
|
237
|
+
"targeted_op": targeted_op,
|
|
238
|
+
"target_role": target_role,
|
|
239
|
+
"current_coarse": current_coarse,
|
|
240
|
+
"new_coarse": new_coarse,
|
|
241
|
+
}
|
|
242
|
+
|
|
130
243
|
seed_a = freeform_seed(
|
|
131
|
-
seed_id=_short_id(
|
|
244
|
+
seed_id=_short_id(
|
|
245
|
+
"op_ratio", f"{track}:{device}:{algorithm}:{targeted_op}:{new_coarse}"
|
|
246
|
+
),
|
|
132
247
|
hypothesis=(
|
|
133
|
-
f"Shift Operator Osc
|
|
248
|
+
f"Shift Operator Osc {targeted_op} ({target_role}) Coarse "
|
|
249
|
+
f"{current_coarse} → {new_coarse} under algorithm {algorithm} "
|
|
134
250
|
f"for a {'subtle' if step == 1 else 'significant'} FM tone change"
|
|
135
251
|
),
|
|
136
252
|
source="synthesis",
|
|
@@ -141,8 +257,15 @@ class OperatorAdapter:
|
|
|
141
257
|
"device_paths": [f"track/{track}/device/{device}"],
|
|
142
258
|
},
|
|
143
259
|
distinctness_reason=(
|
|
144
|
-
"
|
|
260
|
+
f"algorithm-{algorithm} aware shift on {target_role} Osc {targeted_op}"
|
|
145
261
|
),
|
|
262
|
+
producer_payload={
|
|
263
|
+
"device_name": self.device_name,
|
|
264
|
+
"track_index": track,
|
|
265
|
+
"device_index": device,
|
|
266
|
+
"strategy": strategy,
|
|
267
|
+
"topology_hint": topology_hint,
|
|
268
|
+
},
|
|
146
269
|
)
|
|
147
270
|
plan_a = {
|
|
148
271
|
"steps": [
|
|
@@ -151,13 +274,13 @@ class OperatorAdapter:
|
|
|
151
274
|
"params": {
|
|
152
275
|
"track_index": track,
|
|
153
276
|
"device_index": device,
|
|
154
|
-
"parameter_name":
|
|
277
|
+
"parameter_name": coarse_key,
|
|
155
278
|
"value": new_coarse,
|
|
156
279
|
},
|
|
157
280
|
},
|
|
158
281
|
],
|
|
159
282
|
"step_count": 1,
|
|
160
|
-
"summary": f"Osc
|
|
283
|
+
"summary": f"Osc {targeted_op} Coarse {current_coarse} → {new_coarse} (algo {algorithm})",
|
|
161
284
|
}
|
|
162
285
|
results.append((seed_a, plan_a))
|
|
163
286
|
|
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
"""Wavetable adapter — native-synth-aware branch production for Ableton's Wavetable.
|
|
2
2
|
|
|
3
|
-
Knows the relevant parameter names
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Knows the relevant parameter names. PR9 shipped two canned proposers;
|
|
4
|
+
PR2/v2 adds position-region classification so the shift direction and
|
|
5
|
+
magnitude depend on *where* in the wavetable the patch currently sits,
|
|
6
|
+
not just freshness.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
Strategies (selected based on profile + region):
|
|
9
|
+
- osc_position_to_bright: shift toward the bright/complex end when
|
|
10
|
+
the current position is sub_region or mid_region and the target
|
|
11
|
+
timbre asks for brightness.
|
|
12
|
+
- osc_position_to_dark: shift toward sub/mid when starting bright and
|
|
13
|
+
the profile or target prefers warmth.
|
|
14
|
+
- voice_width_variant: increase unison voices + detune for width,
|
|
15
|
+
unless the patch is already over-thickened.
|
|
16
|
+
|
|
17
|
+
Each seed's producer_payload captures:
|
|
18
|
+
{schema_version, device_name, track_index, device_index,
|
|
19
|
+
strategy, topology_hint: {current_region, target_region,
|
|
20
|
+
current_pos, new_pos}}
|
|
21
|
+
so PR4 render-verification and future position-to-spectrum mappings can
|
|
22
|
+
refine the heuristic without losing provenance.
|
|
23
|
+
|
|
24
|
+
Known limitation: region classification is a coarse heuristic on the
|
|
25
|
+
raw Osc 1 Pos float. Specific factory wavetables don't always follow
|
|
26
|
+
the "low value = simple, high value = complex" rule. PR4's render-based
|
|
27
|
+
mapping will refine per-wavetable — producer_payload's topology_hint
|
|
28
|
+
is the contract for that upgrade.
|
|
10
29
|
"""
|
|
11
30
|
|
|
12
31
|
from __future__ import annotations
|
|
@@ -45,6 +64,76 @@ _KNOWN_PARAMS = {
|
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
|
|
67
|
+
# Coarse position → region mapping. Most Ableton factory wavetables fade
|
|
68
|
+
# from low-harmonic (position 0) toward high-harmonic (position 1), but
|
|
69
|
+
# this is approximate. PR4 will refine with render-based spectral mapping.
|
|
70
|
+
_WAVETABLE_REGIONS: list[tuple[float, float, str]] = [
|
|
71
|
+
(0.0, 0.25, "sub_region"),
|
|
72
|
+
(0.25, 0.5, "mid_region"),
|
|
73
|
+
(0.5, 0.75, "bright_region"),
|
|
74
|
+
(0.75, 1.01, "complex_region"),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _classify_position(pos: float) -> str:
|
|
79
|
+
"""Map an Osc 1 Pos float to a coarse spectral region name."""
|
|
80
|
+
for lo, hi, region in _WAVETABLE_REGIONS:
|
|
81
|
+
if lo <= pos < hi:
|
|
82
|
+
return region
|
|
83
|
+
return "complex_region"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _choose_target_region(
|
|
87
|
+
current_region: str,
|
|
88
|
+
target: "TimbralFingerprint",
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Pick a contrasting region based on the target fingerprint.
|
|
91
|
+
|
|
92
|
+
When the target asks for more brightness, move toward
|
|
93
|
+
bright_region/complex_region. When it asks for more warmth or less
|
|
94
|
+
brightness (negative target.brightness), move toward
|
|
95
|
+
sub_region/mid_region. When the target is neutral, shift one region
|
|
96
|
+
away from current for contrast.
|
|
97
|
+
"""
|
|
98
|
+
want_bright = target.brightness
|
|
99
|
+
if abs(want_bright) < 0.1:
|
|
100
|
+
# Neutral target — shift one region away for variety.
|
|
101
|
+
fallback_map = {
|
|
102
|
+
"sub_region": "mid_region",
|
|
103
|
+
"mid_region": "bright_region",
|
|
104
|
+
"bright_region": "mid_region",
|
|
105
|
+
"complex_region": "bright_region",
|
|
106
|
+
}
|
|
107
|
+
return fallback_map.get(current_region, "mid_region")
|
|
108
|
+
|
|
109
|
+
if want_bright > 0:
|
|
110
|
+
# Bias brighter.
|
|
111
|
+
upshift = {
|
|
112
|
+
"sub_region": "mid_region",
|
|
113
|
+
"mid_region": "bright_region",
|
|
114
|
+
"bright_region": "complex_region",
|
|
115
|
+
"complex_region": "complex_region",
|
|
116
|
+
}
|
|
117
|
+
return upshift.get(current_region, "bright_region")
|
|
118
|
+
|
|
119
|
+
# want_bright < 0 — bias darker.
|
|
120
|
+
downshift = {
|
|
121
|
+
"complex_region": "bright_region",
|
|
122
|
+
"bright_region": "mid_region",
|
|
123
|
+
"mid_region": "sub_region",
|
|
124
|
+
"sub_region": "sub_region",
|
|
125
|
+
}
|
|
126
|
+
return downshift.get(current_region, "sub_region")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _region_center(region: str) -> float:
|
|
130
|
+
"""Middle of the region's position range — the target for a shift."""
|
|
131
|
+
for lo, hi, name in _WAVETABLE_REGIONS:
|
|
132
|
+
if name == region:
|
|
133
|
+
return round((lo + min(hi, 1.0)) / 2.0, 3)
|
|
134
|
+
return 0.5
|
|
135
|
+
|
|
136
|
+
|
|
48
137
|
@register_adapter
|
|
49
138
|
class WavetableAdapter:
|
|
50
139
|
"""Adapter for Ableton's native Wavetable."""
|
|
@@ -125,21 +214,47 @@ class WavetableAdapter:
|
|
|
125
214
|
|
|
126
215
|
results: list[tuple[BranchSeed, dict]] = []
|
|
127
216
|
|
|
128
|
-
# ── Branch A:
|
|
129
|
-
#
|
|
130
|
-
#
|
|
217
|
+
# ── Branch A: region-aware Osc 1 Position shift ──────────────
|
|
218
|
+
# Classify current position into a spectral region, pick a
|
|
219
|
+
# contrasting target region based on the timbral target, then
|
|
220
|
+
# shift to that region's center. The actual shift magnitude
|
|
221
|
+
# (how close to the center) scales with freshness — low
|
|
222
|
+
# freshness stops partway, high freshness commits fully.
|
|
131
223
|
current_pos = float(profile.parameter_state.get("Osc 1 Position", 0.0) or 0.0)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
224
|
+
current_region = _classify_position(current_pos)
|
|
225
|
+
target_region = _choose_target_region(current_region, target)
|
|
226
|
+
region_target_pos = _region_center(target_region)
|
|
227
|
+
|
|
228
|
+
# Blend: low freshness only moves partway toward the target region,
|
|
229
|
+
# high freshness commits fully.
|
|
230
|
+
blend = 0.4 if freshness < 0.5 else 1.0
|
|
231
|
+
new_pos = round(
|
|
232
|
+
current_pos + (region_target_pos - current_pos) * blend, 3
|
|
233
|
+
)
|
|
234
|
+
new_pos = max(0.0, min(1.0, new_pos))
|
|
235
|
+
|
|
236
|
+
# Strategy name reflects direction (pick name from target region).
|
|
237
|
+
if target_region in ("bright_region", "complex_region"):
|
|
238
|
+
strategy = "osc_position_to_bright"
|
|
239
|
+
elif target_region in ("sub_region", "mid_region"):
|
|
240
|
+
strategy = "osc_position_to_dark"
|
|
136
241
|
else:
|
|
137
|
-
|
|
242
|
+
strategy = "osc_position_shift"
|
|
243
|
+
|
|
244
|
+
topology_hint = {
|
|
245
|
+
"current_region": current_region,
|
|
246
|
+
"target_region": target_region,
|
|
247
|
+
"current_pos": current_pos,
|
|
248
|
+
"new_pos": new_pos,
|
|
249
|
+
}
|
|
250
|
+
|
|
138
251
|
seed_a = freeform_seed(
|
|
139
|
-
seed_id=_short_id(
|
|
252
|
+
seed_id=_short_id(
|
|
253
|
+
"wt_pos", f"{track}:{device}:{current_region}:{target_region}:{new_pos:.2f}"
|
|
254
|
+
),
|
|
140
255
|
hypothesis=(
|
|
141
|
-
f"Shift Wavetable Osc 1 Position
|
|
142
|
-
f"for a
|
|
256
|
+
f"Shift Wavetable Osc 1 Position {current_pos:.2f} ({current_region}) → "
|
|
257
|
+
f"{new_pos:.2f} ({target_region}) for a {strategy.split('_to_')[-1]} spectrum"
|
|
143
258
|
),
|
|
144
259
|
source="synthesis",
|
|
145
260
|
novelty_label="strong" if freshness < 0.7 else "unexpected",
|
|
@@ -148,7 +263,16 @@ class WavetableAdapter:
|
|
|
148
263
|
"track_indices": [track],
|
|
149
264
|
"device_paths": [f"track/{track}/device/{device}"],
|
|
150
265
|
},
|
|
151
|
-
distinctness_reason=
|
|
266
|
+
distinctness_reason=(
|
|
267
|
+
f"moves Osc 1 Position from {current_region} to {target_region}"
|
|
268
|
+
),
|
|
269
|
+
producer_payload={
|
|
270
|
+
"device_name": self.device_name,
|
|
271
|
+
"track_index": track,
|
|
272
|
+
"device_index": device,
|
|
273
|
+
"strategy": strategy,
|
|
274
|
+
"topology_hint": topology_hint,
|
|
275
|
+
},
|
|
152
276
|
)
|
|
153
277
|
plan_a = {
|
|
154
278
|
"steps": [
|
|
@@ -158,12 +282,12 @@ class WavetableAdapter:
|
|
|
158
282
|
"track_index": track,
|
|
159
283
|
"device_index": device,
|
|
160
284
|
"parameter_name": "Osc 1 Position",
|
|
161
|
-
"value":
|
|
285
|
+
"value": new_pos,
|
|
162
286
|
},
|
|
163
287
|
},
|
|
164
288
|
],
|
|
165
289
|
"step_count": 1,
|
|
166
|
-
"summary": f"Osc 1 Position {current_pos:.2f} → {new_pos:.2f}",
|
|
290
|
+
"summary": f"Osc 1 Position {current_pos:.2f} ({current_region}) → {new_pos:.2f} ({target_region})",
|
|
167
291
|
}
|
|
168
292
|
results.append((seed_a, plan_a))
|
|
169
293
|
|
|
@@ -193,6 +317,18 @@ class WavetableAdapter:
|
|
|
193
317
|
"only seed that changes voice count + detune; focuses on "
|
|
194
318
|
"width rather than spectrum"
|
|
195
319
|
),
|
|
320
|
+
producer_payload={
|
|
321
|
+
"device_name": self.device_name,
|
|
322
|
+
"track_index": track,
|
|
323
|
+
"device_index": device,
|
|
324
|
+
"strategy": "voice_width_variant",
|
|
325
|
+
"topology_hint": {
|
|
326
|
+
"current_voices": int(current_voices),
|
|
327
|
+
"current_detune": current_detune,
|
|
328
|
+
"new_voices": int(new_voices),
|
|
329
|
+
"new_detune": new_detune,
|
|
330
|
+
},
|
|
331
|
+
},
|
|
196
332
|
)
|
|
197
333
|
plan_b = {
|
|
198
334
|
"steps": [
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""MCP tools for the synthesis_brain subsystem (PR5/v2).
|
|
2
|
+
|
|
3
|
+
Four user-facing wrappers that expose the Python-callable internals
|
|
4
|
+
through the MCP surface. Thin wrappers by design — all intelligence
|
|
5
|
+
lives in adapters/engine.py; these tools handle ctx plumbing, param
|
|
6
|
+
rehydration, and response shaping.
|
|
7
|
+
|
|
8
|
+
Tools:
|
|
9
|
+
analyze_synth_patch(track_index, device_index, role_hint?)
|
|
10
|
+
→ SynthProfile dict for any supported native synth on the given
|
|
11
|
+
track+device. Fetches parameter state via get_device_parameters
|
|
12
|
+
and display_values via get_display_values.
|
|
13
|
+
|
|
14
|
+
propose_synth_branches(track_index, device_index, target?, freshness?)
|
|
15
|
+
→ List of (seed_dict, compiled_plan) pairs. Feed directly to
|
|
16
|
+
create_experiment(seeds=seed_dicts, compiled_plans=plans) OR
|
|
17
|
+
skip the plans list to have run_experiment compile from move_id
|
|
18
|
+
(not applicable here — synthesis seeds always ship with plans).
|
|
19
|
+
|
|
20
|
+
extract_timbre_fingerprint(spectrum?, loudness?, spectral_shape?)
|
|
21
|
+
→ TimbralFingerprint dict. Pure transform; no I/O. Callers that
|
|
22
|
+
have already captured audio analysis dicts can convert them to
|
|
23
|
+
a fingerprint without going through the full render-verify path.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from fastmcp import Context
|
|
31
|
+
|
|
32
|
+
from ..server import mcp
|
|
33
|
+
from .engine import analyze_synth_patch as _analyze_synth_patch
|
|
34
|
+
from .engine import propose_synth_branches as _propose_synth_branches
|
|
35
|
+
from .engine import supported_devices
|
|
36
|
+
from .timbre import extract_timbre_fingerprint as _extract_fp
|
|
37
|
+
from .models import TimbralFingerprint
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_ableton(ctx: Context):
|
|
45
|
+
return ctx.lifespan_context["ableton"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@mcp.tool()
|
|
49
|
+
def analyze_synth_patch(
|
|
50
|
+
ctx: Context,
|
|
51
|
+
track_index: int,
|
|
52
|
+
device_index: int,
|
|
53
|
+
role_hint: str = "",
|
|
54
|
+
) -> dict:
|
|
55
|
+
"""Extract a SynthProfile for a native synth on the given track+device.
|
|
56
|
+
|
|
57
|
+
Fetches live parameter state + display_values from Ableton, then hands
|
|
58
|
+
them to the synthesis_brain adapter for that device. When the device
|
|
59
|
+
isn't a supported native (Wavetable / Operator / Analog / Drift / Meld),
|
|
60
|
+
returns an opaque SynthProfile — raw params survive for manual inspection
|
|
61
|
+
but no strategies are proposed.
|
|
62
|
+
|
|
63
|
+
role_hint: optional tag ("pad", "lead", "bass", "pluck", "stab",
|
|
64
|
+
"drone") that gates adapter strategy selection. Leave empty when
|
|
65
|
+
the role is ambiguous.
|
|
66
|
+
|
|
67
|
+
Returns: SynthProfile dict with device_name, opacity, track_index,
|
|
68
|
+
device_index, parameter_state, display_values, role_hint, modulation,
|
|
69
|
+
articulation, notes.
|
|
70
|
+
"""
|
|
71
|
+
ableton = _get_ableton(ctx)
|
|
72
|
+
try:
|
|
73
|
+
info = ableton.send_command(
|
|
74
|
+
"get_device_info",
|
|
75
|
+
{"track_index": track_index, "device_index": device_index},
|
|
76
|
+
)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
return {"error": f"get_device_info failed: {exc}"}
|
|
79
|
+
if not isinstance(info, dict) or "error" in info:
|
|
80
|
+
return {"error": info.get("error") if isinstance(info, dict) else "device not found"}
|
|
81
|
+
|
|
82
|
+
device_name = info.get("name") or info.get("class_name") or ""
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
params_result = ableton.send_command(
|
|
86
|
+
"get_device_parameters",
|
|
87
|
+
{"track_index": track_index, "device_index": device_index},
|
|
88
|
+
)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
return {"error": f"get_device_parameters failed: {exc}"}
|
|
91
|
+
|
|
92
|
+
parameter_state: dict = {}
|
|
93
|
+
display_values: dict = {}
|
|
94
|
+
if isinstance(params_result, dict):
|
|
95
|
+
for p in params_result.get("parameters", []) or []:
|
|
96
|
+
name = p.get("name")
|
|
97
|
+
if name is None:
|
|
98
|
+
continue
|
|
99
|
+
parameter_state[name] = p.get("value")
|
|
100
|
+
if "value_string" in p:
|
|
101
|
+
display_values[name] = p["value_string"]
|
|
102
|
+
|
|
103
|
+
profile = _analyze_synth_patch(
|
|
104
|
+
device_name=device_name,
|
|
105
|
+
track_index=int(track_index),
|
|
106
|
+
device_index=int(device_index),
|
|
107
|
+
parameter_state=parameter_state,
|
|
108
|
+
display_values=display_values,
|
|
109
|
+
role_hint=role_hint,
|
|
110
|
+
)
|
|
111
|
+
result = profile.to_dict()
|
|
112
|
+
result["supported_devices"] = supported_devices()
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@mcp.tool()
|
|
117
|
+
def propose_synth_branches(
|
|
118
|
+
ctx: Context,
|
|
119
|
+
track_index: int,
|
|
120
|
+
device_index: int,
|
|
121
|
+
target: Optional[dict] = None,
|
|
122
|
+
freshness: float = 0.5,
|
|
123
|
+
role_hint: str = "",
|
|
124
|
+
) -> dict:
|
|
125
|
+
"""Propose branch seeds + pre-compiled plans for a native synth.
|
|
126
|
+
|
|
127
|
+
Fetches the device's current parameters (via analyze_synth_patch),
|
|
128
|
+
hands them to the appropriate adapter, and returns the emitted
|
|
129
|
+
(seed, plan) pairs as two parallel lists suitable for
|
|
130
|
+
create_experiment(seeds=..., compiled_plans=...).
|
|
131
|
+
|
|
132
|
+
target: optional TimbralFingerprint dict ({"brightness": 0.3, ...}).
|
|
133
|
+
Seeds that know about target direction (synthesis_brain adapters)
|
|
134
|
+
will score their diffs against it during run_experiment with
|
|
135
|
+
render_verify=True. When omitted, adapters shift based on freshness
|
|
136
|
+
alone and role_hint gating.
|
|
137
|
+
|
|
138
|
+
freshness: 0.0-1.0; threaded into kernel for adapter magnitude scaling.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
{
|
|
142
|
+
"device_name": str,
|
|
143
|
+
"branch_count": int,
|
|
144
|
+
"seeds": [BranchSeed.to_dict(), ...],
|
|
145
|
+
"compiled_plans": [plan_dict, ...] (parallel to seeds),
|
|
146
|
+
"warnings": list,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
Each seed's producer_payload captures strategy + topology_hint so
|
|
150
|
+
PR3/PR4 winner-commit and render-verify can refine behavior without
|
|
151
|
+
losing provenance.
|
|
152
|
+
"""
|
|
153
|
+
profile_dict = analyze_synth_patch(
|
|
154
|
+
ctx, track_index=int(track_index), device_index=int(device_index),
|
|
155
|
+
role_hint=role_hint,
|
|
156
|
+
)
|
|
157
|
+
if "error" in profile_dict:
|
|
158
|
+
return profile_dict
|
|
159
|
+
|
|
160
|
+
device_name = profile_dict.get("device_name", "")
|
|
161
|
+
if profile_dict.get("opacity") != "native":
|
|
162
|
+
return {
|
|
163
|
+
"device_name": device_name,
|
|
164
|
+
"branch_count": 0,
|
|
165
|
+
"seeds": [],
|
|
166
|
+
"compiled_plans": [],
|
|
167
|
+
"warnings": [
|
|
168
|
+
f"'{device_name}' is not a supported native synth — "
|
|
169
|
+
f"synthesis_brain only knows about {supported_devices()}"
|
|
170
|
+
],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Rehydrate the SynthProfile from the dict (round-trip is lossy for
|
|
174
|
+
# some nested fields, so we re-analyze with the original parameter
|
|
175
|
+
# state captured by analyze_synth_patch).
|
|
176
|
+
from .engine import analyze_synth_patch as _refetch
|
|
177
|
+
profile = _refetch(
|
|
178
|
+
device_name=device_name,
|
|
179
|
+
track_index=int(track_index),
|
|
180
|
+
device_index=int(device_index),
|
|
181
|
+
parameter_state=profile_dict.get("parameter_state") or {},
|
|
182
|
+
display_values=profile_dict.get("display_values") or {},
|
|
183
|
+
role_hint=role_hint or profile_dict.get("role_hint", ""),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
target_fp = TimbralFingerprint(**{
|
|
187
|
+
k: float(v) for k, v in (target or {}).items()
|
|
188
|
+
if k in TimbralFingerprint.__dataclass_fields__ and isinstance(v, (int, float))
|
|
189
|
+
})
|
|
190
|
+
kernel = {"freshness": float(freshness)}
|
|
191
|
+
|
|
192
|
+
pairs = _propose_synth_branches(profile, target=target_fp, kernel=kernel)
|
|
193
|
+
|
|
194
|
+
seeds = [s.to_dict() for s, _ in pairs]
|
|
195
|
+
plans = [p for _, p in pairs]
|
|
196
|
+
return {
|
|
197
|
+
"device_name": device_name,
|
|
198
|
+
"branch_count": len(seeds),
|
|
199
|
+
"seeds": seeds,
|
|
200
|
+
"compiled_plans": plans,
|
|
201
|
+
"warnings": [],
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@mcp.tool()
|
|
206
|
+
def extract_timbre_fingerprint(
|
|
207
|
+
ctx: Context,
|
|
208
|
+
spectrum: Optional[dict] = None,
|
|
209
|
+
loudness: Optional[dict] = None,
|
|
210
|
+
spectral_shape: Optional[dict] = None,
|
|
211
|
+
) -> dict:
|
|
212
|
+
"""Build a TimbralFingerprint from analysis dicts.
|
|
213
|
+
|
|
214
|
+
Pure transform — no I/O. Useful when you already have spectrum +
|
|
215
|
+
loudness + spectral_shape dicts (e.g. from analyze_spectrum_offline
|
|
216
|
+
+ analyze_loudness + get_spectral_shape) and want the 9-dimensional
|
|
217
|
+
fingerprint without going through the full render-verify pipeline.
|
|
218
|
+
|
|
219
|
+
Inputs are all optional; the fingerprint degrades gracefully to
|
|
220
|
+
neutral (all-zero) when no signal data is present.
|
|
221
|
+
|
|
222
|
+
Returns: TimbralFingerprint dict with brightness, warmth, bite,
|
|
223
|
+
softness, instability, width, texture_density, movement, polish
|
|
224
|
+
— each in [-1.0, 1.0].
|
|
225
|
+
"""
|
|
226
|
+
fp = _extract_fp(
|
|
227
|
+
spectrum=spectrum,
|
|
228
|
+
loudness=loudness,
|
|
229
|
+
spectral_shape=spectral_shape,
|
|
230
|
+
)
|
|
231
|
+
return fp.to_dict()
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
5
|
+
"description": "Agentic production system for Ableton Live 12 — 402 tools, 52 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
|
|
6
6
|
"author": "Pilot Studio",
|
|
7
7
|
"license": "BSL-1.1",
|
|
8
8
|
"type": "commonjs",
|
|
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
|
|
|
5
5
|
when this script is selected in Preferences > Link, Tempo & MIDI.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "1.
|
|
8
|
+
__version__ = "1.14.0"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from . import router
|