livepilot 1.10.5 → 1.10.7
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/.claude-plugin/marketplace.json +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/execution_router.py +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +13 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -188,14 +188,30 @@ def build_profile_from_filename(
|
|
|
188
188
|
source: str = "filesystem",
|
|
189
189
|
duration_seconds: float = 0.0,
|
|
190
190
|
) -> SampleProfile:
|
|
191
|
-
"""Build a SampleProfile from filename metadata
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
"""Build a SampleProfile from filename metadata + offline spectral
|
|
192
|
+
analysis (BUG-B49 fix).
|
|
193
|
+
|
|
194
|
+
Filename still supplies key / bpm / material-type hints when
|
|
195
|
+
present, but we now ALSO open the audio file via soundfile and
|
|
196
|
+
compute:
|
|
197
|
+
- duration_seconds (exact)
|
|
198
|
+
- frequency_center / frequency_spread (FFT-based centroid)
|
|
199
|
+
- brightness (high-band energy ratio)
|
|
200
|
+
- transient_density (RMS-gradient peak count)
|
|
201
|
+
- has_clear_downbeat (peak-interval consistency)
|
|
202
|
+
These used to be zeros regardless of file contents — downstream
|
|
203
|
+
critics had no real data.
|
|
204
|
+
|
|
205
|
+
If soundfile isn't available or the file can't be decoded, we
|
|
206
|
+
gracefully fall back to the filename-only path (legacy behavior).
|
|
194
207
|
"""
|
|
195
208
|
name = os.path.splitext(os.path.basename(file_path))[0]
|
|
196
209
|
metadata = parse_filename_metadata(file_path)
|
|
197
210
|
material = classify_material_from_name(name)
|
|
198
211
|
|
|
212
|
+
# Offline spectral analysis — best-effort, never raises.
|
|
213
|
+
spectral = _analyze_audio_file(file_path)
|
|
214
|
+
|
|
199
215
|
profile = SampleProfile(
|
|
200
216
|
source=source,
|
|
201
217
|
file_path=file_path,
|
|
@@ -206,7 +222,14 @@ def build_profile_from_filename(
|
|
|
206
222
|
bpm_confidence=0.5 if metadata.get("bpm") else 0.0,
|
|
207
223
|
material_type=material,
|
|
208
224
|
material_confidence=0.4, # filename-only is low confidence
|
|
209
|
-
duration_seconds=
|
|
225
|
+
duration_seconds=(
|
|
226
|
+
spectral.get("duration_seconds") or duration_seconds
|
|
227
|
+
),
|
|
228
|
+
frequency_center=spectral.get("frequency_center", 0.0),
|
|
229
|
+
frequency_spread=spectral.get("frequency_spread", 0.0),
|
|
230
|
+
brightness=spectral.get("brightness", 0.0),
|
|
231
|
+
transient_density=spectral.get("transient_density", 0.0),
|
|
232
|
+
has_clear_downbeat=spectral.get("has_clear_downbeat", False),
|
|
210
233
|
)
|
|
211
234
|
|
|
212
235
|
profile.suggested_mode = suggest_simpler_mode(profile)
|
|
@@ -214,3 +237,107 @@ def build_profile_from_filename(
|
|
|
214
237
|
profile.suggested_warp_mode = suggest_warp_mode(profile)
|
|
215
238
|
|
|
216
239
|
return profile
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _analyze_audio_file(file_path: str) -> dict:
|
|
243
|
+
"""Read an audio file and compute lightweight spectral/temporal
|
|
244
|
+
features via numpy. Returns {} if the file can't be decoded.
|
|
245
|
+
|
|
246
|
+
Uses soundfile (already a dependency) + numpy FFT — no librosa
|
|
247
|
+
required. Falls back cleanly so file-not-found / unsupported
|
|
248
|
+
format doesn't break the analyzer.
|
|
249
|
+
"""
|
|
250
|
+
try:
|
|
251
|
+
import soundfile as sf
|
|
252
|
+
import numpy as np
|
|
253
|
+
except ImportError:
|
|
254
|
+
return {}
|
|
255
|
+
|
|
256
|
+
if not file_path or not os.path.exists(file_path):
|
|
257
|
+
return {}
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
data, samplerate = sf.read(file_path, dtype="float32")
|
|
261
|
+
except Exception:
|
|
262
|
+
return {}
|
|
263
|
+
|
|
264
|
+
# Downmix to mono
|
|
265
|
+
if data.ndim > 1:
|
|
266
|
+
data = data.mean(axis=1)
|
|
267
|
+
if data.size == 0 or samplerate <= 0:
|
|
268
|
+
return {}
|
|
269
|
+
|
|
270
|
+
duration = float(data.size) / float(samplerate)
|
|
271
|
+
|
|
272
|
+
# Spectral centroid via magnitude-weighted frequency average.
|
|
273
|
+
# Use a Welch-style average over ~50ms windows to stabilize.
|
|
274
|
+
win_len = max(1024, int(samplerate * 0.05))
|
|
275
|
+
hop = win_len // 2
|
|
276
|
+
centroids: list[float] = []
|
|
277
|
+
spreads: list[float] = []
|
|
278
|
+
frames = range(0, max(len(data) - win_len, 1), hop)
|
|
279
|
+
for start in frames:
|
|
280
|
+
frame = data[start:start + win_len]
|
|
281
|
+
if len(frame) < 32:
|
|
282
|
+
continue
|
|
283
|
+
# Hann-window + FFT
|
|
284
|
+
mags = np.abs(np.fft.rfft(frame * np.hanning(len(frame))))
|
|
285
|
+
total = mags.sum()
|
|
286
|
+
if total <= 0:
|
|
287
|
+
continue
|
|
288
|
+
freqs = np.linspace(0, samplerate / 2, len(mags))
|
|
289
|
+
c = float((mags * freqs).sum() / total)
|
|
290
|
+
centroids.append(c)
|
|
291
|
+
# Spectral spread = sqrt(sum(mags * (freqs - c)**2) / total)
|
|
292
|
+
s = float(np.sqrt(((mags * (freqs - c) ** 2).sum()) / total))
|
|
293
|
+
spreads.append(s)
|
|
294
|
+
|
|
295
|
+
if not centroids:
|
|
296
|
+
return {"duration_seconds": duration}
|
|
297
|
+
|
|
298
|
+
frequency_center = float(np.mean(centroids))
|
|
299
|
+
frequency_spread = float(np.mean(spreads))
|
|
300
|
+
# Brightness: fraction of energy above 4kHz
|
|
301
|
+
# Use a single FFT on the whole signal for this (cheap)
|
|
302
|
+
full_mags = np.abs(np.fft.rfft(data * np.hanning(len(data))))
|
|
303
|
+
full_freqs = np.linspace(0, samplerate / 2, len(full_mags))
|
|
304
|
+
total_energy = full_mags.sum() or 1.0
|
|
305
|
+
high_energy = full_mags[full_freqs >= 4000].sum()
|
|
306
|
+
brightness = float(high_energy / total_energy)
|
|
307
|
+
|
|
308
|
+
# Transient density: peak count in rectified-RMS gradient
|
|
309
|
+
# Coarse envelope over ~20ms windows
|
|
310
|
+
env_win = max(256, int(samplerate * 0.02))
|
|
311
|
+
envelope = np.array([
|
|
312
|
+
float(np.sqrt(np.mean(data[i:i + env_win] ** 2)))
|
|
313
|
+
for i in range(0, len(data), env_win)
|
|
314
|
+
])
|
|
315
|
+
if envelope.size > 1:
|
|
316
|
+
diffs = np.diff(envelope)
|
|
317
|
+
# Count upward transitions above a dynamic threshold
|
|
318
|
+
thresh = max(envelope.std() * 1.5, 1e-4)
|
|
319
|
+
peaks = int(np.sum(diffs > thresh))
|
|
320
|
+
transient_density = float(peaks / max(duration, 0.001))
|
|
321
|
+
else:
|
|
322
|
+
transient_density = 0.0
|
|
323
|
+
|
|
324
|
+
# Clear downbeat: peaks evenly spaced
|
|
325
|
+
has_clear_downbeat = False
|
|
326
|
+
if envelope.size > 4:
|
|
327
|
+
# Find top-N peaks and check interval stddev
|
|
328
|
+
peak_positions = np.argsort(envelope)[-8:]
|
|
329
|
+
peak_positions.sort()
|
|
330
|
+
if len(peak_positions) >= 3:
|
|
331
|
+
intervals = np.diff(peak_positions)
|
|
332
|
+
if intervals.size > 0 and float(np.mean(intervals)) > 0:
|
|
333
|
+
cv = float(np.std(intervals)) / float(np.mean(intervals))
|
|
334
|
+
has_clear_downbeat = cv < 0.5 # low variation → steady
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
"duration_seconds": duration,
|
|
338
|
+
"frequency_center": frequency_center,
|
|
339
|
+
"frequency_spread": frequency_spread,
|
|
340
|
+
"brightness": brightness,
|
|
341
|
+
"transient_density": transient_density,
|
|
342
|
+
"has_clear_downbeat": has_clear_downbeat,
|
|
343
|
+
}
|
|
@@ -164,21 +164,36 @@ def run_frequency_fit_critic(
|
|
|
164
164
|
) -> CriticResult:
|
|
165
165
|
"""Score frequency fit against existing mix.
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
BUG-B38 fix: the old stub branch returned a neutral 0.5 "fair"
|
|
168
|
+
score even when the analyzer had no spectral data at all —
|
|
169
|
+
misleading the user into thinking the sample was a middling fit
|
|
170
|
+
when in reality the critic couldn't evaluate anything. We now
|
|
171
|
+
mark the result as explicitly unavailable (score=-1 sentinel +
|
|
172
|
+
available=False + rating="unavailable") so downstream aggregators
|
|
173
|
+
can skip this critic rather than fold a fake 0.5 into the overall
|
|
174
|
+
score.
|
|
168
175
|
"""
|
|
169
176
|
if mix_snapshot is None or not mix_snapshot:
|
|
170
177
|
return CriticResult(
|
|
171
|
-
critic_name="frequency_fit",
|
|
172
|
-
|
|
173
|
-
|
|
178
|
+
critic_name="frequency_fit",
|
|
179
|
+
score=-1.0,
|
|
180
|
+
available=False,
|
|
181
|
+
rating_override="unavailable",
|
|
182
|
+
recommendation=(
|
|
183
|
+
"No mix snapshot available — load LivePilot_Analyzer on "
|
|
184
|
+
"master and call get_mix_snapshot first. Falling back to "
|
|
185
|
+
"by-ear verification."
|
|
186
|
+
),
|
|
174
187
|
)
|
|
175
188
|
|
|
176
189
|
# Basic frequency overlap check using mix_snapshot track data
|
|
177
|
-
# mix_snapshot expected shape: {"tracks": [{"name": ..., "peak_frequency": ...}]}
|
|
178
190
|
tracks = mix_snapshot.get("tracks", [])
|
|
179
191
|
if not tracks:
|
|
180
192
|
return CriticResult(
|
|
181
|
-
critic_name="frequency_fit",
|
|
193
|
+
critic_name="frequency_fit",
|
|
194
|
+
score=-1.0,
|
|
195
|
+
available=False,
|
|
196
|
+
rating_override="unavailable",
|
|
182
197
|
recommendation="Mix snapshot has no track data",
|
|
183
198
|
)
|
|
184
199
|
|
|
@@ -186,8 +201,14 @@ def run_frequency_fit_critic(
|
|
|
186
201
|
sample_center = profile.frequency_center
|
|
187
202
|
if sample_center <= 0:
|
|
188
203
|
return CriticResult(
|
|
189
|
-
critic_name="frequency_fit",
|
|
190
|
-
|
|
204
|
+
critic_name="frequency_fit",
|
|
205
|
+
score=-1.0,
|
|
206
|
+
available=False,
|
|
207
|
+
rating_override="unavailable",
|
|
208
|
+
recommendation=(
|
|
209
|
+
"Sample has no spectral data — analyze_sample couldn't "
|
|
210
|
+
"decode the file, or it's a clip-only reference."
|
|
211
|
+
),
|
|
191
212
|
)
|
|
192
213
|
|
|
193
214
|
# Count tracks with energy near the sample's center frequency
|
|
@@ -82,15 +82,32 @@ class SampleIntent:
|
|
|
82
82
|
|
|
83
83
|
@dataclass
|
|
84
84
|
class CriticResult:
|
|
85
|
-
"""Result from a single sample critic.
|
|
85
|
+
"""Result from a single sample critic.
|
|
86
|
+
|
|
87
|
+
BUG-B38: added `available` + rating override so critics can
|
|
88
|
+
explicitly mark themselves as unevaluated (e.g. no mix snapshot
|
|
89
|
+
for frequency_fit) rather than returning a misleading 0.5 score.
|
|
90
|
+
Downstream aggregators check `available` before folding a critic's
|
|
91
|
+
score into the composite.
|
|
92
|
+
"""
|
|
86
93
|
|
|
87
94
|
critic_name: str
|
|
88
95
|
score: float
|
|
89
96
|
recommendation: str
|
|
90
97
|
adjustments: list = field(default_factory=list)
|
|
98
|
+
# Explicit availability flag — False when critic couldn't evaluate
|
|
99
|
+
# (score will be -1.0 sentinel; aggregators should skip)
|
|
100
|
+
available: bool = True
|
|
101
|
+
# Optional hand-set rating label — overrides the score-based
|
|
102
|
+
# default when provided (used for "unavailable" status)
|
|
103
|
+
rating_override: str = ""
|
|
91
104
|
|
|
92
105
|
@property
|
|
93
106
|
def rating(self) -> str:
|
|
107
|
+
if self.rating_override:
|
|
108
|
+
return self.rating_override
|
|
109
|
+
if not self.available:
|
|
110
|
+
return "unavailable"
|
|
94
111
|
if self.score >= 0.8:
|
|
95
112
|
return "excellent"
|
|
96
113
|
if self.score >= 0.6:
|
|
@@ -102,6 +119,8 @@ class CriticResult:
|
|
|
102
119
|
def to_dict(self) -> dict:
|
|
103
120
|
d = asdict(self)
|
|
104
121
|
d["rating"] = self.rating
|
|
122
|
+
# Strip internal override from payload (not for consumers)
|
|
123
|
+
d.pop("rating_override", None)
|
|
105
124
|
return d
|
|
106
125
|
|
|
107
126
|
|
|
@@ -6,12 +6,15 @@ direct Splice online catalog hunt/download via the gRPC client.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import logging
|
|
9
10
|
import os
|
|
10
11
|
from typing import Optional
|
|
11
12
|
|
|
12
13
|
from fastmcp import Context
|
|
13
14
|
|
|
14
15
|
from ..server import mcp
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
15
18
|
from .models import SampleProfile, SampleIntent, SampleFitReport
|
|
16
19
|
from .analyzer import build_profile_from_filename
|
|
17
20
|
from .critics import run_all_sample_critics
|
|
@@ -47,8 +50,8 @@ async def analyze_sample(
|
|
|
47
50
|
)
|
|
48
51
|
if not result.get("error"):
|
|
49
52
|
file_path = result.get("file_path")
|
|
50
|
-
except Exception:
|
|
51
|
-
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
logger.warning("m4l get_clip_file_path failed: %s", exc)
|
|
52
55
|
|
|
53
56
|
if file_path is None:
|
|
54
57
|
return {"error": "Could not determine file path — provide file_path directly"}
|
|
@@ -97,34 +100,70 @@ def evaluate_sample_fit(
|
|
|
97
100
|
name = track_info.get("name", "").lower()
|
|
98
101
|
if name:
|
|
99
102
|
existing_roles.append(name)
|
|
100
|
-
except Exception:
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
logger.debug("get_track_info(%d) skipped: %s", i, exc)
|
|
101
105
|
continue
|
|
102
106
|
|
|
103
|
-
# Detect key from MIDI tracks
|
|
107
|
+
# Detect key from MIDI tracks.
|
|
108
|
+
# BUG-B37 fix: the old code checked clip_info.get("is_midi") but
|
|
109
|
+
# the Remote Script returns is_midi_clip (different field name),
|
|
110
|
+
# so the check always failed and song_key stayed None —
|
|
111
|
+
# key_fit then reported "Song key unknown" even on obvious
|
|
112
|
+
# Dm sessions. Now we check both field names for safety AND
|
|
113
|
+
# aggregate notes from all harmonic tracks via harmonic_score
|
|
114
|
+
# (Batch 5 helper), so key detection uses the richest signal.
|
|
104
115
|
try:
|
|
105
116
|
from ..tools._theory_engine import detect_key
|
|
106
|
-
|
|
117
|
+
from ..tools._composition_engine.harmony import harmonic_score
|
|
118
|
+
|
|
119
|
+
# Collect all tracks' notes, scored by harmonic-ness
|
|
120
|
+
harmonic_pool: list[dict] = []
|
|
121
|
+
for i in range(min(track_count, 16)):
|
|
107
122
|
try:
|
|
108
123
|
clip_info = ableton.send_command("get_clip_info", {
|
|
109
124
|
"track_index": i, "clip_index": 0,
|
|
110
125
|
})
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
logger.debug("get_clip_info(%d) skipped: %s", i, exc)
|
|
128
|
+
continue
|
|
129
|
+
# Accept either the new is_midi_clip field or the legacy
|
|
130
|
+
# is_midi (in case some install combines versions)
|
|
131
|
+
is_midi = (
|
|
132
|
+
clip_info.get("is_midi_clip")
|
|
133
|
+
or clip_info.get("is_midi")
|
|
134
|
+
or False
|
|
135
|
+
)
|
|
136
|
+
if not is_midi:
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
notes_result = ableton.send_command("get_notes", {
|
|
140
|
+
"track_index": i, "clip_index": 0,
|
|
141
|
+
})
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
logger.debug("get_notes(%d) skipped: %s", i, exc)
|
|
144
|
+
continue
|
|
145
|
+
notes = notes_result.get("notes", []) if isinstance(
|
|
146
|
+
notes_result, dict
|
|
147
|
+
) else []
|
|
148
|
+
if not notes:
|
|
123
149
|
continue
|
|
150
|
+
track_name = (
|
|
151
|
+
existing_roles[i] if i < len(existing_roles) else ""
|
|
152
|
+
)
|
|
153
|
+
if harmonic_score(notes, track_name) >= 0.3:
|
|
154
|
+
harmonic_pool.extend(notes)
|
|
155
|
+
|
|
156
|
+
if harmonic_pool:
|
|
157
|
+
key_result = detect_key(harmonic_pool)
|
|
158
|
+
mode = key_result.get("mode", "")
|
|
159
|
+
mode_suffix = "m" if "minor" in mode else ""
|
|
160
|
+
song_key = f"{key_result['tonic_name']}{mode_suffix}"
|
|
124
161
|
except ImportError:
|
|
125
162
|
pass
|
|
126
|
-
|
|
127
|
-
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
logger.debug("key aggregation failed: %s", exc)
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
logger.warning("session context for evaluate_sample_fit failed: %s", exc)
|
|
128
167
|
|
|
129
168
|
critics = run_all_sample_critics(
|
|
130
169
|
profile=profile,
|
|
@@ -243,7 +282,8 @@ async def search_samples(
|
|
|
243
282
|
},
|
|
244
283
|
})
|
|
245
284
|
used_grpc = True
|
|
246
|
-
except Exception:
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
logger.warning("Splice gRPC search failed, falling back to SQL: %s", exc)
|
|
247
287
|
used_grpc = False
|
|
248
288
|
|
|
249
289
|
# Also query local index (if not already covered by gRPC) to surface
|
|
@@ -282,10 +322,11 @@ async def search_samples(
|
|
|
282
322
|
d = candidate.to_dict()
|
|
283
323
|
d["source_priority"] = 2
|
|
284
324
|
results.append(d)
|
|
285
|
-
except Exception:
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
logger.debug("browser search %s skipped: %s", category, exc)
|
|
286
327
|
continue
|
|
287
|
-
except Exception:
|
|
288
|
-
|
|
328
|
+
except Exception as exc:
|
|
329
|
+
logger.warning("browser search unavailable: %s", exc)
|
|
289
330
|
|
|
290
331
|
# Filesystem search
|
|
291
332
|
if source in (None, "filesystem"):
|
|
@@ -442,7 +483,8 @@ def get_sample_opportunities(ctx: Context) -> dict:
|
|
|
442
483
|
try:
|
|
443
484
|
ableton = ctx.lifespan_context["ableton"]
|
|
444
485
|
info = ableton.send_command("get_session_info", {})
|
|
445
|
-
except Exception:
|
|
486
|
+
except Exception as exc:
|
|
487
|
+
logger.warning("get_sample_opportunities: Ableton not reachable: %s", exc)
|
|
446
488
|
return {"opportunities": [], "note": "Cannot read session — Ableton not connected"}
|
|
447
489
|
|
|
448
490
|
track_count = info.get("track_count", 0)
|
|
@@ -458,7 +500,8 @@ def get_sample_opportunities(ctx: Context) -> dict:
|
|
|
458
500
|
for d in devices:
|
|
459
501
|
if d.get("class_name") in ("OriginalSimpler", "MultiSampler"):
|
|
460
502
|
has_sampler = True
|
|
461
|
-
except Exception:
|
|
503
|
+
except Exception as exc:
|
|
504
|
+
logger.debug("track scan idx=%d skipped: %s", i, exc)
|
|
462
505
|
continue
|
|
463
506
|
|
|
464
507
|
# No organic texture
|
|
@@ -542,8 +585,8 @@ def plan_slice_workflow(
|
|
|
542
585
|
if ableton:
|
|
543
586
|
info = ableton.send_command("get_session_info", {})
|
|
544
587
|
tempo = float(info.get("tempo", 120.0))
|
|
545
|
-
except Exception:
|
|
546
|
-
|
|
588
|
+
except Exception as exc:
|
|
589
|
+
logger.debug("plan_slice_workflow tempo fetch failed (using 120): %s", exc)
|
|
547
590
|
|
|
548
591
|
# Read slice count from existing Simpler if track provided
|
|
549
592
|
slice_count = 8 # Default transient slice count
|
|
@@ -556,8 +599,8 @@ def plan_slice_workflow(
|
|
|
556
599
|
})
|
|
557
600
|
if isinstance(slices, dict) and slices.get("slice_count"):
|
|
558
601
|
slice_count = slices["slice_count"]
|
|
559
|
-
except Exception:
|
|
560
|
-
|
|
602
|
+
except Exception as exc:
|
|
603
|
+
logger.debug("get_simpler_slices failed (using default 8): %s", exc)
|
|
561
604
|
|
|
562
605
|
# Build the plan
|
|
563
606
|
plan = plan_slice_steps(
|
|
@@ -891,7 +934,7 @@ async def splice_download_sample(
|
|
|
891
934
|
try:
|
|
892
935
|
info = await client.get_credits()
|
|
893
936
|
response["credits_remaining"] = int(info.credits)
|
|
894
|
-
except Exception:
|
|
895
|
-
|
|
937
|
+
except Exception as exc:
|
|
938
|
+
logger.warning("post-download credit check failed: %s", exc)
|
|
896
939
|
|
|
897
940
|
return response
|
|
@@ -14,9 +14,11 @@ from . import resolvers
|
|
|
14
14
|
def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
15
15
|
"""Compile 'add_warmth': volume boost + reverb send for perceived warmth.
|
|
16
16
|
|
|
17
|
-
SAFETY: Never
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
SAFETY: Never target device parameters by raw index. Ableton's parameter
|
|
18
|
+
index 0 is "Device On" on every device, so set_device_parameter(idx=0)
|
|
19
|
+
with any fractional value rounds to 0 and DISABLES the device. Use sends
|
|
20
|
+
and volume for warmth; device-param automation is only safe once the
|
|
21
|
+
resolver can look parameters up by name.
|
|
20
22
|
"""
|
|
21
23
|
steps = []
|
|
22
24
|
descriptions = []
|
|
@@ -31,24 +33,6 @@ def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
31
33
|
idx = t["index"]
|
|
32
34
|
name = t["name"]
|
|
33
35
|
|
|
34
|
-
# Try to find a Saturator on the track (safe device adjustment)
|
|
35
|
-
saturator = resolvers.find_device_on_track(kernel, idx, "Saturator")
|
|
36
|
-
if saturator:
|
|
37
|
-
steps.append(CompiledStep(
|
|
38
|
-
tool="set_device_parameter",
|
|
39
|
-
params={
|
|
40
|
-
"track_index": idx,
|
|
41
|
-
"device_index": saturator["device_index"],
|
|
42
|
-
"parameter_index": 0,
|
|
43
|
-
"value": 0.3,
|
|
44
|
-
},
|
|
45
|
-
description=f"Gentle Saturator drive on {name}",
|
|
46
|
-
))
|
|
47
|
-
descriptions.append(f"Saturate {name}")
|
|
48
|
-
else:
|
|
49
|
-
# No Saturator found — use volume + send instead of risky device params
|
|
50
|
-
warnings.append(f"No Saturator on {name} — using volume+reverb for warmth")
|
|
51
|
-
|
|
52
36
|
# Boost volume slightly for perceived warmth
|
|
53
37
|
steps.append(CompiledStep(
|
|
54
38
|
tool="set_track_volume",
|
|
@@ -84,32 +68,22 @@ def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
84
68
|
|
|
85
69
|
|
|
86
70
|
def _compile_add_texture(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
87
|
-
"""Compile 'add_texture':
|
|
71
|
+
"""Compile 'add_texture': delay send for spatial texture.
|
|
72
|
+
|
|
73
|
+
Device-parameter automation (perlin filter motion) was removed because it
|
|
74
|
+
targeted device_index=0, parameter_index=0 without a resolver check — that
|
|
75
|
+
hits "Device On" on every Ableton device and would silently disable the
|
|
76
|
+
first device. Re-enable once resolvers.find_device_parameter lands.
|
|
77
|
+
"""
|
|
88
78
|
steps = []
|
|
89
79
|
descriptions = []
|
|
80
|
+
warnings = []
|
|
90
81
|
|
|
91
82
|
targets = resolvers.find_tracks_by_role(kernel, ["pad", "chords", "lead"])
|
|
92
83
|
|
|
93
84
|
for t in targets[:1]:
|
|
94
85
|
idx = t["index"]
|
|
95
86
|
name = t["name"]
|
|
96
|
-
steps.append(CompiledStep(
|
|
97
|
-
tool="apply_automation_shape",
|
|
98
|
-
params={
|
|
99
|
-
"track_index": idx,
|
|
100
|
-
"clip_index": 0,
|
|
101
|
-
"parameter_type": "device",
|
|
102
|
-
"device_index": 0,
|
|
103
|
-
"parameter_index": 0,
|
|
104
|
-
"curve_type": "perlin",
|
|
105
|
-
"center": 0.4,
|
|
106
|
-
"amplitude": 0.2,
|
|
107
|
-
"duration": 8,
|
|
108
|
-
"density": 16,
|
|
109
|
-
},
|
|
110
|
-
description=f"Perlin filter motion on {name} for organic texture",
|
|
111
|
-
))
|
|
112
|
-
descriptions.append(f"Perlin filter on {name}")
|
|
113
87
|
|
|
114
88
|
# Add delay send
|
|
115
89
|
steps.append(CompiledStep(
|
|
@@ -119,6 +93,9 @@ def _compile_add_texture(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
119
93
|
))
|
|
120
94
|
descriptions.append(f"Delay texture on {name}")
|
|
121
95
|
|
|
96
|
+
if not targets:
|
|
97
|
+
warnings.append("No pad/chords/lead tracks — texture needs a melodic bed")
|
|
98
|
+
|
|
122
99
|
steps.append(CompiledStep(
|
|
123
100
|
tool="get_track_meters",
|
|
124
101
|
params={"include_stereo": True},
|
|
@@ -132,14 +109,17 @@ def _compile_add_texture(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
132
109
|
risk_level="medium",
|
|
133
110
|
summary="; ".join(descriptions) if descriptions else "No tracks for texture",
|
|
134
111
|
requires_approval=(kernel.get("mode", "improve") != "explore"),
|
|
112
|
+
warnings=warnings,
|
|
135
113
|
)
|
|
136
114
|
|
|
137
115
|
|
|
138
116
|
def _compile_shape_transients(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
139
117
|
"""Compile 'shape_transients': push drum volume for punch, adjust sends.
|
|
140
118
|
|
|
141
|
-
SAFETY: Never
|
|
142
|
-
|
|
119
|
+
SAFETY: Never target device parameters by raw index. Index 0 on every
|
|
120
|
+
Ableton device is "Device On" — writing 0.2 rounds to 0 and disables the
|
|
121
|
+
device. Punch is achieved via volume + send shaping; Compressor attack
|
|
122
|
+
automation is only safe once the resolver can look parameters up by name.
|
|
143
123
|
"""
|
|
144
124
|
steps = []
|
|
145
125
|
descriptions = []
|
|
@@ -158,24 +138,7 @@ def _compile_shape_transients(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
158
138
|
idx = dt["index"]
|
|
159
139
|
name = dt["name"]
|
|
160
140
|
|
|
161
|
-
#
|
|
162
|
-
compressor = resolvers.find_device_on_track(kernel, idx, "Compressor")
|
|
163
|
-
if compressor:
|
|
164
|
-
steps.append(CompiledStep(
|
|
165
|
-
tool="set_device_parameter",
|
|
166
|
-
params={
|
|
167
|
-
"track_index": idx,
|
|
168
|
-
"device_index": compressor["device_index"],
|
|
169
|
-
"parameter_index": 0,
|
|
170
|
-
"value": 0.2,
|
|
171
|
-
},
|
|
172
|
-
description=f"Faster Compressor attack on {name} for snap",
|
|
173
|
-
))
|
|
174
|
-
descriptions.append(f"Shape {name} compressor")
|
|
175
|
-
else:
|
|
176
|
-
warnings.append(f"No Compressor on {name} — using volume push for punch")
|
|
177
|
-
|
|
178
|
-
# Push volume for transient punch regardless
|
|
141
|
+
# Push volume for transient punch
|
|
179
142
|
steps.append(CompiledStep(
|
|
180
143
|
tool="set_track_volume",
|
|
181
144
|
params={"track_index": idx, "volume": 0.75},
|
|
@@ -14,6 +14,9 @@ from fastmcp import Context
|
|
|
14
14
|
|
|
15
15
|
from ..server import mcp
|
|
16
16
|
from . import registry
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
@mcp.tool()
|
|
@@ -80,7 +83,8 @@ def preview_semantic_move(
|
|
|
80
83
|
info = ableton.send_command("get_session_info")
|
|
81
84
|
if isinstance(info, dict):
|
|
82
85
|
session_info = info
|
|
83
|
-
except Exception:
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
logger.debug("preview_semantic_move failed: %s", exc)
|
|
84
88
|
session_info = {}
|
|
85
89
|
|
|
86
90
|
state = build_capability_state(
|
|
@@ -8,31 +8,20 @@ from . import resolvers
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def _compile_increase_forward_motion(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
11
|
-
"""Compile 'increase_forward_motion':
|
|
11
|
+
"""Compile 'increase_forward_motion': rhythm push + reverb wash.
|
|
12
|
+
|
|
13
|
+
Device-parameter automation (rising filter sweep) was removed: targeting
|
|
14
|
+
device_index=0, parameter_index=0 without a resolver lookup hits "Device
|
|
15
|
+
On" on every Ableton device and would disable the first effect. Re-enable
|
|
16
|
+
once resolvers.find_device_parameter can locate a filter cutoff by name.
|
|
17
|
+
"""
|
|
12
18
|
steps = []
|
|
13
19
|
descriptions = []
|
|
20
|
+
warnings = []
|
|
14
21
|
|
|
15
22
|
melodic = resolvers.find_tracks_by_role(kernel, ["chords", "lead", "pad"])
|
|
16
23
|
drums = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
|
|
17
24
|
|
|
18
|
-
for mt in melodic[:1]:
|
|
19
|
-
steps.append(CompiledStep(
|
|
20
|
-
tool="apply_automation_shape",
|
|
21
|
-
params={
|
|
22
|
-
"track_index": mt["index"],
|
|
23
|
-
"clip_index": 0,
|
|
24
|
-
"parameter_type": "device",
|
|
25
|
-
"device_index": 0,
|
|
26
|
-
"parameter_index": 0,
|
|
27
|
-
"curve_type": "exponential",
|
|
28
|
-
"start": 0.2,
|
|
29
|
-
"end": 0.7,
|
|
30
|
-
"duration": 4,
|
|
31
|
-
},
|
|
32
|
-
description=f"Rising filter sweep on {mt['name']} over 4 bars",
|
|
33
|
-
))
|
|
34
|
-
descriptions.append(f"Rising filter on {mt['name']}")
|
|
35
|
-
|
|
36
25
|
for dt in drums[:1]:
|
|
37
26
|
steps.append(CompiledStep(
|
|
38
27
|
tool="set_track_volume",
|
|
@@ -49,6 +38,9 @@ def _compile_increase_forward_motion(move: SemanticMove, kernel: dict) -> Compil
|
|
|
49
38
|
))
|
|
50
39
|
descriptions.append(f"Reverb wash on {mt['name']}")
|
|
51
40
|
|
|
41
|
+
if not drums and not melodic:
|
|
42
|
+
warnings.append("No drum or melodic tracks — cannot build forward motion")
|
|
43
|
+
|
|
52
44
|
steps.append(CompiledStep(
|
|
53
45
|
tool="get_track_meters",
|
|
54
46
|
params={"include_stereo": True},
|
|
@@ -62,6 +54,7 @@ def _compile_increase_forward_motion(move: SemanticMove, kernel: dict) -> Compil
|
|
|
62
54
|
risk_level="low",
|
|
63
55
|
summary="; ".join(descriptions) if descriptions else "No melodic tracks for motion",
|
|
64
56
|
requires_approval=(kernel.get("mode", "improve") != "explore"),
|
|
57
|
+
warnings=warnings,
|
|
65
58
|
)
|
|
66
59
|
|
|
67
60
|
|