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.
@@ -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 ships one canned proposer that shifts a carrier/modulator
5
- ratio, which is the highest-leverage single parameter change for FM tone.
6
- Later PRs add algorithm swaps, envelope reshaping, and feedback variants.
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 on modulator B ─────────────────────
121
- # Pick a new Coarse ratio for Oscillator B (a common modulator slot)
122
- # that contrasts with current. 2 → 3 is inharmonic, 1 2 is octave+,
123
- # 3 → 5 is bell-like. Default to +1 coarse step when freshness < 0.5,
124
- # +2 steps when higher.
125
- current_coarse = int(profile.parameter_state.get("Oscillator B Coarse", 1) or 1)
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("op_ratio", f"{track}:{device}:{new_coarse}"),
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 B Coarse from {current_coarse} to {new_coarse} "
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
- "only seed that changes the modulator/carrier ratio on Osc B"
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": "Oscillator B Coarse",
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 B Coarse {current_coarse} → {new_coarse}",
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 and proposes two canned variant
4
- branches per call:
5
- - osc_position_shift: moves Osc 1 position to create a timbral contrast
6
- - voice_width_variant: increases unison voices and detune for width
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
- Later PRs add: modulation-matrix inversion, filter-envelope reshaping,
9
- tuning-table variants, sub/body-layer variants.
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: osc_position_shift ──────────────────────────────
129
- # Moves Osc 1 position to a contrasting point. Safe / incremental
130
- # when freshness < 0.5; more aggressive shift when higher.
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
- shift = 0.2 if freshness < 0.5 else 0.45
133
- # Wrap within [0, 1]; if the current position is high, shift down.
134
- if current_pos + shift > 1.0:
135
- new_pos = max(0.0, current_pos - shift)
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
- new_pos = min(1.0, current_pos + shift)
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("wt_pos", f"{track}:{device}:{new_pos:.2f}"),
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 from {current_pos:.2f} to {new_pos:.2f} "
142
- f"for a contrasting harmonic spectrum"
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="only seed that changes Osc 1 Position",
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": round(new_pos, 3),
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.13.0",
3
+ "version": "1.14.0",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 398 tools, 52 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
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.13.0"
8
+ __version__ = "1.14.0"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router