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.
- package/CHANGELOG.md +168 -0
- package/README.md +12 -10
- package/bin/livepilot.js +168 -30
- package/installer/install.js +117 -11
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +215 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +132 -33
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- package/mcp_server/creative_constraints/tools.py +206 -33
- package/mcp_server/experiment/engine.py +7 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +60 -9
- package/mcp_server/m4l_bridge.py +68 -12
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/performance_engine/tools.py +112 -29
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +22 -6
- 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 +55 -5
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +47 -6
- package/mcp_server/runtime/execution_router.py +66 -2
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +10 -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 +42 -4
- package/mcp_server/sample_engine/tools.py +48 -14
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +15 -4
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +75 -5
- package/mcp_server/services/singletons.py +68 -0
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tracker.py +14 -1
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +77 -13
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +29 -8
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +41 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
- package/mcp_server/tools/_composition_engine/__init__.py +2 -2
- package/mcp_server/tools/_composition_engine/harmony.py +90 -0
- package/mcp_server/tools/_composition_engine/sections.py +47 -4
- package/mcp_server/tools/_harmony_engine.py +52 -8
- 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 +20 -3
- package/mcp_server/tools/analyzer.py +105 -6
- package/mcp_server/tools/clips.py +46 -1
- package/mcp_server/tools/composition.py +66 -23
- package/mcp_server/tools/devices.py +22 -1
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +23 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +49 -3
- package/mcp_server/tools/research.py +24 -0
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +77 -2
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/router.py +13 -1
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcpbignore +0 -57
- package/AGENTS.md +0 -46
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -131
- 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 (
|
|
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 (
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
|
|
@@ -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
|
-
|
|
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)
|