livepilot 1.10.9 → 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.
- package/CHANGELOG.md +327 -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 +32 -0
- package/mcp_server/branches/types.py +230 -0
- package/mcp_server/composer/__init__.py +10 -1
- package/mcp_server/composer/branch_producer.py +229 -0
- package/mcp_server/evaluation/policy.py +129 -2
- package/mcp_server/experiment/engine.py +47 -11
- package/mcp_server/experiment/models.py +72 -7
- package/mcp_server/experiment/tools.py +231 -35
- package/mcp_server/m4l_bridge.py +488 -13
- package/mcp_server/memory/taste_graph.py +84 -11
- package/mcp_server/persistence/taste_store.py +21 -5
- package/mcp_server/runtime/execution_router.py +7 -0
- package/mcp_server/runtime/mcp_dispatch.py +32 -0
- package/mcp_server/runtime/remote_commands.py +54 -0
- package/mcp_server/runtime/session_kernel.py +46 -0
- package/mcp_server/runtime/tools.py +29 -3
- package/mcp_server/sample_engine/slice_classifier.py +169 -0
- package/mcp_server/server.py +11 -3
- 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 +167 -0
- package/mcp_server/synthesis_brain/adapters/base.py +86 -0
- package/mcp_server/synthesis_brain/adapters/drift.py +166 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +151 -0
- package/mcp_server/synthesis_brain/adapters/operator.py +169 -0
- package/mcp_server/synthesis_brain/adapters/wavetable.py +228 -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/tools/_conductor.py +144 -0
- package/mcp_server/tools/analyzer.py +187 -7
- package/mcp_server/tools/clips.py +65 -0
- package/mcp_server/tools/devices.py +517 -5
- package/mcp_server/tools/diagnostics.py +42 -0
- package/mcp_server/tools/follow_actions.py +202 -0
- package/mcp_server/tools/grooves.py +142 -0
- package/mcp_server/tools/miditool.py +280 -0
- package/mcp_server/tools/scales.py +126 -0
- package/mcp_server/tools/take_lanes.py +135 -0
- package/mcp_server/tools/tracks.py +46 -3
- package/mcp_server/tools/transport.py +62 -1
- 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 +8 -4
- package/remote_script/LivePilot/clips.py +62 -0
- package/remote_script/LivePilot/devices.py +444 -0
- package/remote_script/LivePilot/diagnostics.py +52 -1
- package/remote_script/LivePilot/follow_actions.py +235 -0
- package/remote_script/LivePilot/grooves.py +185 -0
- package/remote_script/LivePilot/scales.py +138 -0
- package/remote_script/LivePilot/take_lanes.py +175 -0
- package/remote_script/LivePilot/tracks.py +59 -1
- package/remote_script/LivePilot/transport.py +90 -1
- package/remote_script/LivePilot/version_detect.py +9 -0
- package/server.json +3 -3
|
@@ -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
|
+
)
|
|
@@ -34,6 +34,36 @@ logger = logging.getLogger(__name__)
|
|
|
34
34
|
CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
# Live 12 Simpler Slice mode maps slice N to MIDI pitch 36+N (C1 base).
|
|
38
|
+
# This is NOT exposed by the Remote Script API and is a common source of
|
|
39
|
+
# silent audio bugs (BUG-F2). See feedback_analyze_slices_before_programming
|
|
40
|
+
# memory for context.
|
|
41
|
+
SIMPLER_SLICE_BASE_PITCH = 36
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _enrich_slice_response(response: Optional[dict]) -> Optional[dict]:
|
|
45
|
+
"""Add base_midi_pitch field + per-slice midi_pitch to bridge response (BUG-F2).
|
|
46
|
+
|
|
47
|
+
The Remote Script returns slice indices only. Users then have to know
|
|
48
|
+
that slice N plays at MIDI pitch 36+N — a fact that's undocumented in
|
|
49
|
+
both Ableton's and LivePilot's public API. This enrichment makes the
|
|
50
|
+
mapping explicit so MIDI pattern generation doesn't silently produce
|
|
51
|
+
out-of-range notes.
|
|
52
|
+
"""
|
|
53
|
+
if response is None:
|
|
54
|
+
return None
|
|
55
|
+
enriched = dict(response)
|
|
56
|
+
enriched["base_midi_pitch"] = SIMPLER_SLICE_BASE_PITCH
|
|
57
|
+
slices = enriched.get("slices") or []
|
|
58
|
+
# BUG-audit-M2: fall back to positional index when the bridge response
|
|
59
|
+
# omits the `index` field (protects against bridge version skew).
|
|
60
|
+
enriched["slices"] = [
|
|
61
|
+
{**s, "midi_pitch": SIMPLER_SLICE_BASE_PITCH + s.get("index", i)}
|
|
62
|
+
for i, s in enumerate(slices)
|
|
63
|
+
]
|
|
64
|
+
return enriched
|
|
65
|
+
|
|
66
|
+
|
|
37
67
|
@mcp.tool()
|
|
38
68
|
async def reconnect_bridge(ctx: Context) -> dict:
|
|
39
69
|
"""Attempt to reconnect the M4L UDP bridge (port 9880).
|
|
@@ -97,11 +127,36 @@ def get_master_spectrum(ctx: Context) -> dict:
|
|
|
97
127
|
return result
|
|
98
128
|
|
|
99
129
|
|
|
130
|
+
def _sanitize_pitch(pitch: Optional[dict]) -> Optional[dict]:
|
|
131
|
+
"""Validate a pitch reading from the M4L analyzer (BUG-F1).
|
|
132
|
+
|
|
133
|
+
The polyphonic pitch detector can emit out-of-range MIDI notes
|
|
134
|
+
(e.g., 319, -50, 128+) when it can't latch onto a single
|
|
135
|
+
fundamental — typical for dense mixes. The amplitude field is the
|
|
136
|
+
reliable confidence signal: if the detector was sure of its
|
|
137
|
+
reading, amplitude is non-zero.
|
|
138
|
+
|
|
139
|
+
Returns the original dict if the reading is usable, None otherwise.
|
|
140
|
+
"""
|
|
141
|
+
if not pitch:
|
|
142
|
+
return None
|
|
143
|
+
amplitude = pitch.get("amplitude")
|
|
144
|
+
midi_note = pitch.get("midi_note")
|
|
145
|
+
if amplitude is None or amplitude <= 0:
|
|
146
|
+
return None
|
|
147
|
+
if midi_note is None or midi_note < 0 or midi_note > 127:
|
|
148
|
+
return None
|
|
149
|
+
return pitch
|
|
150
|
+
|
|
151
|
+
|
|
100
152
|
@mcp.tool()
|
|
101
153
|
def get_master_rms(ctx: Context) -> dict:
|
|
102
154
|
"""Get real-time RMS and peak levels from the master bus.
|
|
103
155
|
|
|
104
156
|
More accurate than LOM meters — includes true RMS (not just peak hold).
|
|
157
|
+
Pitch readings are validated: the field is only present when the
|
|
158
|
+
polyphonic pitch detector produced a reading with non-zero
|
|
159
|
+
amplitude and a MIDI note in [0, 127] (BUG-F1).
|
|
105
160
|
Requires LivePilot Analyzer on master track.
|
|
106
161
|
"""
|
|
107
162
|
cache = _get_spectral(ctx)
|
|
@@ -117,9 +172,11 @@ def get_master_rms(ctx: Context) -> dict:
|
|
|
117
172
|
if peak:
|
|
118
173
|
result["peak"] = peak["value"]
|
|
119
174
|
|
|
120
|
-
|
|
121
|
-
if
|
|
122
|
-
|
|
175
|
+
pitch_entry = cache.get("pitch")
|
|
176
|
+
if pitch_entry:
|
|
177
|
+
clean = _sanitize_pitch(pitch_entry.get("value"))
|
|
178
|
+
if clean is not None:
|
|
179
|
+
result["pitch"] = clean
|
|
123
180
|
|
|
124
181
|
return result
|
|
125
182
|
|
|
@@ -414,15 +471,138 @@ async def get_simpler_slices(
|
|
|
414
471
|
) -> dict:
|
|
415
472
|
"""Get slice point positions from a Simpler device.
|
|
416
473
|
|
|
417
|
-
Returns each slice's position in frames and seconds,
|
|
418
|
-
(
|
|
419
|
-
|
|
474
|
+
Returns each slice's position in frames and seconds, the MIDI pitch
|
|
475
|
+
that triggers it (slice 0 = C1 / MIDI 36, slice 1 = C#1 / MIDI 37, etc.
|
|
476
|
+
per BUG-F2), plus sample metadata (sample rate, length, playback mode).
|
|
477
|
+
|
|
478
|
+
**Always use the returned `midi_pitch` when programming MIDI notes to
|
|
479
|
+
trigger slices.** The Live 12 Simpler Slice-mode base note is C1,
|
|
480
|
+
NOT C3 — writing notes at pitch 60+ on a sample with <24 slices
|
|
481
|
+
triggers nothing and produces silent output.
|
|
482
|
+
|
|
483
|
+
Use this to understand the rhythmic structure of a sliced sample
|
|
484
|
+
and program MIDI patterns targeting slices. Requires LivePilot
|
|
485
|
+
Analyzer on master track.
|
|
486
|
+
"""
|
|
487
|
+
cache = _get_spectral(ctx)
|
|
488
|
+
_require_analyzer(cache)
|
|
489
|
+
bridge = _get_m4l(ctx)
|
|
490
|
+
raw = await bridge.send_command("get_simpler_slices", track_index, device_index)
|
|
491
|
+
return _enrich_slice_response(raw)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@mcp.tool()
|
|
495
|
+
async def classify_simpler_slices(
|
|
496
|
+
ctx: Context,
|
|
497
|
+
track_index: int,
|
|
498
|
+
device_index: int = 0,
|
|
499
|
+
file_path: Optional[str] = None,
|
|
500
|
+
) -> dict:
|
|
501
|
+
"""Classify each Simpler slice as KICK / SNARE / HAT / ghost via FFT analysis.
|
|
502
|
+
|
|
503
|
+
Reads slice positions via ``get_simpler_slices``, loads the backing
|
|
504
|
+
WAV file, and runs 4-band spectral classification on each segment.
|
|
505
|
+
Returns the enriched slice list with a ``label`` field per entry
|
|
506
|
+
plus feature breakdown (peak, rms, sub_pct, low_pct, mid_pct,
|
|
507
|
+
high_pct).
|
|
508
|
+
|
|
509
|
+
**Always run this before programming drum patterns on a sliced
|
|
510
|
+
break.** Slice content depends on transient detection order in the
|
|
511
|
+
source audio — slice 0 is NOT guaranteed to be a kick. Assuming
|
|
512
|
+
drum-rack convention produces wrong grooves that take iterations to
|
|
513
|
+
diagnose (see 2026-04-18 creative session for the canonical case).
|
|
514
|
+
|
|
515
|
+
Classification rules (validated on "Break Ghosts 90 bpm"):
|
|
516
|
+
- KICK: sub+low >= 45%, high < 40%
|
|
517
|
+
- HAT: high >= 70% AND mid < 25% (thin metal disc = no drum body)
|
|
518
|
+
- SNARE: mid >= 25% AND high >= 40% AND peak >= 0.6 (broadband loud)
|
|
519
|
+
- ghost: peak < 0.35
|
|
520
|
+
|
|
521
|
+
Parameters:
|
|
522
|
+
track_index, device_index: the Simpler to analyze
|
|
523
|
+
file_path: (optional) explicit WAV path. If omitted, attempts
|
|
524
|
+
lookup via the bridge. Bridge-native resolution is limited in
|
|
525
|
+
v1.11 — when the sample lives in the Core Library, pass the
|
|
526
|
+
absolute path explicitly.
|
|
527
|
+
|
|
528
|
+
Returns: dict with ``slices`` list. Each slice entry has:
|
|
529
|
+
index, frame, seconds, midi_pitch (36+index), label, peak, rms,
|
|
530
|
+
sub_pct, low_pct, mid_pct, high_pct.
|
|
531
|
+
|
|
420
532
|
Requires LivePilot Analyzer on master track.
|
|
421
533
|
"""
|
|
534
|
+
import soundfile as sf
|
|
535
|
+
|
|
536
|
+
from ..sample_engine.slice_classifier import classify_slices
|
|
537
|
+
|
|
422
538
|
cache = _get_spectral(ctx)
|
|
423
539
|
_require_analyzer(cache)
|
|
424
540
|
bridge = _get_m4l(ctx)
|
|
425
|
-
|
|
541
|
+
|
|
542
|
+
# 1. Get slice positions
|
|
543
|
+
raw_slices = await bridge.send_command(
|
|
544
|
+
"get_simpler_slices", track_index, device_index
|
|
545
|
+
)
|
|
546
|
+
enriched = _enrich_slice_response(raw_slices)
|
|
547
|
+
if enriched is None:
|
|
548
|
+
return {"error": "Bridge returned no slice data"}
|
|
549
|
+
|
|
550
|
+
# 2. Resolve file path
|
|
551
|
+
wav_path = file_path
|
|
552
|
+
if not wav_path:
|
|
553
|
+
try:
|
|
554
|
+
file_info = await bridge.send_command(
|
|
555
|
+
"get_simpler_file_path", track_index, device_index
|
|
556
|
+
)
|
|
557
|
+
if isinstance(file_info, dict):
|
|
558
|
+
wav_path = file_info.get("file_path")
|
|
559
|
+
except Exception: # noqa: BLE001 — bridge command may not exist yet
|
|
560
|
+
wav_path = None
|
|
561
|
+
|
|
562
|
+
if not wav_path:
|
|
563
|
+
return {
|
|
564
|
+
**enriched,
|
|
565
|
+
"error": (
|
|
566
|
+
"No file_path available — pass file_path= explicitly. "
|
|
567
|
+
"Bridge-based lookup for Simpler sample paths is a v1.12 "
|
|
568
|
+
"follow-up."
|
|
569
|
+
),
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
# 3. Load WAV and build frame boundaries
|
|
573
|
+
try:
|
|
574
|
+
audio, sr = sf.read(wav_path)
|
|
575
|
+
except (sf.LibsndfileError, sf.SoundFileError, RuntimeError, OSError) as exc:
|
|
576
|
+
# BUG-audit-C3: corrupt / missing / non-audio files must return a
|
|
577
|
+
# structured error dict instead of raising through the MCP framework
|
|
578
|
+
# (inconsistent with every other tool in this module).
|
|
579
|
+
return {
|
|
580
|
+
**enriched,
|
|
581
|
+
"error": f"Could not load WAV at {wav_path!r}: {exc}",
|
|
582
|
+
}
|
|
583
|
+
slices = enriched["slices"]
|
|
584
|
+
frame_boundaries = [s["frame"] for s in slices] + [len(audio)]
|
|
585
|
+
|
|
586
|
+
# 4. Classify
|
|
587
|
+
classifications = classify_slices(audio, sr, frame_boundaries)
|
|
588
|
+
|
|
589
|
+
# 5. Merge classification into each slice entry
|
|
590
|
+
merged_slices = []
|
|
591
|
+
for slice_entry, features in zip(slices, classifications):
|
|
592
|
+
merged_slices.append({
|
|
593
|
+
**slice_entry,
|
|
594
|
+
"label": features["label"],
|
|
595
|
+
"peak": features["peak"],
|
|
596
|
+
"rms": features["rms"],
|
|
597
|
+
"sub_pct": features["sub_pct"],
|
|
598
|
+
"low_pct": features["low_pct"],
|
|
599
|
+
"mid_pct": features["mid_pct"],
|
|
600
|
+
"high_pct": features["high_pct"],
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
enriched["slices"] = merged_slices
|
|
604
|
+
enriched["classifier_version"] = "v1.0"
|
|
605
|
+
return enriched
|
|
426
606
|
|
|
427
607
|
|
|
428
608
|
@mcp.tool()
|