livepilot 1.10.6 → 1.10.8

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 (163) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +12 -10
  3. package/bin/livepilot.js +168 -30
  4. package/installer/install.js +117 -11
  5. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  6. package/m4l_device/livepilot_bridge.js +215 -3
  7. package/mcp_server/__init__.py +1 -1
  8. package/mcp_server/atlas/__init__.py +132 -33
  9. package/mcp_server/atlas/tools.py +56 -15
  10. package/mcp_server/composer/layer_planner.py +27 -0
  11. package/mcp_server/composer/prompt_parser.py +15 -6
  12. package/mcp_server/connection.py +11 -3
  13. package/mcp_server/corpus/__init__.py +14 -4
  14. package/mcp_server/creative_constraints/tools.py +206 -33
  15. package/mcp_server/experiment/engine.py +7 -9
  16. package/mcp_server/hook_hunter/analyzer.py +62 -9
  17. package/mcp_server/hook_hunter/tools.py +60 -9
  18. package/mcp_server/m4l_bridge.py +68 -12
  19. package/mcp_server/musical_intelligence/detectors.py +32 -0
  20. package/mcp_server/performance_engine/tools.py +112 -29
  21. package/mcp_server/preview_studio/engine.py +89 -8
  22. package/mcp_server/preview_studio/tools.py +22 -6
  23. package/mcp_server/project_brain/automation_graph.py +71 -19
  24. package/mcp_server/project_brain/builder.py +2 -0
  25. package/mcp_server/project_brain/tools.py +55 -5
  26. package/mcp_server/reference_engine/profile_builder.py +129 -3
  27. package/mcp_server/reference_engine/tools.py +47 -6
  28. package/mcp_server/runtime/execution_router.py +66 -2
  29. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  30. package/mcp_server/runtime/remote_commands.py +10 -2
  31. package/mcp_server/sample_engine/analyzer.py +131 -4
  32. package/mcp_server/sample_engine/critics.py +29 -8
  33. package/mcp_server/sample_engine/models.py +42 -4
  34. package/mcp_server/sample_engine/tools.py +48 -14
  35. package/mcp_server/semantic_moves/__init__.py +1 -0
  36. package/mcp_server/semantic_moves/compiler.py +9 -1
  37. package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
  38. package/mcp_server/semantic_moves/mix_compilers.py +170 -0
  39. package/mcp_server/semantic_moves/mix_moves.py +1 -1
  40. package/mcp_server/semantic_moves/models.py +5 -0
  41. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  42. package/mcp_server/semantic_moves/tools.py +15 -4
  43. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  44. package/mcp_server/server.py +75 -5
  45. package/mcp_server/services/singletons.py +68 -0
  46. package/mcp_server/session_continuity/models.py +4 -0
  47. package/mcp_server/session_continuity/tracker.py +14 -1
  48. package/mcp_server/song_brain/builder.py +110 -12
  49. package/mcp_server/song_brain/tools.py +77 -13
  50. package/mcp_server/sound_design/tools.py +112 -1
  51. package/mcp_server/splice_client/client.py +29 -8
  52. package/mcp_server/stuckness_detector/detector.py +90 -0
  53. package/mcp_server/stuckness_detector/tools.py +41 -0
  54. package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
  55. package/mcp_server/tools/_composition_engine/__init__.py +2 -2
  56. package/mcp_server/tools/_composition_engine/harmony.py +90 -0
  57. package/mcp_server/tools/_composition_engine/sections.py +47 -4
  58. package/mcp_server/tools/_harmony_engine.py +52 -8
  59. package/mcp_server/tools/_research_engine.py +98 -19
  60. package/mcp_server/tools/_theory_engine.py +138 -9
  61. package/mcp_server/tools/agent_os.py +20 -3
  62. package/mcp_server/tools/analyzer.py +105 -6
  63. package/mcp_server/tools/clips.py +46 -1
  64. package/mcp_server/tools/composition.py +66 -23
  65. package/mcp_server/tools/devices.py +22 -1
  66. package/mcp_server/tools/harmony.py +115 -14
  67. package/mcp_server/tools/midi_io.py +23 -1
  68. package/mcp_server/tools/mixing.py +35 -1
  69. package/mcp_server/tools/motif.py +49 -3
  70. package/mcp_server/tools/research.py +24 -0
  71. package/mcp_server/tools/theory.py +108 -16
  72. package/mcp_server/tools/tracks.py +1 -1
  73. package/mcp_server/tools/transport.py +1 -1
  74. package/mcp_server/transition_engine/critics.py +18 -11
  75. package/mcp_server/translation_engine/tools.py +8 -4
  76. package/package.json +25 -3
  77. package/remote_script/LivePilot/__init__.py +77 -2
  78. package/remote_script/LivePilot/arrangement.py +12 -2
  79. package/remote_script/LivePilot/browser.py +16 -6
  80. package/remote_script/LivePilot/clips.py +69 -0
  81. package/remote_script/LivePilot/devices.py +10 -5
  82. package/remote_script/LivePilot/mixing.py +117 -0
  83. package/remote_script/LivePilot/notes.py +13 -2
  84. package/remote_script/LivePilot/router.py +13 -1
  85. package/remote_script/LivePilot/server.py +51 -13
  86. package/remote_script/LivePilot/version_detect.py +7 -4
  87. package/server.json +20 -0
  88. package/.claude-plugin/marketplace.json +0 -21
  89. package/.mcpbignore +0 -57
  90. package/AGENTS.md +0 -46
  91. package/CODE_OF_CONDUCT.md +0 -27
  92. package/CONTRIBUTING.md +0 -131
  93. package/SECURITY.md +0 -48
  94. package/livepilot/.Codex-plugin/plugin.json +0 -8
  95. package/livepilot/.claude-plugin/plugin.json +0 -8
  96. package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
  97. package/livepilot/commands/arrange.md +0 -47
  98. package/livepilot/commands/beat.md +0 -77
  99. package/livepilot/commands/evaluate.md +0 -49
  100. package/livepilot/commands/memory.md +0 -22
  101. package/livepilot/commands/mix.md +0 -44
  102. package/livepilot/commands/perform.md +0 -42
  103. package/livepilot/commands/session.md +0 -13
  104. package/livepilot/commands/sounddesign.md +0 -43
  105. package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
  106. package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
  107. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
  108. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
  109. package/livepilot/skills/livepilot-core/SKILL.md +0 -184
  110. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
  111. package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
  112. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
  113. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
  114. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
  115. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
  116. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
  117. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
  118. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
  119. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
  120. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
  121. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
  122. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
  123. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
  124. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
  125. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
  126. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
  127. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
  128. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
  129. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
  130. package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
  131. package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
  132. package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
  133. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
  134. package/livepilot/skills/livepilot-core/references/overview.md +0 -290
  135. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
  136. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
  137. package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
  138. package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
  139. package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
  140. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
  141. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
  142. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
  143. package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
  144. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
  145. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
  146. package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
  147. package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
  148. package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
  149. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
  150. package/livepilot/skills/livepilot-release/SKILL.md +0 -130
  151. package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
  152. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
  153. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
  154. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
  155. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
  156. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
  157. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
  158. package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
  159. package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
  160. package/manifest.json +0 -91
  161. package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
  162. package/scripts/generate_tool_catalog.py +0 -131
  163. package/scripts/sync_metadata.py +0 -132
@@ -21,18 +21,20 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
21
21
  "set_track_solo", "set_track_arm", "stop_track_clips",
22
22
  "set_group_fold", "set_track_input_monitoring",
23
23
  "get_freeze_status", "freeze_track", "flatten_track",
24
- # clips (11)
24
+ # clips (12)
25
25
  "get_clip_info", "create_clip", "delete_clip", "duplicate_clip",
26
26
  "fire_clip", "stop_clip", "set_clip_name", "set_clip_color",
27
27
  "set_clip_loop", "set_clip_launch", "set_clip_warp_mode",
28
+ "set_clip_pitch",
28
29
  # notes (8)
29
30
  "add_notes", "get_notes", "remove_notes", "remove_notes_by_id",
30
31
  "modify_notes", "duplicate_notes", "transpose_notes", "quantize_clip",
31
- # mixing (11)
32
+ # mixing (12)
32
33
  "set_track_volume", "set_track_pan", "set_track_send",
33
34
  "get_return_tracks", "get_master_track", "set_master_volume",
34
35
  "get_track_routing", "get_track_meters", "get_master_meters",
35
36
  "get_mix_snapshot", "set_track_routing",
37
+ "set_compressor_sidechain", # BUG-A3 — Python LOM path (was M4L bridge)
36
38
  # scenes (12)
37
39
  "get_scenes_info", "create_scene", "delete_scene", "duplicate_scene",
38
40
  "fire_scene", "set_scene_name", "set_scene_color", "set_scene_tempo",
@@ -80,6 +82,12 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
80
82
  "remove_warp_marker", "capture_audio", "capture_stop",
81
83
  "check_flucoma", "scrub_clip", "stop_scrub", "get_display_values",
82
84
  "get_plugin_params", "map_plugin_param", "get_plugin_presets",
85
+ # Deep-LOM writes that the Python Remote Script cannot reach (live on
86
+ # the sample child object or require device-selection semantics that
87
+ # only Max JS LiveAPI exposes). See mcp_server/tools/analyzer.py for
88
+ # the matching MCP tools that route through bridge.send_command.
89
+ "simpler_set_warp",
90
+ "compressor_set_sidechain",
83
91
  # NOTE: load_sample_to_simpler used to live here, but it's actually an
84
92
  # async Python MCP tool in mcp_server/tools/analyzer.py, not a bridge
85
93
  # command. It has no case in livepilot_bridge.js and no @register handler
@@ -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
 
@@ -120,11 +139,30 @@ class SampleFitReport:
120
139
 
121
140
  @property
122
141
  def overall_score(self) -> float:
142
+ """Average over AVAILABLE critics only.
143
+
144
+ BUG-B38 reshaped frequency_fit to report ``-1.0`` with
145
+ ``available=False`` when no mix snapshot is present. The previous
146
+ aggregator mean-folded that sentinel into the overall score,
147
+ dropping it by ~17 points (one critic out of six). The fix is to
148
+ respect the ``available`` flag — same contract every other caller
149
+ uses.
150
+ """
123
151
  if not self.critics:
124
152
  return 0.0
125
- scores = [c.score if isinstance(c, CriticResult) else c.get("score", 0)
126
- for c in self.critics.values()]
127
- return sum(scores) / len(scores) if scores else 0.0
153
+ available_scores = []
154
+ for c in self.critics.values():
155
+ if isinstance(c, CriticResult):
156
+ if c.available is False:
157
+ continue
158
+ available_scores.append(c.score)
159
+ else: # legacy dict shape
160
+ if c.get("available") is False:
161
+ continue
162
+ available_scores.append(c.get("score", 0))
163
+ if not available_scores:
164
+ return 0.0
165
+ return sum(available_scores) / len(available_scores)
128
166
 
129
167
  def to_dict(self) -> dict:
130
168
  return {
@@ -104,30 +104,64 @@ def evaluate_sample_fit(
104
104
  logger.debug("get_track_info(%d) skipped: %s", i, exc)
105
105
  continue
106
106
 
107
- # 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.
108
115
  try:
109
116
  from ..tools._theory_engine import detect_key
110
- 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)):
111
122
  try:
112
123
  clip_info = ableton.send_command("get_clip_info", {
113
124
  "track_index": i, "clip_index": 0,
114
125
  })
115
- if clip_info.get("is_midi"):
116
- notes_result = ableton.send_command("get_notes", {
117
- "track_index": i, "clip_index": 0,
118
- })
119
- notes = notes_result.get("notes", [])
120
- if notes:
121
- key_result = detect_key(notes)
122
- mode = key_result.get("mode", "")
123
- mode_suffix = "m" if "minor" in mode else ""
124
- song_key = f"{key_result['tonic_name']}{mode_suffix}"
125
- break
126
126
  except Exception as exc:
127
- logger.debug("key detection on track %d skipped: %s", i, 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:
128
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:
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}"
129
161
  except ImportError:
130
162
  pass
163
+ except Exception as exc:
164
+ logger.debug("key aggregation failed: %s", exc)
131
165
  except Exception as exc:
132
166
  logger.warning("session context for evaluate_sample_fit failed: %s", exc)
133
167
 
@@ -14,3 +14,4 @@ from . import transition_compilers # noqa: F401
14
14
  from . import sound_design_compilers # noqa: F401
15
15
  from . import performance_compilers # noqa: F401
16
16
  from . import sample_compilers # noqa: F401
17
+ from . import device_creation_compilers # noqa: F401
@@ -24,14 +24,22 @@ class CompiledStep:
24
24
  params: dict # Concrete params, e.g. {"track_index": 0, "volume": 0.72}
25
25
  description: str # Human-readable, e.g. "Push Drums from 0.65 → 0.72"
26
26
  verify_after: bool = True # Whether to check meters after this step
27
+ # Optional explicit backend. If set, the execution router uses it verbatim
28
+ # and skips classify_step(). Leave None to let the router auto-classify at
29
+ # dispatch time — safe because test_move_annotations enforces every
30
+ # registered move's steps map to a known backend.
31
+ backend: Optional[str] = None
27
32
 
28
33
  def to_dict(self) -> dict:
29
- return {
34
+ d = {
30
35
  "tool": self.tool,
31
36
  "params": self.params,
32
37
  "description": self.description,
33
38
  "verify_after": self.verify_after,
34
39
  }
40
+ if self.backend:
41
+ d["backend"] = self.backend
42
+ return d
35
43
 
36
44
 
37
45
  @dataclass
@@ -0,0 +1,47 @@
1
+ """Family compiler for device-creation semantic moves.
2
+
3
+ Device-creation moves generate custom M4L devices via the Device Forge
4
+ (``generate_m4l_effect``). Unlike mix/sound-design moves — where the
5
+ compiler inspects the kernel's track topology — device-creation moves
6
+ are parametric: the plan_template already contains the tool call and
7
+ concrete arguments.
8
+
9
+ We therefore use a single family-level compiler that just maps
10
+ ``plan_template`` → ``CompiledStep`` objects. This keeps the registry
11
+ honest (every move is either compilable or analytical_only) without
12
+ duplicating templates into per-move compilers.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .compiler import CompiledPlan, CompiledStep, register_family_compiler
17
+ from .models import SemanticMove
18
+
19
+
20
+ def _compile_device_creation(move: SemanticMove, kernel: dict) -> CompiledPlan:
21
+ """Map plan_template steps straight to CompiledStep.
22
+
23
+ plan_template is trusted for this family: each step already has
24
+ ``tool``, ``params``, ``description``, and ``backend`` annotated.
25
+ """
26
+ steps: list[CompiledStep] = []
27
+ for step in move.plan_template:
28
+ steps.append(CompiledStep(
29
+ tool=step.get("tool", ""),
30
+ params=step.get("params", {}),
31
+ description=step.get("description", ""),
32
+ verify_after=bool(step.get("verify_after", True)),
33
+ backend=step.get("backend"),
34
+ ))
35
+
36
+ return CompiledPlan(
37
+ move_id=move.move_id,
38
+ intent=move.intent,
39
+ steps=steps,
40
+ risk_level=move.risk_level,
41
+ summary=move.intent,
42
+ requires_approval=(kernel.get("mode", "improve") != "explore"),
43
+ warnings=[],
44
+ )
45
+
46
+
47
+ register_family_compiler("device_creation", _compile_device_creation)
@@ -282,6 +282,173 @@ def _compile_reduce_repetition(move: SemanticMove, kernel: dict) -> CompiledPlan
282
282
  )
283
283
 
284
284
 
285
+ def _compile_make_kick_bass_lock(move: SemanticMove, kernel: dict) -> CompiledPlan:
286
+ """Compile 'make_kick_bass_lock': carve space between kick and bass.
287
+
288
+ Strategy: reduce bass level slightly (clears sub for kick), verify both
289
+ tracks remain active. Sidechain compressor insertion is left as a future
290
+ step — it requires device selection + parameter mapping that varies too
291
+ much across projects to hardcode safely.
292
+ """
293
+ steps: list[CompiledStep] = []
294
+ warnings: list[str] = []
295
+ descriptions: list[str] = []
296
+
297
+ bass_tracks = resolvers.find_tracks_by_role(kernel, ["bass"])
298
+ kick_tracks = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
299
+
300
+ if not bass_tracks:
301
+ warnings.append("No bass track found — cannot lock kick and bass")
302
+ if not kick_tracks:
303
+ warnings.append("No kick/drum track found — reference track missing")
304
+
305
+ steps.append(CompiledStep(
306
+ tool="get_master_spectrum",
307
+ params={},
308
+ description="Read current sub/low balance before carving",
309
+ verify_after=False,
310
+ ))
311
+
312
+ if bass_tracks:
313
+ bass = bass_tracks[0]
314
+ idx = bass["index"]
315
+ steps.append(CompiledStep(
316
+ tool="set_track_volume",
317
+ params={"track_index": idx, "volume": 0.60},
318
+ description=f"Pull {bass['name']} to 0.60 to clear sub for kick",
319
+ ))
320
+ descriptions.append(f"Pull {bass['name']} to 0.60")
321
+
322
+ steps.append(CompiledStep(
323
+ tool="get_track_meters",
324
+ params={"include_stereo": True},
325
+ description="Verify kick and bass both still producing audio",
326
+ ))
327
+
328
+ return CompiledPlan(
329
+ move_id=move.move_id,
330
+ intent=move.intent,
331
+ steps=steps,
332
+ before_reads=[{"tool": "get_master_spectrum", "params": {}}],
333
+ after_reads=[
334
+ {"tool": "get_master_spectrum", "params": {}},
335
+ {"tool": "get_track_meters", "params": {"include_stereo": True}},
336
+ ],
337
+ risk_level="low",
338
+ summary="; ".join(descriptions) if descriptions else "No kick/bass changes compiled",
339
+ requires_approval=(kernel.get("mode", "improve") != "explore"),
340
+ warnings=warnings,
341
+ )
342
+
343
+
344
+ def _compile_create_buildup_tension(move: SemanticMove, kernel: dict) -> CompiledPlan:
345
+ """Compile 'create_buildup_tension': pull harmony back, raise perc energy.
346
+
347
+ We apply volume moves as the minimal, reversible tension-builder. Filter
348
+ rises and send ramps belong in an automation recipe — we issue a tension
349
+ gesture template step if the gesture engine is available, otherwise fall
350
+ back to direct volume changes only.
351
+ """
352
+ steps: list[CompiledStep] = []
353
+ warnings: list[str] = []
354
+ descriptions: list[str] = []
355
+
356
+ perc_tracks = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
357
+ harmony_tracks = resolvers.find_tracks_by_role(kernel, ["chords", "pad"])
358
+
359
+ if not perc_tracks and not harmony_tracks:
360
+ warnings.append("No percussion or harmony tracks found — cannot build tension")
361
+
362
+ # Raise perc for energy
363
+ for pt in perc_tracks[:1]:
364
+ steps.append(CompiledStep(
365
+ tool="set_track_volume",
366
+ params={"track_index": pt["index"], "volume": 0.78},
367
+ description=f"Push {pt['name']} to 0.78 for rising energy",
368
+ ))
369
+ descriptions.append(f"Push {pt['name']} to 0.78")
370
+
371
+ # Pull harmony slightly to amplify perc contrast
372
+ for ht in harmony_tracks[:1]:
373
+ steps.append(CompiledStep(
374
+ tool="set_track_volume",
375
+ params={"track_index": ht["index"], "volume": 0.35},
376
+ description=f"Pull {ht['name']} to 0.35 to create harmonic vacuum before drop",
377
+ ))
378
+ descriptions.append(f"Pull {ht['name']} to 0.35")
379
+
380
+ steps.append(CompiledStep(
381
+ tool="get_track_meters",
382
+ params={"include_stereo": True},
383
+ description="Verify tension steps did not silence any track",
384
+ ))
385
+
386
+ return CompiledPlan(
387
+ move_id=move.move_id,
388
+ intent=move.intent,
389
+ steps=steps,
390
+ before_reads=[{"tool": "get_emotional_arc", "params": {}}],
391
+ after_reads=[
392
+ {"tool": "get_emotional_arc", "params": {}},
393
+ {"tool": "get_track_meters", "params": {"include_stereo": True}},
394
+ ],
395
+ risk_level="medium",
396
+ summary="; ".join(descriptions) if descriptions else "No tracks to ratchet",
397
+ requires_approval=(kernel.get("mode", "improve") != "explore"),
398
+ warnings=warnings,
399
+ )
400
+
401
+
402
+ def _compile_smooth_scene_handoff(move: SemanticMove, kernel: dict) -> CompiledPlan:
403
+ """Compile 'smooth_scene_handoff': reduce master volume briefly around the handoff.
404
+
405
+ Without knowing which two scenes are involved, the compiler can only do a
406
+ conservative energy dip using master volume. A future version should take
407
+ scene indices via kernel.intent_context and apply targeted crossfades.
408
+ """
409
+ steps: list[CompiledStep] = []
410
+ warnings: list[str] = []
411
+ descriptions: list[str] = []
412
+
413
+ # Minimal approach — gentle master dip the agent can reverse easily.
414
+ steps.append(CompiledStep(
415
+ tool="get_master_meters",
416
+ params={},
417
+ description="Record current master level for handoff reference",
418
+ verify_after=False,
419
+ ))
420
+
421
+ steps.append(CompiledStep(
422
+ tool="set_master_volume",
423
+ params={"volume": 0.78},
424
+ description="Gentle master dip for transition",
425
+ ))
426
+ descriptions.append("Master dip to 0.78")
427
+
428
+ steps.append(CompiledStep(
429
+ tool="get_master_meters",
430
+ params={},
431
+ description="Verify master dip applied without clipping",
432
+ ))
433
+
434
+ warnings.append(
435
+ "Scene-aware handoff (from_scene/to_scene) not yet compiled — "
436
+ "this is a conservative energy-dip fallback"
437
+ )
438
+
439
+ return CompiledPlan(
440
+ move_id=move.move_id,
441
+ intent=move.intent,
442
+ steps=steps,
443
+ before_reads=[{"tool": "get_emotional_arc", "params": {}}],
444
+ after_reads=[{"tool": "get_emotional_arc", "params": {}}],
445
+ risk_level="low",
446
+ summary="; ".join(descriptions),
447
+ requires_approval=(kernel.get("mode", "improve") != "explore"),
448
+ warnings=warnings,
449
+ )
450
+
451
+
285
452
  # ── Register all compilers ──────────────────────────────────────────────────
286
453
 
287
454
  register_compiler("make_punchier", _compile_make_punchier)
@@ -289,3 +456,6 @@ register_compiler("tighten_low_end", _compile_tighten_low_end)
289
456
  register_compiler("widen_stereo", _compile_widen_stereo)
290
457
  register_compiler("darken_without_losing_width", _compile_darken_mix)
291
458
  register_compiler("reduce_repetition_fatigue", _compile_reduce_repetition)
459
+ register_compiler("make_kick_bass_lock", _compile_make_kick_bass_lock)
460
+ register_compiler("create_buildup_tension", _compile_create_buildup_tension)
461
+ register_compiler("smooth_scene_handoff", _compile_smooth_scene_handoff)