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.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcp.json.disabled +9 -0
  3. package/.mcpbignore +3 -0
  4. package/AGENTS.md +3 -3
  5. package/BUGS.md +1570 -0
  6. package/CHANGELOG.md +92 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +7 -7
  9. package/bin/livepilot.js +28 -8
  10. package/livepilot/.Codex-plugin/plugin.json +2 -2
  11. package/livepilot/.claude-plugin/plugin.json +2 -2
  12. package/livepilot/skills/livepilot-core/SKILL.md +4 -4
  13. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  14. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +8 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  18. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  19. package/m4l_device/livepilot_bridge.js +226 -3
  20. package/manifest.json +3 -3
  21. package/mcp_server/__init__.py +1 -1
  22. package/mcp_server/atlas/__init__.py +93 -26
  23. package/mcp_server/composer/sample_resolver.py +10 -6
  24. package/mcp_server/composer/tools.py +10 -6
  25. package/mcp_server/connection.py +6 -1
  26. package/mcp_server/creative_constraints/tools.py +214 -40
  27. package/mcp_server/experiment/engine.py +16 -14
  28. package/mcp_server/experiment/tools.py +9 -9
  29. package/mcp_server/hook_hunter/analyzer.py +62 -9
  30. package/mcp_server/hook_hunter/tools.py +74 -18
  31. package/mcp_server/m4l_bridge.py +32 -6
  32. package/mcp_server/memory/taste_graph.py +7 -2
  33. package/mcp_server/mix_engine/tools.py +8 -3
  34. package/mcp_server/musical_intelligence/detectors.py +32 -0
  35. package/mcp_server/musical_intelligence/tools.py +15 -10
  36. package/mcp_server/performance_engine/tools.py +117 -30
  37. package/mcp_server/preview_studio/engine.py +89 -8
  38. package/mcp_server/preview_studio/tools.py +43 -21
  39. package/mcp_server/project_brain/automation_graph.py +71 -19
  40. package/mcp_server/project_brain/builder.py +2 -0
  41. package/mcp_server/project_brain/tools.py +73 -15
  42. package/mcp_server/reference_engine/profile_builder.py +129 -3
  43. package/mcp_server/reference_engine/tools.py +54 -11
  44. package/mcp_server/runtime/capability_probe.py +10 -4
  45. package/mcp_server/runtime/execution_router.py +50 -0
  46. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  47. package/mcp_server/runtime/remote_commands.py +4 -2
  48. package/mcp_server/runtime/tools.py +8 -2
  49. package/mcp_server/sample_engine/analyzer.py +131 -4
  50. package/mcp_server/sample_engine/critics.py +29 -8
  51. package/mcp_server/sample_engine/models.py +20 -1
  52. package/mcp_server/sample_engine/tools.py +74 -31
  53. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  54. package/mcp_server/semantic_moves/tools.py +5 -1
  55. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  56. package/mcp_server/server.py +78 -11
  57. package/mcp_server/services/motif_service.py +9 -3
  58. package/mcp_server/session_continuity/models.py +4 -0
  59. package/mcp_server/session_continuity/tools.py +7 -3
  60. package/mcp_server/session_continuity/tracker.py +23 -9
  61. package/mcp_server/song_brain/builder.py +110 -12
  62. package/mcp_server/song_brain/tools.py +94 -25
  63. package/mcp_server/sound_design/tools.py +112 -1
  64. package/mcp_server/splice_client/client.py +19 -6
  65. package/mcp_server/stuckness_detector/detector.py +90 -0
  66. package/mcp_server/stuckness_detector/tools.py +49 -5
  67. package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
  68. package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
  69. package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
  70. package/mcp_server/tools/_agent_os_engine/models.py +132 -0
  71. package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
  72. package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
  73. package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
  74. package/mcp_server/tools/_composition_engine/__init__.py +67 -0
  75. package/mcp_server/tools/_composition_engine/analysis.py +174 -0
  76. package/mcp_server/tools/_composition_engine/critics.py +522 -0
  77. package/mcp_server/tools/_composition_engine/gestures.py +230 -0
  78. package/mcp_server/tools/_composition_engine/harmony.py +160 -0
  79. package/mcp_server/tools/_composition_engine/models.py +193 -0
  80. package/mcp_server/tools/_composition_engine/sections.py +414 -0
  81. package/mcp_server/tools/_harmony_engine.py +52 -8
  82. package/mcp_server/tools/_perception_engine.py +18 -11
  83. package/mcp_server/tools/_research_engine.py +98 -19
  84. package/mcp_server/tools/_theory_engine.py +138 -9
  85. package/mcp_server/tools/agent_os.py +43 -18
  86. package/mcp_server/tools/analyzer.py +105 -8
  87. package/mcp_server/tools/automation.py +6 -1
  88. package/mcp_server/tools/clips.py +45 -0
  89. package/mcp_server/tools/composition.py +90 -38
  90. package/mcp_server/tools/devices.py +32 -7
  91. package/mcp_server/tools/harmony.py +115 -14
  92. package/mcp_server/tools/midi_io.py +13 -1
  93. package/mcp_server/tools/mixing.py +35 -1
  94. package/mcp_server/tools/motif.py +56 -5
  95. package/mcp_server/tools/planner.py +6 -2
  96. package/mcp_server/tools/research.py +37 -10
  97. package/mcp_server/tools/theory.py +108 -16
  98. package/mcp_server/transition_engine/critics.py +18 -11
  99. package/mcp_server/transition_engine/tools.py +6 -1
  100. package/mcp_server/translation_engine/tools.py +8 -6
  101. package/mcp_server/wonder_mode/engine.py +8 -3
  102. package/mcp_server/wonder_mode/tools.py +29 -21
  103. package/package.json +2 -2
  104. package/remote_script/LivePilot/__init__.py +57 -2
  105. package/remote_script/LivePilot/clips.py +69 -0
  106. package/remote_script/LivePilot/mixing.py +117 -0
  107. package/remote_script/LivePilot/router.py +13 -1
  108. package/scripts/generate_tool_catalog.py +13 -38
  109. package/scripts/sync_metadata.py +231 -14
  110. package/mcp_server/tools/_agent_os_engine.py +0 -947
  111. 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 only (no spectral analysis).
192
-
193
- This is the fallback when M4L bridge is unavailable.
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=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
- Without mix_snapshot (no M4L bridge), returns neutral 0.5.
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", score=0.5,
172
- recommendation="No spectral data — verify frequency fit by ear",
173
- adjustments=[{"note": "stub — spectral overlap analysis not yet implemented"}],
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", score=0.5,
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", score=0.5,
190
- recommendation="Sample has no spectral data — verify by ear",
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
- pass
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
- for i in range(min(track_count, 8)):
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
- if clip_info.get("is_midi"):
112
- notes_result = ableton.send_command("get_notes", {
113
- "track_index": i, "clip_index": 0,
114
- })
115
- notes = notes_result.get("notes", [])
116
- if notes:
117
- key_result = detect_key(notes)
118
- mode = key_result.get("mode", "")
119
- mode_suffix = "m" if "minor" in mode else ""
120
- song_key = f"{key_result['tonic_name']}{mode_suffix}"
121
- break
122
- except Exception:
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
- except Exception:
127
- pass
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
- pass
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
- pass
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
- pass # Fall back to default
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
- pass
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 blindly set device parameters device_index=0, parameter_index=0
18
- can kill audio if the first device isn't a Saturator. Only adjust device params
19
- when find_device_on_track confirms a Saturator is present.
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': perlin filter motion + delay send."""
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 blindly set device parameters. Only adjust Compressor params
142
- when find_device_on_track confirms one exists. Otherwise use volume for punch.
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
- # Try to find a Compressor on the track
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': rising filter + rhythm push."""
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