livepilot 1.12.2 → 1.13.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 (34) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +3 -3
  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 +32 -0
  7. package/mcp_server/branches/types.py +230 -0
  8. package/mcp_server/composer/__init__.py +10 -1
  9. package/mcp_server/composer/branch_producer.py +229 -0
  10. package/mcp_server/evaluation/policy.py +129 -2
  11. package/mcp_server/experiment/engine.py +47 -11
  12. package/mcp_server/experiment/models.py +72 -7
  13. package/mcp_server/experiment/tools.py +231 -35
  14. package/mcp_server/memory/taste_graph.py +84 -11
  15. package/mcp_server/persistence/taste_store.py +21 -5
  16. package/mcp_server/runtime/session_kernel.py +46 -0
  17. package/mcp_server/runtime/tools.py +29 -3
  18. package/mcp_server/synthesis_brain/__init__.py +53 -0
  19. package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
  20. package/mcp_server/synthesis_brain/adapters/analog.py +167 -0
  21. package/mcp_server/synthesis_brain/adapters/base.py +86 -0
  22. package/mcp_server/synthesis_brain/adapters/drift.py +166 -0
  23. package/mcp_server/synthesis_brain/adapters/meld.py +151 -0
  24. package/mcp_server/synthesis_brain/adapters/operator.py +169 -0
  25. package/mcp_server/synthesis_brain/adapters/wavetable.py +228 -0
  26. package/mcp_server/synthesis_brain/engine.py +91 -0
  27. package/mcp_server/synthesis_brain/models.py +121 -0
  28. package/mcp_server/synthesis_brain/timbre.py +194 -0
  29. package/mcp_server/tools/_conductor.py +144 -0
  30. package/mcp_server/wonder_mode/engine.py +324 -0
  31. package/mcp_server/wonder_mode/tools.py +153 -1
  32. package/package.json +2 -2
  33. package/remote_script/LivePilot/__init__.py +1 -1
  34. package/server.json +2 -2
@@ -0,0 +1,121 @@
1
+ """Synthesis-brain data models.
2
+
3
+ Pure dataclasses — zero I/O. Shape is intentionally minimal in PR9;
4
+ later PRs firm up fields as adapters discover what's actually useful.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+ from typing import Literal, Optional
11
+
12
+
13
+ # Device opacity markers — natives are inspectable via device parameters,
14
+ # opaque plugins (AU / VST) are not. Adapters are registered for natives only.
15
+ NATIVE = "native"
16
+ OPAQUE = "opaque"
17
+
18
+ DeviceOpacity = Literal["native", "opaque"]
19
+
20
+
21
+ @dataclass
22
+ class TimbralFingerprint:
23
+ """A compact per-device timbre target.
24
+
25
+ All dimensions are floats in [-1.0, 1.0]; 0.0 means "no change from
26
+ whatever the source patch is". This intentionally mirrors the existing
27
+ TimbralGoalVector in sound_design.models so the two subsystems can
28
+ share goal inputs.
29
+ """
30
+
31
+ brightness: float = 0.0
32
+ warmth: float = 0.0
33
+ bite: float = 0.0
34
+ softness: float = 0.0
35
+ instability: float = 0.0
36
+ width: float = 0.0
37
+ texture_density: float = 0.0
38
+ movement: float = 0.0
39
+ polish: float = 0.0
40
+
41
+ def to_dict(self) -> dict:
42
+ return asdict(self)
43
+
44
+
45
+ @dataclass
46
+ class ModulationGraph:
47
+ """Flat list of modulation routes on a single device.
48
+
49
+ Each route: {source, target, amount, range}. Shape is deliberately
50
+ loose because natives differ (Wavetable has LFO routing, Operator
51
+ has a per-osc modulation matrix, Analog has FM + Envelope routing).
52
+ Adapters populate it in a device-consistent way.
53
+ """
54
+
55
+ routes: list[dict] = field(default_factory=list)
56
+
57
+ def to_dict(self) -> dict:
58
+ return {"routes": list(self.routes)}
59
+
60
+
61
+ @dataclass
62
+ class ArticulationProfile:
63
+ """How a patch responds to note-on / note-off / velocity.
64
+
65
+ attack_ms / release_ms are envelope rough times; velocity_mapping is
66
+ a tag ("linear", "exponential", "flat"); mono indicates mono-only
67
+ mode (portamento hints live here in later PRs).
68
+ """
69
+
70
+ attack_ms: float = 0.0
71
+ release_ms: float = 0.0
72
+ velocity_mapping: str = "linear"
73
+ mono: bool = False
74
+
75
+ def to_dict(self) -> dict:
76
+ return asdict(self)
77
+
78
+
79
+ @dataclass
80
+ class SynthProfile:
81
+ """Extracted per-device patch state.
82
+
83
+ Fields:
84
+ device_name: the Ableton device name ("Wavetable", "Operator", ...)
85
+ opacity: NATIVE ⇒ adapter knows this device; OPAQUE ⇒ fallback path
86
+ track_index / device_index: where the device lives in the session
87
+ parameter_state: raw ``{name: value}`` dict from get_device_parameters;
88
+ adapters translate this into structured knowledge
89
+ display_values: parallel ``{name: value_string}`` when available
90
+ (lets adapters reason about actual Hz / dB / % rather than 0-1 floats)
91
+ role_hint: caller-supplied role ("pad", "lead", "bass", "perc", ...) or ""
92
+ modulation: the device's current modulation graph
93
+ articulation: envelope + velocity response
94
+ notes: free-form observations the adapter wants to record for downstream
95
+ reasoning (e.g. "voices=4, detune=0.12 — subtly rich already")
96
+ """
97
+
98
+ device_name: str = ""
99
+ opacity: DeviceOpacity = OPAQUE
100
+ track_index: int = -1
101
+ device_index: int = -1
102
+ parameter_state: dict = field(default_factory=dict)
103
+ display_values: dict = field(default_factory=dict)
104
+ role_hint: str = ""
105
+ modulation: ModulationGraph = field(default_factory=ModulationGraph)
106
+ articulation: ArticulationProfile = field(default_factory=ArticulationProfile)
107
+ notes: list[str] = field(default_factory=list)
108
+
109
+ def to_dict(self) -> dict:
110
+ return {
111
+ "device_name": self.device_name,
112
+ "opacity": self.opacity,
113
+ "track_index": self.track_index,
114
+ "device_index": self.device_index,
115
+ "parameter_state": dict(self.parameter_state),
116
+ "display_values": dict(self.display_values),
117
+ "role_hint": self.role_hint,
118
+ "modulation": self.modulation.to_dict(),
119
+ "articulation": self.articulation.to_dict(),
120
+ "notes": list(self.notes),
121
+ }
@@ -0,0 +1,194 @@
1
+ """Render-based timbre extraction.
2
+
3
+ Builds a TimbralFingerprint from captured spectrum / loudness / spectral-shape
4
+ data. The inputs come from existing perception tools (capture_audio →
5
+ analyze_spectrum_offline / analyze_loudness / get_spectral_shape when
6
+ FluCoMa is available).
7
+
8
+ This layer is intentionally pure Python — no I/O. Callers capture audio
9
+ and feed the dicts here. PR10 ships a heuristic first pass; later PRs
10
+ will add model-driven extraction on render-based features.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ from .models import TimbralFingerprint
18
+
19
+
20
+ # ── Band-based brightness / warmth mapping ──────────────────────────────
21
+ #
22
+ # The M4L analyzer returns an 8-band spectrum by default. When a full
23
+ # spectrum dict is passed, we look for these band keys in order. If the
24
+ # raw {freq: magnitude} shape is passed instead, we fall back to a coarser
25
+ # low/mid/high split.
26
+
27
+ _BANDS = ("sub", "low", "low_mid", "mid", "high_mid", "high", "very_high", "ultra")
28
+
29
+
30
+ def _band_energy(spectrum: Optional[dict], band: str) -> float:
31
+ """Read a single band's energy from a spectrum dict. Defaults to 0."""
32
+ if not spectrum:
33
+ return 0.0
34
+ val = spectrum.get(band)
35
+ if val is None and "bands" in spectrum:
36
+ val = spectrum["bands"].get(band)
37
+ try:
38
+ return float(val or 0.0)
39
+ except (TypeError, ValueError):
40
+ return 0.0
41
+
42
+
43
+ def _normalize_to_range(value: float, low: float = -1.0, high: float = 1.0) -> float:
44
+ """Clamp to [-1, 1]."""
45
+ return max(low, min(high, value))
46
+
47
+
48
+ def extract_timbre_fingerprint(
49
+ spectrum: Optional[dict] = None,
50
+ loudness: Optional[dict] = None,
51
+ spectral_shape: Optional[dict] = None,
52
+ ) -> TimbralFingerprint:
53
+ """Build a TimbralFingerprint from captured analysis data.
54
+
55
+ Inputs are all optional — the function degrades gracefully when only
56
+ some dimensions are measurable.
57
+
58
+ spectrum: either {sub, low, low_mid, mid, high_mid, high, very_high, ultra}
59
+ or {"bands": {...}} — the 8-band shape returned by get_master_spectrum /
60
+ analyze_spectrum_offline. Missing bands default to 0.
61
+ loudness: {"rms": float, "peak": float, "lufs": float, "lra": float} —
62
+ output shape from analyze_loudness.
63
+ spectral_shape: FluCoMa descriptors when available — {"centroid", "flatness",
64
+ "rolloff", "crest"} (see get_spectral_shape).
65
+
66
+ Heuristic dimension mapping (each dimension is clamped to [-1, 1]):
67
+ brightness ← (high_mid + high - low_mid) / total, scaled; or centroid / 10000
68
+ warmth ← low_mid / total — classic low-mid richness
69
+ bite ← high_mid / mid — the "bite" frequency balance
70
+ softness ← 1.0 - crest (if present) or 1.0 - peak/rms
71
+ instability ← flatness (if present) — noisier = less stable pitch
72
+ width ← not from single-channel data; left at 0 (stereo support in PR11+)
73
+ texture_density ← flatness proxy — more noise-like = denser texture
74
+ movement ← not from single capture — left at 0
75
+ polish ← rough dynamic-range proxy: rms / peak closer to 1 = less polished
76
+ """
77
+ bands = {b: _band_energy(spectrum, b) for b in _BANDS}
78
+ total = sum(bands.values())
79
+ # Silent/empty input → neutral fingerprint. Band-derived dimensions
80
+ # need real signal to be meaningful; falling back to 0 everywhere is
81
+ # more honest than forcing brightness/warmth into the extremes.
82
+ has_signal = total > 1e-6
83
+ total_safe = total if has_signal else 1.0
84
+
85
+ low_mid = bands["low_mid"]
86
+ mid = bands["mid"] or 0.001
87
+ high_mid = bands["high_mid"]
88
+ high = bands["high"]
89
+
90
+ # brightness ∈ [-1, 1] — bias on high-band presence relative to low-mid
91
+ brightness = (
92
+ _normalize_to_range((high_mid + high - low_mid) / total_safe * 2.0)
93
+ if has_signal else 0.0
94
+ )
95
+ # Prefer spectral centroid when available (model-driven).
96
+ shape = spectral_shape or {}
97
+ centroid = shape.get("centroid")
98
+ if centroid is not None:
99
+ # Centroid typically in Hz — map 200Hz → -0.8, 5000Hz → +0.8.
100
+ try:
101
+ c = float(centroid)
102
+ # Log-scale mapping is fairer; approximate with piecewise linear.
103
+ if c <= 200:
104
+ brightness = -0.8
105
+ elif c >= 5000:
106
+ brightness = 0.8
107
+ else:
108
+ # linear over log(200..5000) ≈ 2.30 .. 3.70
109
+ import math
110
+ t = (math.log10(c) - math.log10(200)) / (math.log10(5000) - math.log10(200))
111
+ brightness = _normalize_to_range(-0.8 + t * 1.6)
112
+ except (TypeError, ValueError):
113
+ pass
114
+
115
+ warmth = (
116
+ _normalize_to_range(low_mid / total_safe * 4.0 - 1.0)
117
+ if has_signal else 0.0
118
+ )
119
+ bite = (
120
+ _normalize_to_range((high_mid / mid) - 1.0)
121
+ if has_signal else 0.0
122
+ )
123
+
124
+ # softness via crest factor (lower crest = more sustained / softer)
125
+ crest = shape.get("crest") if spectral_shape else None
126
+ if crest is not None:
127
+ try:
128
+ softness = _normalize_to_range(1.0 - float(crest) / 10.0)
129
+ except (TypeError, ValueError):
130
+ softness = 0.0
131
+ elif loudness:
132
+ peak = float(loudness.get("peak", 0.0) or 0.0)
133
+ rms = float(loudness.get("rms", 0.0) or 0.0)
134
+ if peak > 0:
135
+ softness = _normalize_to_range(rms / peak * 2.0 - 1.0)
136
+ else:
137
+ softness = 0.0
138
+ else:
139
+ softness = 0.0
140
+
141
+ # instability + texture_density via spectral flatness
142
+ flatness = shape.get("flatness") if spectral_shape else None
143
+ if flatness is not None:
144
+ try:
145
+ f = float(flatness)
146
+ instability = _normalize_to_range(f * 2.0 - 1.0)
147
+ texture_density = _normalize_to_range(f * 2.0 - 1.0)
148
+ except (TypeError, ValueError):
149
+ instability = 0.0
150
+ texture_density = 0.0
151
+ else:
152
+ instability = 0.0
153
+ texture_density = 0.0
154
+
155
+ # polish = inverse of crest dominance (very crest-heavy = unpolished)
156
+ if crest is not None:
157
+ try:
158
+ polish = _normalize_to_range(1.0 - float(crest) / 8.0)
159
+ except (TypeError, ValueError):
160
+ polish = 0.0
161
+ else:
162
+ polish = 0.0
163
+
164
+ return TimbralFingerprint(
165
+ brightness=round(brightness, 3),
166
+ warmth=round(warmth, 3),
167
+ bite=round(bite, 3),
168
+ softness=round(softness, 3),
169
+ instability=round(instability, 3),
170
+ width=0.0, # stereo detection in later PRs
171
+ texture_density=round(texture_density, 3),
172
+ movement=0.0, # single-capture — no movement signal
173
+ polish=round(polish, 3),
174
+ )
175
+
176
+
177
+ def diff_fingerprint(a: TimbralFingerprint, b: TimbralFingerprint) -> dict:
178
+ """Return per-dimension delta a → b.
179
+
180
+ Useful after a branch has been auditioned: capture audio before and
181
+ after, extract fingerprints for each, and diff to see which dimensions
182
+ actually moved.
183
+ """
184
+ return {
185
+ "brightness": round(b.brightness - a.brightness, 3),
186
+ "warmth": round(b.warmth - a.warmth, 3),
187
+ "bite": round(b.bite - a.bite, 3),
188
+ "softness": round(b.softness - a.softness, 3),
189
+ "instability": round(b.instability - a.instability, 3),
190
+ "width": round(b.width - a.width, 3),
191
+ "texture_density": round(b.texture_density - a.texture_density, 3),
192
+ "movement": round(b.movement - a.movement, 3),
193
+ "polish": round(b.polish - a.polish, 3),
194
+ }
@@ -311,3 +311,147 @@ def create_conductor_plan(
311
311
  budget = budgets.create_budget(mode=mode, aggression=aggression)
312
312
  plan.budget = budget.to_dict()
313
313
  return plan
314
+
315
+
316
+ # ── PR4 — creative_search routing fork ──────────────────────────────────
317
+ #
318
+ # Runs only when the user intent is exploratory (workflow_mode =
319
+ # "creative_search"). Adds producer selection on top of the base
320
+ # engine routing so Wonder / synthesis_brain / composer / technique memory
321
+ # can all be consulted for branch seeds. The base classify_request is
322
+ # untouched so every existing caller and test continues to see identical
323
+ # behavior — this path is a parallel, additive classifier.
324
+
325
+
326
+ @dataclass
327
+ class CreativeSearchPlan:
328
+ """Extended routing plan used for creative_search mode.
329
+
330
+ Wraps a ConductorPlan with producer-selection metadata that branch
331
+ assemblers (Wonder / synthesis_brain / composer) act on to generate
332
+ diverse branch seeds.
333
+
334
+ Fields:
335
+ base_plan: the engine routing from classify_request().
336
+ branch_sources: ordered list of producers to consult. Always contains
337
+ "semantic_move" and "freeform"; adds "synthesis", "composer", and
338
+ "technique" based on request content and kernel state.
339
+ seed_hints: per-source hints passed to the producer. Shape:
340
+ {"synthesis": {...kernel.synth_hints...}, "composer": {...}, ...}
341
+ target_branch_count: how many branches to aim for (3 by default;
342
+ matches the safe / strong / unexpected triptych in Preview Studio).
343
+ freshness: 0.0-1.0, threaded from kernel.freshness.
344
+ creativity_profile: from kernel.creativity_profile ("" when absent).
345
+ """
346
+
347
+ base_plan: ConductorPlan
348
+ branch_sources: list[str] = field(default_factory=list)
349
+ seed_hints: dict = field(default_factory=dict)
350
+ target_branch_count: int = 3
351
+ freshness: float = 0.5
352
+ creativity_profile: str = ""
353
+
354
+ def to_dict(self) -> dict:
355
+ d = self.base_plan.to_dict()
356
+ d["creative_search"] = {
357
+ "branch_sources": list(self.branch_sources),
358
+ "seed_hints": dict(self.seed_hints),
359
+ "target_branch_count": self.target_branch_count,
360
+ "freshness": self.freshness,
361
+ "creativity_profile": self.creativity_profile,
362
+ }
363
+ # Creative-search plans always recommend experiments
364
+ d["experiment_recommended"] = True
365
+ return d
366
+
367
+
368
+ # Keyword families that imply a particular producer is worth consulting
369
+ # even when the kernel carries no explicit hint for it.
370
+ _SYNTH_REQUEST = re.compile(
371
+ r"synth|patch|timbre|timbral|oscillat|wavetable|operator|filter|"
372
+ r"modulation|lfo|envelope|drift|meld|analog|detune|spread|"
373
+ r"haunted|lush|aggressive|warm.?pad|fat.?bass|bright.?lead",
374
+ re.IGNORECASE,
375
+ )
376
+ _TECHNIQUE_HINT = re.compile(
377
+ r"like.?last.?time|same.?as|recall|remember|how.?i.?did",
378
+ re.IGNORECASE,
379
+ )
380
+
381
+
382
+ def classify_request_creative(
383
+ request: str,
384
+ kernel: Optional[dict] = None,
385
+ ) -> CreativeSearchPlan:
386
+ """Classify a request for creative_search mode.
387
+
388
+ Builds on classify_request() for engine routing and adds producer
389
+ selection so downstream branch assemblers know which sources to
390
+ consult. This is intentionally additive — callers that don't know
391
+ about creative_search mode can keep using classify_request() and see
392
+ no difference.
393
+
394
+ Producer selection:
395
+ - "semantic_move" is always included (baseline).
396
+ - "synthesis" added when kernel.synth_hints is non-empty OR the
397
+ request mentions synth / patch / timbre / oscillator / filter /
398
+ modulation / etc.
399
+ - "composer" added when base_plan primary engine is "composition".
400
+ - "technique" added when the kernel has enough taste evidence
401
+ (>= 3 recorded move outcomes) OR the request suggests recalling
402
+ a prior technique.
403
+ - "freeform" is always the last option — a catch-all for producers
404
+ that want to emit a seed without matching any structured category.
405
+
406
+ When kernel is None, the function still works — it just skips the
407
+ kernel-driven producer additions (synthesis / technique) unless the
408
+ request text triggers them directly.
409
+ """
410
+ base = classify_request(request)
411
+ kernel = kernel or {}
412
+ request_lower = request.lower()
413
+
414
+ sources: list[str] = ["semantic_move"]
415
+ hints: dict = {}
416
+
417
+ # ── Synthesis producer ──────────────────────────────────────────────
418
+ synth_hints = kernel.get("synth_hints") or {}
419
+ synth_matched_by_request = bool(_SYNTH_REQUEST.search(request_lower))
420
+ if synth_hints or synth_matched_by_request:
421
+ sources.append("synthesis")
422
+ hints["synthesis"] = dict(synth_hints) if synth_hints else {}
423
+ if synth_matched_by_request and not synth_hints:
424
+ hints["synthesis"]["inferred_from_request"] = True
425
+
426
+ # ── Composer producer (only for composition-primary routes) ────────
427
+ if base.routes and base.routes[0].engine == "composition":
428
+ sources.append("composer")
429
+ hints["composer"] = {"request": request}
430
+
431
+ # ── Technique memory producer ──────────────────────────────────────
432
+ taste = kernel.get("taste_graph") or {}
433
+ move_fam = taste.get("move_family_scores") or {}
434
+ evidence = int(taste.get("evidence_count", 0) or 0)
435
+ technique_hinted = bool(_TECHNIQUE_HINT.search(request_lower))
436
+ if technique_hinted or (move_fam and evidence >= 3):
437
+ sources.append("technique")
438
+ preferred = []
439
+ for fam, s in move_fam.items():
440
+ if isinstance(s, dict) and s.get("score", 0) > 0.2:
441
+ preferred.append(fam)
442
+ hints["technique"] = {
443
+ "preferred_families": preferred[:3],
444
+ "hinted_by_request": technique_hinted,
445
+ }
446
+
447
+ # ── Freeform always available ──────────────────────────────────────
448
+ sources.append("freeform")
449
+
450
+ return CreativeSearchPlan(
451
+ base_plan=base,
452
+ branch_sources=sources,
453
+ seed_hints=hints,
454
+ target_branch_count=3,
455
+ freshness=float(kernel.get("freshness", 0.5) or 0.5),
456
+ creativity_profile=kernel.get("creativity_profile", "") or "",
457
+ )