livepilot 1.12.2 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/README.md +7 -7
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/branches/__init__.py +34 -0
  7. package/mcp_server/branches/types.py +286 -0
  8. package/mcp_server/composer/__init__.py +10 -1
  9. package/mcp_server/composer/branch_producer.py +349 -0
  10. package/mcp_server/composer/tools.py +58 -1
  11. package/mcp_server/evaluation/policy.py +227 -2
  12. package/mcp_server/experiment/engine.py +47 -11
  13. package/mcp_server/experiment/models.py +112 -8
  14. package/mcp_server/experiment/tools.py +502 -38
  15. package/mcp_server/memory/taste_graph.py +84 -11
  16. package/mcp_server/persistence/taste_store.py +21 -5
  17. package/mcp_server/runtime/session_kernel.py +46 -0
  18. package/mcp_server/runtime/tools.py +29 -3
  19. package/mcp_server/server.py +1 -0
  20. package/mcp_server/synthesis_brain/__init__.py +53 -0
  21. package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
  22. package/mcp_server/synthesis_brain/adapters/analog.py +273 -0
  23. package/mcp_server/synthesis_brain/adapters/base.py +86 -0
  24. package/mcp_server/synthesis_brain/adapters/drift.py +271 -0
  25. package/mcp_server/synthesis_brain/adapters/meld.py +261 -0
  26. package/mcp_server/synthesis_brain/adapters/operator.py +292 -0
  27. package/mcp_server/synthesis_brain/adapters/wavetable.py +364 -0
  28. package/mcp_server/synthesis_brain/engine.py +91 -0
  29. package/mcp_server/synthesis_brain/models.py +121 -0
  30. package/mcp_server/synthesis_brain/timbre.py +194 -0
  31. package/mcp_server/synthesis_brain/tools.py +231 -0
  32. package/mcp_server/tools/_conductor.py +144 -0
  33. package/mcp_server/wonder_mode/engine.py +324 -0
  34. package/mcp_server/wonder_mode/tools.py +153 -1
  35. package/package.json +2 -2
  36. package/remote_script/LivePilot/__init__.py +1 -1
  37. package/server.json +3 -3
@@ -75,13 +75,37 @@ class TasteGraph:
75
75
  # Device preferences
76
76
  device_affinities: dict[str, DeviceAffinity] = field(default_factory=dict)
77
77
 
78
- # Novelty tolerance: 0 = very conservative, 1 = very experimental
79
- novelty_band: float = 0.5
78
+ # PR8 per-goal-mode novelty bands. Canonical source of truth.
79
+ # Keys are goal modes ("improve", "explore", or any string a caller
80
+ # supplies). novelty_band (below, as a property) reads/writes
81
+ # novelty_bands["improve"] — one storage, two access paths.
82
+ novelty_bands: dict = field(
83
+ default_factory=lambda: {"improve": 0.5, "explore": 0.5}
84
+ )
85
+
86
+ # PR8 — when True, rank_moves returns uniform taste scores (0.5) to
87
+ # bypass taste filtering during branch generation. Callers flip this
88
+ # for fresh / surprise-me mode so novelty survives to post-hoc ranking.
89
+ bypass_taste_in_generation: bool = False
80
90
 
81
91
  # Total evidence count (how many decisions informed this graph)
82
92
  evidence_count: int = 0
83
93
  last_updated_ms: int = 0
84
94
 
95
+ @property
96
+ def novelty_band(self) -> float:
97
+ """Legacy flat novelty band — mirrors novelty_bands["improve"].
98
+
99
+ Kept for back-compat with callers that set/read novelty_band
100
+ directly. Setting this property writes through to the bands dict
101
+ so there's no dual source of truth.
102
+ """
103
+ return self.novelty_bands.get("improve", 0.5)
104
+
105
+ @novelty_band.setter
106
+ def novelty_band(self, value: float) -> None:
107
+ self.novelty_bands["improve"] = float(value)
108
+
85
109
  def to_dict(self) -> dict:
86
110
  return {
87
111
  "dimension_weights": self.dimension_weights,
@@ -96,7 +120,11 @@ class TasteGraph:
96
120
  key=lambda x: -x[1].affinity,
97
121
  )[:10] # Top 10 only
98
122
  },
123
+ # novelty_band kept for legacy consumers that read it directly;
124
+ # novelty_bands is the canonical per-goal-mode shape going forward.
99
125
  "novelty_band": round(self.novelty_band, 3),
126
+ "novelty_bands": {k: round(v, 3) for k, v in self.novelty_bands.items()},
127
+ "bypass_taste_in_generation": self.bypass_taste_in_generation,
100
128
  "evidence_count": self.evidence_count,
101
129
  }
102
130
 
@@ -154,21 +182,53 @@ class TasteGraph:
154
182
  self.evidence_count += 1
155
183
  self.last_updated_ms = now
156
184
 
157
- def update_novelty_from_experiment(self, chose_bold: bool) -> None:
158
- """Shift novelty band based on experiment choices."""
185
+ def update_novelty_from_experiment(
186
+ self, chose_bold: bool, goal_mode: str = "improve",
187
+ ) -> None:
188
+ """Shift novelty band for a given goal mode based on experiment choices.
189
+
190
+ PR8: goal_mode defaults to "improve" so legacy callers land on the
191
+ same band they updated before. Pass "explore" to shift the
192
+ exploration-mode band without touching improve-mode preference.
193
+ (novelty_band is a property view over novelty_bands["improve"], so
194
+ improve-mode updates automatically surface there too.)
195
+ """
196
+ current = self.novelty_bands.get(goal_mode, 0.5)
159
197
  if chose_bold:
160
- self.novelty_band = min(1.0, self.novelty_band + 0.05)
198
+ new_val = min(1.0, current + 0.05)
161
199
  else:
162
- self.novelty_band = max(0.0, self.novelty_band - 0.05)
200
+ new_val = max(0.0, current - 0.05)
201
+ self.novelty_bands[goal_mode] = new_val
163
202
 
164
203
  # ── Ranking ──────────────────────────────────────────────────────
165
204
 
166
- def rank_moves(self, move_specs: list[dict]) -> list[dict]:
205
+ def rank_moves(
206
+ self,
207
+ move_specs: list[dict],
208
+ goal_mode: str = "improve",
209
+ ) -> list[dict]:
167
210
  """Rank a list of semantic move dicts by taste fit.
168
211
 
169
212
  Each move dict should have: move_id, family, targets, risk_level.
170
213
  Returns the same dicts with added 'taste_score' field, sorted desc.
214
+
215
+ PR8 additions:
216
+ goal_mode (str, default "improve"): which novelty band to use for
217
+ risk alignment. "improve" respects the user's conservative history;
218
+ "explore" uses the explore-mode band so past timid choices don't
219
+ punish surprise-me branch generation.
220
+ bypass_taste_in_generation (instance flag): when True, every move
221
+ scores a uniform 0.5. Used during branch generation so taste
222
+ doesn't prune novelty before the user has a chance to audition.
223
+ Ranking order is preserved from input when this flag is on.
171
224
  """
225
+ if self.bypass_taste_in_generation:
226
+ return [dict(move, taste_score=0.5) for move in move_specs]
227
+
228
+ # Read the band for the requested mode. Falls back to the improve
229
+ # band (via self.novelty_band property) when the mode is unknown.
230
+ novelty_band = self.novelty_bands.get(goal_mode, self.novelty_band)
231
+
172
232
  ranked = []
173
233
  for move in move_specs:
174
234
  taste_score = 0.5 # Neutral baseline
@@ -190,10 +250,10 @@ class TasteGraph:
190
250
  if dim in self.dimension_avoidances:
191
251
  taste_score -= 0.3
192
252
 
193
- # Novelty/risk alignment
253
+ # Novelty/risk alignment (PR8: per-mode band)
194
254
  risk = move.get("risk_level", "low")
195
255
  risk_val = {"low": 0.2, "medium": 0.5, "high": 0.8}.get(risk, 0.5)
196
- novelty_match = 1.0 - abs(risk_val - self.novelty_band)
256
+ novelty_match = 1.0 - abs(risk_val - novelty_band)
197
257
  taste_score += novelty_match * 0.1
198
258
 
199
259
  # Clamp
@@ -297,8 +357,21 @@ def build_taste_graph(
297
357
  if total > 0:
298
358
  fam.score = round((fam.kept_count - fam.undone_count) / total, 3)
299
359
 
300
- # Novelty band
301
- graph.novelty_band = persisted.get("novelty_band", 0.5)
360
+ # Novelty band — migrate from flat float if present, OR read
361
+ # per-mode dict if newer persistence format has it (PR8).
362
+ persisted_band = persisted.get("novelty_band", 0.5)
363
+ persisted_bands = persisted.get("novelty_bands")
364
+ if isinstance(persisted_bands, dict) and persisted_bands:
365
+ graph.novelty_bands = {
366
+ k: float(v) for k, v in persisted_bands.items() if isinstance(v, (int, float))
367
+ }
368
+ # Ensure both canonical keys are present with sensible defaults.
369
+ graph.novelty_bands.setdefault("improve", persisted_band)
370
+ graph.novelty_bands.setdefault("explore", persisted_band)
371
+ graph.novelty_band = graph.novelty_bands["improve"]
372
+ else:
373
+ graph.novelty_band = persisted_band
374
+ graph.novelty_bands = {"improve": persisted_band, "explore": persisted_band}
302
375
 
303
376
  # Device affinities
304
377
  for dev_name, dev_data in persisted.get("device_affinities", {}).items():
@@ -48,15 +48,29 @@ class PersistentTasteStore:
48
48
  return data
49
49
  self._store.update(_update)
50
50
 
51
- def update_novelty(self, chose_bold: bool) -> None:
52
- """Update novelty band from experiment choice."""
51
+ def update_novelty(self, chose_bold: bool, goal_mode: str = "improve") -> None:
52
+ """Update novelty band from experiment choice for a given goal mode.
53
+
54
+ PR8: goal_mode defaults to "improve" so legacy callers land on the
55
+ same band they updated before. The per-mode dict ``novelty_bands``
56
+ is maintained alongside the flat ``novelty_band`` field; the flat
57
+ field mirrors the "improve" band.
58
+ """
53
59
  def _update(data: dict) -> dict:
54
60
  data = data if data.get("version") == 1 else self._default()
55
- band = data.get("novelty_band", 0.5)
61
+ # Ensure the per-mode dict exists (migrating from legacy shape).
62
+ bands = data.get("novelty_bands")
63
+ if not isinstance(bands, dict) or not bands:
64
+ flat = data.get("novelty_band", 0.5)
65
+ bands = {"improve": flat, "explore": flat}
66
+ current = bands.get(goal_mode, 0.5)
56
67
  if chose_bold:
57
- data["novelty_band"] = min(1.0, band + 0.05)
68
+ bands[goal_mode] = min(1.0, current + 0.05)
58
69
  else:
59
- data["novelty_band"] = max(0.0, band - 0.05)
70
+ bands[goal_mode] = max(0.0, current - 0.05)
71
+ data["novelty_bands"] = bands
72
+ # Mirror the improve band onto the flat field for back-compat.
73
+ data["novelty_band"] = bands.get("improve", 0.5)
60
74
  data["evidence_count"] = data.get("evidence_count", 0) + 1
61
75
  return data
62
76
  self._store.update(_update)
@@ -114,6 +128,8 @@ class PersistentTasteStore:
114
128
  "version": 1,
115
129
  "move_outcomes": {},
116
130
  "novelty_band": 0.5,
131
+ # PR8 — per-goal-mode novelty bands; novelty_band mirrors "improve"
132
+ "novelty_bands": {"improve": 0.5, "explore": 0.5},
117
133
  "device_affinities": {},
118
134
  "anti_preferences": [],
119
135
  "dimension_weights": {},
@@ -45,6 +45,37 @@ class SessionKernel:
45
45
  recommended_engines: list = field(default_factory=list)
46
46
  recommended_workflow: str = ""
47
47
 
48
+ # ── Creative controls (PR2 — branch-native migration) ──────────────
49
+ # All optional. Producers (Wonder, synthesis_brain, composer) read these
50
+ # to bias branch generation. Pre-PR2 callers leave them at defaults and
51
+ # nothing changes. PR6 (Wonder refactor) and PR9 (synthesis_brain) start
52
+ # reading them in earnest.
53
+
54
+ # 0.0 = conservative / don't surprise me; 1.0 = surprise me.
55
+ # Distinct from aggression (which is about execution boldness).
56
+ freshness: float = 0.5
57
+
58
+ # Shorthand producer philosophy tag. The sample_engine already uses
59
+ # "surgeon" / "alchemist" (see livepilot-sample-engine); synth work
60
+ # may add "sculptor". Empty string = producer picks a default.
61
+ creativity_profile: str = ""
62
+
63
+ # Caller-asserted sacred elements. Normally sacred elements come from
64
+ # song_brain; this lets the user or a skill override. Shape matches
65
+ # song_brain.sacred_elements entries: {element_type, description, salience}.
66
+ sacred_elements: list = field(default_factory=list)
67
+
68
+ # Hints for synthesis_brain: which tracks/devices to focus on and what
69
+ # target timbre to aim for. Shape is open in PR2 and will be firmed up
70
+ # when PR9 adds the first adapters.
71
+ # {
72
+ # "track_indices": [int, ...],
73
+ # "device_paths": ["track/Wavetable", ...],
74
+ # "target_timbre": {"brightness": +0.3, "width": +0.2, ...},
75
+ # "preferred_devices": ["Wavetable", "Operator", ...],
76
+ # }
77
+ synth_hints: dict = field(default_factory=dict)
78
+
48
79
  def to_dict(self) -> dict:
49
80
  return asdict(self)
50
81
 
@@ -60,12 +91,23 @@ def build_session_kernel(
60
91
  taste_graph: Optional[dict] = None,
61
92
  anti_preferences: Optional[list] = None,
62
93
  protected_dimensions: Optional[dict] = None,
94
+ # PR2 — creative controls. All optional; legacy callers unaffected.
95
+ freshness: float = 0.5,
96
+ creativity_profile: str = "",
97
+ sacred_elements: Optional[list] = None,
98
+ synth_hints: Optional[dict] = None,
63
99
  ) -> SessionKernel:
64
100
  """Build a SessionKernel from raw data.
65
101
 
66
102
  All optional fields degrade gracefully to empty defaults.
67
103
  The kernel_id is deterministic from the core inputs so it's stable
68
104
  within the same turn context.
105
+
106
+ The PR2 creative-control fields (freshness, creativity_profile,
107
+ sacred_elements, synth_hints) are intentionally excluded from the
108
+ kernel_id hash so existing callers see no identity changes. Producers
109
+ that need these fields to influence identity can compose their own
110
+ derived id downstream.
69
111
  """
70
112
  # Deterministic kernel_id from inputs
71
113
  id_seed = json.dumps(
@@ -93,4 +135,8 @@ def build_session_kernel(
93
135
  taste_graph=taste_graph or {},
94
136
  anti_preferences=anti_preferences or [],
95
137
  protected_dimensions=protected_dimensions or {},
138
+ freshness=freshness,
139
+ creativity_profile=creativity_profile,
140
+ sacred_elements=sacred_elements or [],
141
+ synth_hints=synth_hints or {},
96
142
  )
@@ -7,6 +7,8 @@ Tools:
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from typing import Optional
11
+
10
12
  from fastmcp import Context
11
13
 
12
14
  from ..server import mcp
@@ -79,6 +81,10 @@ def get_session_kernel(
79
81
  request_text: str = "",
80
82
  mode: str = "improve",
81
83
  aggression: float = 0.5,
84
+ freshness: float = 0.5,
85
+ creativity_profile: str = "",
86
+ sacred_elements: Optional[list] = None,
87
+ synth_hints: Optional[dict] = None,
82
88
  ) -> dict:
83
89
  """Build the unified turn snapshot for V2 orchestration.
84
90
 
@@ -86,11 +92,27 @@ def get_session_kernel(
86
92
  Assembles: session info, capability state, action ledger, taste profile,
87
93
  anti-preferences, and session memory into one canonical snapshot.
88
94
 
89
- mode: observe | improve | explore | finish | diagnose
90
- aggression: 0.0 (subtle) to 1.0 (bold)
95
+ Core params:
96
+ mode: observe | improve | explore | finish | diagnose
97
+ aggression: 0.0 (subtle) to 1.0 (bold) — execution boldness.
98
+
99
+ Creative controls (PR2 — branch-native migration, optional):
100
+ freshness: 0.0 (don't surprise me) to 1.0 (surprise me). Read by
101
+ producers (Wonder, synthesis_brain, composer) to bias branch
102
+ generation. Distinct from aggression, which is about applying
103
+ a single move boldly; freshness is about how far to roam.
104
+ creativity_profile: shorthand producer philosophy tag. Known values
105
+ include "surgeon" (targeted), "alchemist" (transformative),
106
+ "sculptor" (synthesis-focused). Empty ⇒ producer picks a default.
107
+ sacred_elements: caller-asserted list of sacred elements that
108
+ override or augment what song_brain infers. Shape matches
109
+ song_brain entries: {element_type, description, salience}.
110
+ synth_hints: focus hints for synthesis_brain; shape is open in PR2
111
+ and firms up in PR9. Typical keys: track_indices, device_paths,
112
+ target_timbre, preferred_devices.
91
113
 
92
114
  Returns: SessionKernel dict with kernel_id, session topology, capabilities,
93
- memory context, and routing hints.
115
+ memory context, routing hints, and (if provided) creative controls.
94
116
  """
95
117
  from .session_kernel import build_session_kernel
96
118
 
@@ -179,6 +201,10 @@ def get_session_kernel(
179
201
  session_memory=session_mem,
180
202
  taste_graph=taste_graph,
181
203
  anti_preferences=anti_prefs,
204
+ freshness=freshness,
205
+ creativity_profile=creativity_profile,
206
+ sacred_elements=sacred_elements,
207
+ synth_hints=synth_hints,
182
208
  )
183
209
 
184
210
  # Populate routing hints from conductor when request context is available
@@ -305,6 +305,7 @@ from .device_forge import tools as device_forge_tools # noqa: F401, E40
305
305
  from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
306
306
  from .atlas import tools as atlas_tools # noqa: F401, E402
307
307
  from .composer import tools as composer_tools # noqa: F401, E402
308
+ from .synthesis_brain import tools as synthesis_brain_tools # noqa: F401, E402
308
309
  from .tools import diagnostics # noqa: F401, E402
309
310
  from .tools import miditool # noqa: F401, E402
310
311
 
@@ -0,0 +1,53 @@
1
+ """Synthesis brain — native-synth-aware branch production.
2
+
3
+ Parallel subsystem to mcp_server.sound_design. Where sound_design reasons
4
+ at the block-type level (oscillator / filter / envelope / ...), the
5
+ synthesis brain reasons at the native-device level with per-adapter
6
+ knowledge of Wavetable / Operator / Analog / Drift / Meld parameter
7
+ spaces, modulation graphs, and articulation profiles.
8
+
9
+ Each adapter implements the SynthAdapter protocol:
10
+ - device_name: the Ableton device name it claims
11
+ - extract_profile(device_parameters): read a SynthProfile from live params
12
+ - propose_branches(profile, target, kernel): emit BranchSeed objects plus
13
+ pre-compiled plans (set_device_parameter / batch_set_parameters steps)
14
+
15
+ PR9 ships Wavetable and Operator adapters. PR10 adds Analog, Drift, Meld
16
+ and render-based timbre extraction on top of ``capture_audio``.
17
+
18
+ No MCP @tool() decorators in this PR — the subsystem is callable from
19
+ Python (Wonder's generate_branch_seeds, composer, etc.). PR12 wires
20
+ dedicated MCP tools and does the tool-count metadata sweep in one pass.
21
+ """
22
+
23
+ from .models import (
24
+ SynthProfile,
25
+ TimbralFingerprint,
26
+ ModulationGraph,
27
+ ArticulationProfile,
28
+ OPAQUE,
29
+ NATIVE,
30
+ )
31
+ from .adapters import get_adapter, SynthAdapter
32
+ from .engine import (
33
+ analyze_synth_patch,
34
+ propose_synth_branches,
35
+ supported_devices,
36
+ )
37
+ from .timbre import extract_timbre_fingerprint, diff_fingerprint
38
+
39
+ __all__ = [
40
+ "SynthProfile",
41
+ "TimbralFingerprint",
42
+ "ModulationGraph",
43
+ "ArticulationProfile",
44
+ "OPAQUE",
45
+ "NATIVE",
46
+ "SynthAdapter",
47
+ "get_adapter",
48
+ "analyze_synth_patch",
49
+ "propose_synth_branches",
50
+ "supported_devices",
51
+ "extract_timbre_fingerprint",
52
+ "diff_fingerprint",
53
+ ]
@@ -0,0 +1,34 @@
1
+ """Synth adapter registry.
2
+
3
+ Each adapter is a Python class implementing the SynthAdapter protocol.
4
+ Registration is explicit — adapters add themselves to ``_REGISTRY`` at
5
+ module import time. ``get_adapter(device_name)`` returns the registered
6
+ adapter or None.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .base import SynthAdapter, _REGISTRY, register_adapter
12
+ from . import wavetable as _wavetable # noqa: F401 — import for registration
13
+ from . import operator as _operator # noqa: F401
14
+ from . import analog as _analog # noqa: F401
15
+ from . import drift as _drift # noqa: F401
16
+ from . import meld as _meld # noqa: F401
17
+
18
+
19
+ def get_adapter(device_name: str) -> SynthAdapter | None:
20
+ """Return the adapter for a given Ableton device name, or None."""
21
+ return _REGISTRY.get(device_name)
22
+
23
+
24
+ def registered_devices() -> list[str]:
25
+ """List device names this package has an adapter for."""
26
+ return sorted(_REGISTRY.keys())
27
+
28
+
29
+ __all__ = [
30
+ "SynthAdapter",
31
+ "get_adapter",
32
+ "register_adapter",
33
+ "registered_devices",
34
+ ]