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.
- package/CHANGELOG.md +219 -0
- package/README.md +7 -7
- 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 +34 -0
- package/mcp_server/branches/types.py +286 -0
- package/mcp_server/composer/__init__.py +10 -1
- package/mcp_server/composer/branch_producer.py +349 -0
- package/mcp_server/composer/tools.py +58 -1
- package/mcp_server/evaluation/policy.py +227 -2
- package/mcp_server/experiment/engine.py +47 -11
- package/mcp_server/experiment/models.py +112 -8
- package/mcp_server/experiment/tools.py +502 -38
- package/mcp_server/memory/taste_graph.py +84 -11
- package/mcp_server/persistence/taste_store.py +21 -5
- package/mcp_server/runtime/session_kernel.py +46 -0
- package/mcp_server/runtime/tools.py +29 -3
- package/mcp_server/server.py +1 -0
- package/mcp_server/synthesis_brain/__init__.py +53 -0
- package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
- package/mcp_server/synthesis_brain/adapters/analog.py +273 -0
- package/mcp_server/synthesis_brain/adapters/base.py +86 -0
- package/mcp_server/synthesis_brain/adapters/drift.py +271 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +261 -0
- package/mcp_server/synthesis_brain/adapters/operator.py +292 -0
- package/mcp_server/synthesis_brain/adapters/wavetable.py +364 -0
- package/mcp_server/synthesis_brain/engine.py +91 -0
- package/mcp_server/synthesis_brain/models.py +121 -0
- package/mcp_server/synthesis_brain/timbre.py +194 -0
- package/mcp_server/synthesis_brain/tools.py +231 -0
- package/mcp_server/tools/_conductor.py +144 -0
- package/mcp_server/wonder_mode/engine.py +324 -0
- package/mcp_server/wonder_mode/tools.py +153 -1
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -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
|
+
}
|
|
@@ -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()
|
|
@@ -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
|
+
)
|