livepilot 1.10.6 → 1.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +42 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +214 -2
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- 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 +21 -6
- 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 +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +48 -14
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +68 -2
- 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/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 +98 -0
- package/mcp_server/tools/clips.py +45 -0
- 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 +13 -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/transition_engine/critics.py +18 -11
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
|
@@ -7,6 +7,7 @@ These tools are optional — all core tools work without the device.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
+
from typing import Optional
|
|
10
11
|
|
|
11
12
|
from fastmcp import Context
|
|
12
13
|
|
|
@@ -968,3 +969,100 @@ def check_flucoma(ctx: Context) -> dict:
|
|
|
968
969
|
streams[key] = cache.get(key) is not None
|
|
969
970
|
active = sum(1 for v in streams.values() if v)
|
|
970
971
|
return {"flucoma_available": active > 0, "active_streams": active, "streams": streams}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
# ── BUG-A2 + A3: deep-LOM properties via M4L bridge ──────────────────
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
@mcp.tool()
|
|
978
|
+
async def simpler_set_warp(
|
|
979
|
+
ctx: Context,
|
|
980
|
+
track_index: int,
|
|
981
|
+
device_index: int,
|
|
982
|
+
warping: bool,
|
|
983
|
+
warp_mode: Optional[int] = None,
|
|
984
|
+
) -> dict:
|
|
985
|
+
"""Toggle a Simpler's sample warping + set the warp algorithm (BUG-A2).
|
|
986
|
+
|
|
987
|
+
Python's Remote Script ControlSurface API can't reach Simpler's
|
|
988
|
+
`warping` or `warp_mode` — they live on the sample child object
|
|
989
|
+
(SimplerDevice.sample.*) that only Max for Live's JavaScript LiveAPI
|
|
990
|
+
can step into. This tool routes through the M4L bridge to do the
|
|
991
|
+
write.
|
|
992
|
+
|
|
993
|
+
When enabling warping, pass the desired warp_mode too so Live doesn't
|
|
994
|
+
default to whatever was there last:
|
|
995
|
+
|
|
996
|
+
warp_mode 0 = Beats (good for drums / percussive loops)
|
|
997
|
+
warp_mode 1 = Tones (mono harmonic material)
|
|
998
|
+
warp_mode 2 = Texture (poly / ambient material)
|
|
999
|
+
warp_mode 3 = Re-Pitch (classic pitch-shift feel)
|
|
1000
|
+
warp_mode 4 = Complex (music / full mixes — higher CPU)
|
|
1001
|
+
warp_mode 6 = Complex Pro (highest quality — highest CPU)
|
|
1002
|
+
|
|
1003
|
+
Args:
|
|
1004
|
+
track_index: 0+ for regular tracks
|
|
1005
|
+
device_index: Simpler device's position in the chain
|
|
1006
|
+
warping: True → enable sample warp; False → disable
|
|
1007
|
+
warp_mode: 0-6 (omit to leave the current mode unchanged)
|
|
1008
|
+
|
|
1009
|
+
Requires LivePilot Analyzer on master track.
|
|
1010
|
+
"""
|
|
1011
|
+
if warp_mode is not None and warp_mode not in (0, 1, 2, 3, 4, 6):
|
|
1012
|
+
raise ValueError("warp_mode must be 0,1,2,3,4,6 (no 5 — Live skips it)")
|
|
1013
|
+
cache = _get_spectral(ctx)
|
|
1014
|
+
_require_analyzer(cache)
|
|
1015
|
+
bridge = _get_m4l(ctx)
|
|
1016
|
+
return await bridge.send_command(
|
|
1017
|
+
"simpler_set_warp",
|
|
1018
|
+
int(track_index),
|
|
1019
|
+
int(device_index),
|
|
1020
|
+
1 if warping else 0,
|
|
1021
|
+
-1 if warp_mode is None else int(warp_mode),
|
|
1022
|
+
timeout=10.0,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
@mcp.tool()
|
|
1027
|
+
async def compressor_set_sidechain(
|
|
1028
|
+
ctx: Context,
|
|
1029
|
+
track_index: int,
|
|
1030
|
+
device_index: int,
|
|
1031
|
+
source_type: str = "",
|
|
1032
|
+
source_channel: str = "",
|
|
1033
|
+
) -> dict:
|
|
1034
|
+
"""Configure a Compressor's sidechain INPUT ROUTING (BUG-A3).
|
|
1035
|
+
|
|
1036
|
+
Complements set_device_parameter's `S/C On` toggle: that enables the
|
|
1037
|
+
sidechain, this picks WHICH track/channel feeds the detector. The
|
|
1038
|
+
routing properties (`sidechain_input_routing_type`,
|
|
1039
|
+
`sidechain_input_routing_channel`) aren't in Compressor's automatable
|
|
1040
|
+
parameter list, but Python's Remote Script reaches them directly as
|
|
1041
|
+
device properties (same LOM pattern as set_track_routing).
|
|
1042
|
+
|
|
1043
|
+
Args:
|
|
1044
|
+
track_index: 0+ regular, -1/-2 returns, -1000 master
|
|
1045
|
+
device_index: Compressor position in the chain
|
|
1046
|
+
source_type: sidechain source display name
|
|
1047
|
+
(e.g. "1-Kick", "Ext. In", "No Input")
|
|
1048
|
+
source_channel: tap point on the source
|
|
1049
|
+
(e.g. "Post FX", "Pre FX", "Post Mixer")
|
|
1050
|
+
|
|
1051
|
+
Omit a param to leave that property unchanged. If a display name
|
|
1052
|
+
doesn't match, the error message includes the full list of available
|
|
1053
|
+
options from the running Live session.
|
|
1054
|
+
|
|
1055
|
+
Routes through the Remote Script (TCP) — does NOT require the M4L
|
|
1056
|
+
analyzer. This is the Python-side path introduced after the M4L
|
|
1057
|
+
bridge approach hit LiveAPI shape issues in Live 12.3.6.
|
|
1058
|
+
"""
|
|
1059
|
+
params: dict = {
|
|
1060
|
+
"track_index": int(track_index),
|
|
1061
|
+
"device_index": int(device_index),
|
|
1062
|
+
}
|
|
1063
|
+
if source_type:
|
|
1064
|
+
params["source_type"] = str(source_type)
|
|
1065
|
+
if source_channel:
|
|
1066
|
+
params["source_channel"] = str(source_channel)
|
|
1067
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
1068
|
+
return ableton.send_command("set_compressor_sidechain", params)
|
|
@@ -196,6 +196,51 @@ def set_clip_launch(
|
|
|
196
196
|
return _get_ableton(ctx).send_command("set_clip_launch", params)
|
|
197
197
|
|
|
198
198
|
|
|
199
|
+
@mcp.tool()
|
|
200
|
+
def set_clip_pitch(
|
|
201
|
+
ctx: Context,
|
|
202
|
+
track_index: int,
|
|
203
|
+
clip_index: int,
|
|
204
|
+
coarse: Optional[int] = None,
|
|
205
|
+
fine: Optional[float] = None,
|
|
206
|
+
gain: Optional[float] = None,
|
|
207
|
+
) -> dict:
|
|
208
|
+
"""Set pitch transposition and/or gain on an audio clip (BUG-A5).
|
|
209
|
+
|
|
210
|
+
Audio clips only. Use this to correct sample pitch to match session key
|
|
211
|
+
(e.g. a D#min Splice clip in a Dm session -> coarse=-1).
|
|
212
|
+
|
|
213
|
+
coarse: semitones, -48..+48
|
|
214
|
+
fine: cents, -50..+50
|
|
215
|
+
gain: linear, 0..1 (Live's internal scale, not dB)
|
|
216
|
+
|
|
217
|
+
At least one of coarse/fine/gain must be provided.
|
|
218
|
+
"""
|
|
219
|
+
_validate_track_index(track_index)
|
|
220
|
+
_validate_clip_index(clip_index)
|
|
221
|
+
if coarse is None and fine is None and gain is None:
|
|
222
|
+
raise ValueError(
|
|
223
|
+
"Provide at least one of: coarse (semitones), fine (cents), gain (0-1)"
|
|
224
|
+
)
|
|
225
|
+
if coarse is not None and not -48 <= coarse <= 48:
|
|
226
|
+
raise ValueError("coarse must be in -48..+48 semitones")
|
|
227
|
+
if fine is not None and not -50.0 <= fine <= 50.0:
|
|
228
|
+
raise ValueError("fine must be in -50..+50 cents")
|
|
229
|
+
if gain is not None and not 0.0 <= gain <= 1.0:
|
|
230
|
+
raise ValueError("gain must be in 0..1")
|
|
231
|
+
params: dict = {
|
|
232
|
+
"track_index": track_index,
|
|
233
|
+
"clip_index": clip_index,
|
|
234
|
+
}
|
|
235
|
+
if coarse is not None:
|
|
236
|
+
params["coarse"] = coarse
|
|
237
|
+
if fine is not None:
|
|
238
|
+
params["fine"] = fine
|
|
239
|
+
if gain is not None:
|
|
240
|
+
params["gain"] = gain
|
|
241
|
+
return _get_ableton(ctx).send_command("set_clip_pitch", params)
|
|
242
|
+
|
|
243
|
+
|
|
199
244
|
_VALID_WARP_MODES = {0, 1, 2, 3, 4, 6}
|
|
200
245
|
|
|
201
246
|
|
|
@@ -388,8 +388,13 @@ def get_harmony_field(
|
|
|
388
388
|
|
|
389
389
|
section = sections[section_index]
|
|
390
390
|
|
|
391
|
-
# Find a track with notes to analyze harmony
|
|
392
|
-
#
|
|
391
|
+
# Find a track with notes to analyze harmony.
|
|
392
|
+
# BUG-E3 fix: score each active track for harmonic-ness and aggregate
|
|
393
|
+
# notes across all tracks that pass a threshold. Percussion tracks
|
|
394
|
+
# (all-single-pitch staccato stabs) scramble key detection when treated
|
|
395
|
+
# as the canonical harmonic source. Aggregating pad + bass notes yields
|
|
396
|
+
# the true key, and picking the highest-scoring single track for chord
|
|
397
|
+
# extraction gives the cleanest chord groupings.
|
|
393
398
|
from . import _theory_engine as theory_engine
|
|
394
399
|
from . import _harmony_engine as harmony_engine
|
|
395
400
|
|
|
@@ -398,27 +403,65 @@ def get_harmony_field(
|
|
|
398
403
|
progression_info = None
|
|
399
404
|
voice_leading_info = None
|
|
400
405
|
|
|
406
|
+
# Name lookup for track-name-based harmonic scoring hints
|
|
407
|
+
track_names = {t.get("index", i): t.get("name", "")
|
|
408
|
+
for i, t in enumerate(tracks)}
|
|
409
|
+
|
|
410
|
+
# Per-track scan: fetch notes + score, then sort by score desc.
|
|
411
|
+
HARMONIC_THRESHOLD = 0.3
|
|
412
|
+
candidates: list[tuple[float, int, list[dict]]] = []
|
|
401
413
|
for t_idx in section.tracks_active:
|
|
402
414
|
try:
|
|
403
|
-
# Get notes via TCP (valid Remote Script command)
|
|
404
415
|
result = ableton.send_command("get_notes", {
|
|
405
416
|
"track_index": t_idx, "clip_index": section_index,
|
|
406
417
|
})
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
418
|
+
except Exception as exc:
|
|
419
|
+
logger.debug("harmony scan track %d: %s", t_idx, exc)
|
|
420
|
+
continue
|
|
421
|
+
notes = result.get("notes", []) if isinstance(result, dict) else []
|
|
422
|
+
if not notes:
|
|
423
|
+
continue
|
|
424
|
+
score = engine.harmonic_score(notes, track_names.get(t_idx, ""))
|
|
425
|
+
candidates.append((score, t_idx, notes))
|
|
426
|
+
|
|
427
|
+
# Sort highest score first; ties broken by track index for stability.
|
|
428
|
+
candidates.sort(key=lambda c: (-c[0], c[1]))
|
|
429
|
+
|
|
430
|
+
# Aggregate harmonic notes for key detection; pick the top candidate
|
|
431
|
+
# for chord extraction.
|
|
432
|
+
harmonic_notes: list[dict] = []
|
|
433
|
+
harmonic_track_idx: Optional[int] = None
|
|
434
|
+
for score, t_idx, notes in candidates:
|
|
435
|
+
if score < HARMONIC_THRESHOLD:
|
|
436
|
+
continue
|
|
437
|
+
harmonic_notes.extend(notes)
|
|
438
|
+
if harmonic_track_idx is None:
|
|
439
|
+
harmonic_track_idx = t_idx
|
|
440
|
+
|
|
441
|
+
# If nothing passed the threshold, fall back to the highest-scoring
|
|
442
|
+
# track (or the first with any notes) to stay honest on edge cases.
|
|
443
|
+
if not harmonic_notes and candidates:
|
|
444
|
+
_, harmonic_track_idx, fallback_notes = candidates[0]
|
|
445
|
+
harmonic_notes = fallback_notes
|
|
446
|
+
|
|
447
|
+
if harmonic_notes and harmonic_track_idx is not None:
|
|
448
|
+
try:
|
|
449
|
+
# identify_scale on the AGGREGATED harmonic pool
|
|
450
|
+
detected = theory_engine.detect_key(harmonic_notes, mode_detection=True)
|
|
451
|
+
top = {
|
|
452
|
+
"key": f"{detected['tonic_name']} {detected['mode'].replace('_', ' ')}",
|
|
453
|
+
"confidence": detected["confidence"],
|
|
454
|
+
"mode": detected["mode"].replace("_", " "),
|
|
455
|
+
"mode_id": detected["mode"],
|
|
456
|
+
"tonic": detected["tonic_name"],
|
|
457
|
+
}
|
|
458
|
+
scale_info = {"top_match": top}
|
|
459
|
+
|
|
460
|
+
# Chord extraction: use the notes from the top-scoring track
|
|
461
|
+
# so chord groups don't get polluted by simultaneous notes
|
|
462
|
+
# across unrelated tracks (bass + pad + lead would fuse into
|
|
463
|
+
# chord aggregates that no single instrument actually plays).
|
|
464
|
+
notes = next(n for s, t, n in candidates if t == harmonic_track_idx)
|
|
422
465
|
|
|
423
466
|
# analyze_harmony: chordify + roman numeral analysis directly
|
|
424
467
|
if not harmony_analysis:
|
|
@@ -491,12 +534,12 @@ def get_harmony_field(
|
|
|
491
534
|
}
|
|
492
535
|
except Exception as exc:
|
|
493
536
|
logger.warning("voice_leading analysis failed: %s", exc)
|
|
494
|
-
|
|
495
|
-
if scale_info and harmony_analysis:
|
|
496
|
-
break
|
|
497
537
|
except Exception as exc:
|
|
498
|
-
|
|
499
|
-
|
|
538
|
+
# Any per-track analysis failure — log and emit whatever we
|
|
539
|
+
# have. Unlike the old loop we're not iterating further, so
|
|
540
|
+
# there's nowhere to continue to.
|
|
541
|
+
logger.debug("harmony analysis on track %s failed: %s",
|
|
542
|
+
harmonic_track_idx, exc)
|
|
500
543
|
|
|
501
544
|
hf = engine.build_harmony_field(
|
|
502
545
|
section_id=section.section_id,
|
|
@@ -249,7 +249,28 @@ def set_device_parameter(
|
|
|
249
249
|
parameter_index: Optional[int] = None,
|
|
250
250
|
) -> dict:
|
|
251
251
|
"""Set a device parameter by name or index.
|
|
252
|
-
|
|
252
|
+
|
|
253
|
+
track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master.
|
|
254
|
+
|
|
255
|
+
⚠️ PARAMETER RANGES ARE NOT ALWAYS 0-1 (BUG-B4 / B9):
|
|
256
|
+
Ableton devices use MIXED units depending on the parameter. Always
|
|
257
|
+
read the `value_string` in the response (and the `min`/`max` from
|
|
258
|
+
get_device_parameters) before assuming 0-1 semantics:
|
|
259
|
+
|
|
260
|
+
- Auto Filter `Frequency`: 20-135 index (NOT normalized)
|
|
261
|
+
- Auto Filter Legacy `LFO Amount`: 0-30 absolute (displays as %)
|
|
262
|
+
- Auto Filter `Resonance`: 0-1.25 on legacy, 0-1 on AutoFilter2
|
|
263
|
+
- Auto Filter `Env. Modulation`: -127..+127 on legacy
|
|
264
|
+
- Compressor I, Dynamic Tube, Vocoder: pre-2010 units
|
|
265
|
+
- EQ Three `Frequency Hi/Lo`: 50Hz-15kHz absolute
|
|
266
|
+
- Wavetable `Osc 1 Pos`: 0-1 normalized ✓
|
|
267
|
+
- Drift / Analog / Operator macros: 0-1 normalized ✓
|
|
268
|
+
|
|
269
|
+
The `value_string` field in the response is the SOURCE OF TRUTH
|
|
270
|
+
for what the user sees. Automation recipes that assume 0-1 will
|
|
271
|
+
clamp on legacy devices. When in doubt, call
|
|
272
|
+
get_device_parameters first to inspect min/max/is_quantized.
|
|
273
|
+
"""
|
|
253
274
|
_validate_track_index(track_index)
|
|
254
275
|
_validate_device_index(device_index)
|
|
255
276
|
if parameter_name is None and parameter_index is None:
|
|
@@ -129,19 +129,37 @@ def find_voice_leading_path(
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
path_strs = [harmony.chord_to_str(*c) for c in result["path"]]
|
|
132
|
+
|
|
133
|
+
# BUG-B25 fix: optimize voice assignment. The old code emitted each
|
|
134
|
+
# chord at its root-position octave-4 voicing, so moving D minor →
|
|
135
|
+
# Bb major (D F A → Bb D F) appeared as a minor-6th jump instead of
|
|
136
|
+
# the smooth D→D / F→F / A→Bb voice leading a pianist would pick.
|
|
137
|
+
# We now walk the path keeping the FIRST chord at its default
|
|
138
|
+
# voicing and, for each subsequent chord, pick the permutation
|
|
139
|
+
# (inversion + octave offsets) that minimizes total semitone
|
|
140
|
+
# movement from the previous voicing.
|
|
132
141
|
voice_leading = []
|
|
142
|
+
prev_voicing = harmony.chord_to_midi(*result["path"][0]) if result["path"] else []
|
|
143
|
+
|
|
133
144
|
for i in range(len(result["path"]) - 1):
|
|
134
|
-
|
|
135
|
-
|
|
145
|
+
next_chord = result["path"][i + 1]
|
|
146
|
+
candidate_voicing = harmony.chord_to_midi(*next_chord)
|
|
147
|
+
optimized_voicing = _optimize_voicing(prev_voicing, candidate_voicing)
|
|
148
|
+
|
|
136
149
|
movements = []
|
|
137
|
-
for f, t in zip(
|
|
150
|
+
for f, t in zip(prev_voicing, optimized_voicing):
|
|
138
151
|
if f != t:
|
|
139
152
|
movements.append(f"{theory.pitch_name(f)}→{theory.pitch_name(t)}")
|
|
153
|
+
|
|
140
154
|
voice_leading.append({
|
|
141
|
-
"from":
|
|
142
|
-
"to":
|
|
155
|
+
"from": list(prev_voicing),
|
|
156
|
+
"to": list(optimized_voicing),
|
|
143
157
|
"movement": ", ".join(movements) if movements else "no movement",
|
|
158
|
+
"total_semitone_movement": sum(
|
|
159
|
+
abs(t - f) for f, t in zip(prev_voicing, optimized_voicing)
|
|
160
|
+
),
|
|
144
161
|
})
|
|
162
|
+
prev_voicing = optimized_voicing
|
|
145
163
|
|
|
146
164
|
return {
|
|
147
165
|
"from": from_chord,
|
|
@@ -154,6 +172,40 @@ def find_voice_leading_path(
|
|
|
154
172
|
}
|
|
155
173
|
|
|
156
174
|
|
|
175
|
+
def _optimize_voicing(prev_voicing: list[int], target_pitches: list[int]) -> list[int]:
|
|
176
|
+
"""Pick an inversion/octave arrangement of *target_pitches* that
|
|
177
|
+
minimizes total semitone movement from *prev_voicing*.
|
|
178
|
+
|
|
179
|
+
Search space: for each permutation of target_pitches (3 voices →
|
|
180
|
+
6 permutations), for each voice try octave offsets in ±2 octaves.
|
|
181
|
+
That's 6 * 5^3 = 750 combinations per transition — trivial at runtime
|
|
182
|
+
but dramatically smoother output than fixed-octave voicings.
|
|
183
|
+
|
|
184
|
+
Assumes same voice-count on both sides; falls back to target_pitches
|
|
185
|
+
unchanged if lengths differ.
|
|
186
|
+
"""
|
|
187
|
+
import itertools
|
|
188
|
+
|
|
189
|
+
if len(prev_voicing) != len(target_pitches) or not target_pitches:
|
|
190
|
+
return list(target_pitches)
|
|
191
|
+
|
|
192
|
+
best_voicing = list(target_pitches)
|
|
193
|
+
best_cost = sum(abs(t - f) for f, t in zip(prev_voicing, best_voicing))
|
|
194
|
+
|
|
195
|
+
# Each voice can float ±2 octaves (±24 semitones) from the base pitch
|
|
196
|
+
octave_offsets = (-24, -12, 0, 12, 24)
|
|
197
|
+
|
|
198
|
+
for perm in itertools.permutations(target_pitches):
|
|
199
|
+
for offs in itertools.product(octave_offsets, repeat=len(perm)):
|
|
200
|
+
candidate = [p + o for p, o in zip(perm, offs)]
|
|
201
|
+
cost = sum(abs(t - f) for f, t in zip(prev_voicing, candidate))
|
|
202
|
+
if cost < best_cost:
|
|
203
|
+
best_cost = cost
|
|
204
|
+
best_voicing = candidate
|
|
205
|
+
|
|
206
|
+
return best_voicing
|
|
207
|
+
|
|
208
|
+
|
|
157
209
|
# -- Tool 3: classify_progression --------------------------------------------
|
|
158
210
|
|
|
159
211
|
@mcp.tool()
|
|
@@ -191,24 +243,72 @@ def classify_progression(
|
|
|
191
243
|
|
|
192
244
|
classification = "free neo-Riemannian progression"
|
|
193
245
|
notable_usage = None
|
|
194
|
-
clean = pattern.replace("?", "")
|
|
195
246
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
247
|
+
# BUG-B24: the old code did `clean = pattern.replace("?", "")` and
|
|
248
|
+
# then checked alphabet purity on the cleaned string. That gave
|
|
249
|
+
# a cheerful "diatonic cycle fragment" label to a pattern like
|
|
250
|
+
# "LR?LR" — silently ignoring the middle step motion.
|
|
251
|
+
# Now we check alphabet purity on the FULL pattern (only counting
|
|
252
|
+
# transforms that landed in the target alphabet) AND track whether
|
|
253
|
+
# any transforms were unclassified OR were step primitives that
|
|
254
|
+
# aren't part of the target cycle alphabet.
|
|
255
|
+
|
|
256
|
+
def _primitives(pat: str) -> list[str]:
|
|
257
|
+
"""Split a concatenated pattern into its atomic tokens.
|
|
258
|
+
|
|
259
|
+
Tokens: P / L / R single letters, S1u/S1d/S2u/S2d step markers,
|
|
260
|
+
and ? for unknown. The tokenizer walks left-to-right matching
|
|
261
|
+
the longest known token at each position.
|
|
262
|
+
"""
|
|
263
|
+
known = ("S1u", "S1d", "S2u", "S2d")
|
|
264
|
+
out = []
|
|
265
|
+
i = 0
|
|
266
|
+
while i < len(pat):
|
|
267
|
+
matched = None
|
|
268
|
+
for tok in known:
|
|
269
|
+
if pat.startswith(tok, i):
|
|
270
|
+
matched = tok
|
|
271
|
+
break
|
|
272
|
+
if matched is None:
|
|
273
|
+
out.append(pat[i])
|
|
274
|
+
i += 1
|
|
275
|
+
else:
|
|
276
|
+
out.append(matched)
|
|
277
|
+
i += len(matched)
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
tokens = _primitives(pattern)
|
|
281
|
+
core_tokens = [t for t in tokens if t in ("P", "L", "R")]
|
|
282
|
+
step_tokens = [t for t in tokens if t.startswith("S")]
|
|
283
|
+
unknown_count = sum(1 for t in tokens if t == "?")
|
|
284
|
+
|
|
285
|
+
if len(core_tokens) >= 2:
|
|
286
|
+
alphabet = set(core_tokens)
|
|
287
|
+
if alphabet.issubset({"P", "L"}):
|
|
199
288
|
classification = "hexatonic cycle fragment"
|
|
200
289
|
notable_usage = "Radiohead, film scores (Zimmer, Howard)"
|
|
201
|
-
elif
|
|
290
|
+
elif alphabet.issubset({"P", "R"}):
|
|
202
291
|
classification = "octatonic cycle fragment"
|
|
203
292
|
notable_usage = "late Romantic (Wagner, Strauss), horror film scores"
|
|
204
|
-
elif
|
|
293
|
+
elif alphabet.issubset({"L", "R"}):
|
|
205
294
|
classification = "diatonic cycle fragment"
|
|
206
295
|
notable_usage = "functional harmony, common in classical and pop"
|
|
207
|
-
|
|
208
|
-
if len(clean) == 1:
|
|
296
|
+
elif len(core_tokens) == 1:
|
|
209
297
|
names = {"P": "parallel transform", "L": "leading-tone transform",
|
|
210
298
|
"R": "relative transform"}
|
|
211
|
-
classification = names.get(
|
|
299
|
+
classification = names.get(core_tokens[0], classification)
|
|
300
|
+
|
|
301
|
+
# Annotate when the progression isn't purely in the classified alphabet
|
|
302
|
+
annotations = []
|
|
303
|
+
if step_tokens:
|
|
304
|
+
annotations.append("with diatonic step motion")
|
|
305
|
+
if unknown_count:
|
|
306
|
+
annotations.append(
|
|
307
|
+
f"with {unknown_count} unclassified transition"
|
|
308
|
+
+ ("s" if unknown_count != 1 else "")
|
|
309
|
+
)
|
|
310
|
+
if annotations:
|
|
311
|
+
classification = f"{classification} ({', '.join(annotations)})"
|
|
212
312
|
|
|
213
313
|
return {
|
|
214
314
|
"chords": normalized,
|
|
@@ -216,6 +316,7 @@ def classify_progression(
|
|
|
216
316
|
"pattern": pattern,
|
|
217
317
|
"classification": classification,
|
|
218
318
|
"notable_usage": notable_usage,
|
|
319
|
+
"unknown_transitions": unknown_count,
|
|
219
320
|
}
|
|
220
321
|
|
|
221
322
|
|
|
@@ -130,7 +130,19 @@ def export_clip_midi(
|
|
|
130
130
|
if not filename.endswith((".mid", ".midi")):
|
|
131
131
|
filename += ".mid"
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
# BUG-B52: honor user-provided absolute paths. Previously the tool
|
|
134
|
+
# stripped the directory component and always wrote to the default
|
|
135
|
+
# output dir — a security posture meant to block path traversal but
|
|
136
|
+
# over-broad for legitimate absolute paths the user explicitly chose.
|
|
137
|
+
# Now: absolute paths are honored (creating parent dirs if needed);
|
|
138
|
+
# bare filenames / relative paths still get containment via
|
|
139
|
+
# _safe_output_path.
|
|
140
|
+
user_path = Path(filename)
|
|
141
|
+
if user_path.is_absolute():
|
|
142
|
+
user_path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
out_path = user_path.resolve()
|
|
144
|
+
else:
|
|
145
|
+
out_path = _safe_output_path(_output_dir(), filename)
|
|
134
146
|
|
|
135
147
|
midi = MIDIFile(1)
|
|
136
148
|
midi.addTempo(0, 0, tempo)
|
|
@@ -100,13 +100,47 @@ def get_track_meters(
|
|
|
100
100
|
|
|
101
101
|
track_index: specific track (omit for all tracks)
|
|
102
102
|
include_stereo: include left/right channel meters (adds GUI load)
|
|
103
|
+
|
|
104
|
+
BUG-B3: when playback is stopped, `level` reports peak-hold from the
|
|
105
|
+
last loud moment while `left`/`right` report instantaneous channel
|
|
106
|
+
levels (which decay to 0). The two fields then visibly disagree, and
|
|
107
|
+
callers debugging "is my filter killing the signal?" get false alarms.
|
|
108
|
+
We now tag each response with `is_playing` so callers can interpret
|
|
109
|
+
the levels correctly, and — when include_stereo=True AND playback is
|
|
110
|
+
stopped — we mark left/right as `null` instead of 0 so the semantic
|
|
111
|
+
is explicit.
|
|
103
112
|
"""
|
|
104
113
|
params: dict = {}
|
|
105
114
|
if track_index is not None:
|
|
106
115
|
params["track_index"] = track_index
|
|
107
116
|
if include_stereo:
|
|
108
117
|
params["include_stereo"] = include_stereo
|
|
109
|
-
|
|
118
|
+
ableton = _get_ableton(ctx)
|
|
119
|
+
result = ableton.send_command("get_track_meters", params)
|
|
120
|
+
|
|
121
|
+
# Probe playback state once so we can annotate the response
|
|
122
|
+
try:
|
|
123
|
+
session = ableton.send_command("get_session_info", {})
|
|
124
|
+
is_playing = bool(session.get("is_playing", False))
|
|
125
|
+
except Exception:
|
|
126
|
+
is_playing = None # unknown — leave left/right as reported
|
|
127
|
+
|
|
128
|
+
if not isinstance(result, dict):
|
|
129
|
+
return result
|
|
130
|
+
result["is_playing"] = is_playing
|
|
131
|
+
# When stopped AND stereo was requested, mark l/r as None so they
|
|
132
|
+
# don't look like a killed signal
|
|
133
|
+
if include_stereo and is_playing is False:
|
|
134
|
+
for t in result.get("tracks", []):
|
|
135
|
+
if isinstance(t, dict):
|
|
136
|
+
if t.get("left") == 0 and t.get("right") == 0:
|
|
137
|
+
t["left"] = None
|
|
138
|
+
t["right"] = None
|
|
139
|
+
t["_stereo_note"] = (
|
|
140
|
+
"left/right suppressed because playback is stopped; "
|
|
141
|
+
"`level` is peak-hold from the last audio event"
|
|
142
|
+
)
|
|
143
|
+
return result
|
|
110
144
|
|
|
111
145
|
|
|
112
146
|
@mcp.tool()
|
|
@@ -22,7 +22,12 @@ def _get_ableton(ctx: Context):
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
@mcp.tool()
|
|
25
|
-
def get_motif_graph(
|
|
25
|
+
def get_motif_graph(
|
|
26
|
+
ctx: Context,
|
|
27
|
+
limit: int = 50,
|
|
28
|
+
offset: int = 0,
|
|
29
|
+
summary_only: bool = False,
|
|
30
|
+
) -> dict:
|
|
26
31
|
"""Detect recurring melodic and rhythmic patterns across all tracks.
|
|
27
32
|
|
|
28
33
|
Scans note data from all session clips to find repeated interval
|
|
@@ -31,7 +36,27 @@ def get_motif_graph(ctx: Context) -> dict:
|
|
|
31
36
|
|
|
32
37
|
Use this to understand what musical ideas are present and which
|
|
33
38
|
ones need development or variation.
|
|
39
|
+
|
|
40
|
+
BUG-B7 fix: sessions with many clips produced 90 KB+ payloads that
|
|
41
|
+
exceeded inline-tool-response limits. Callers now page the list and
|
|
42
|
+
can opt into a compact summary view that drops per-motif occurrence
|
|
43
|
+
arrays and suggested_developments.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
limit: maximum motifs returned per call (default 50, max 500).
|
|
47
|
+
offset: skip this many of the highest-salience motifs (for paging).
|
|
48
|
+
summary_only: return only motif_id + kind + salience + fatigue_risk
|
|
49
|
+
+ occurrence_count per motif, dropping occurrences
|
|
50
|
+
and other lists. Use when you need a bird's-eye view.
|
|
34
51
|
"""
|
|
52
|
+
# Cheap input validation — these bounds match the tool contract the
|
|
53
|
+
# rest of the server relies on for inline responses.
|
|
54
|
+
if limit < 0:
|
|
55
|
+
raise ValueError("limit must be >= 0")
|
|
56
|
+
if offset < 0:
|
|
57
|
+
raise ValueError("offset must be >= 0")
|
|
58
|
+
limit = min(limit, 500)
|
|
59
|
+
|
|
35
60
|
ableton = _get_ableton(ctx)
|
|
36
61
|
session = ableton.send_command("get_session_info")
|
|
37
62
|
tracks = session.get("tracks", [])
|
|
@@ -57,10 +82,31 @@ def get_motif_graph(ctx: Context) -> dict:
|
|
|
57
82
|
notes_by_track[t_idx] = track_notes
|
|
58
83
|
|
|
59
84
|
motifs = motif_engine.detect_motifs(notes_by_track)
|
|
85
|
+
total = len(motifs)
|
|
86
|
+
page = motifs[offset:offset + limit] if limit > 0 else []
|
|
87
|
+
|
|
88
|
+
if summary_only:
|
|
89
|
+
# Compact per-motif record: keep identity + scoring signals, drop
|
|
90
|
+
# occurrences / developments / pitch/rhythm payloads that balloon
|
|
91
|
+
# the response on complex sessions.
|
|
92
|
+
motif_dicts = [{
|
|
93
|
+
"motif_id": m.motif_id,
|
|
94
|
+
"kind": m.kind,
|
|
95
|
+
"salience": m.salience,
|
|
96
|
+
"fatigue_risk": m.fatigue_risk,
|
|
97
|
+
"occurrence_count": len(m.occurrences),
|
|
98
|
+
} for m in page]
|
|
99
|
+
else:
|
|
100
|
+
motif_dicts = [m.to_dict() for m in page]
|
|
60
101
|
|
|
61
102
|
return {
|
|
62
|
-
"motifs":
|
|
63
|
-
"motif_count": len(
|
|
103
|
+
"motifs": motif_dicts,
|
|
104
|
+
"motif_count": len(motif_dicts),
|
|
105
|
+
"total_motifs": total,
|
|
106
|
+
"offset": offset,
|
|
107
|
+
"limit": limit,
|
|
108
|
+
"summary_only": summary_only,
|
|
109
|
+
"has_more": offset + len(motif_dicts) < total,
|
|
64
110
|
"tracks_analyzed": len(notes_by_track),
|
|
65
111
|
}
|
|
66
112
|
|
|
@@ -121,6 +121,30 @@ def get_emotional_arc(ctx: Context) -> dict:
|
|
|
121
121
|
no resolution at the end, peak too early.
|
|
122
122
|
|
|
123
123
|
Returns: tension curve and issues with recommended composition moves.
|
|
124
|
+
|
|
125
|
+
📌 On the `tension_curve` vs other energy metrics (BUG-B21 clarification):
|
|
126
|
+
LivePilot exposes THREE intentionally different "energy-like"
|
|
127
|
+
signals — they are NOT scaled versions of each other:
|
|
128
|
+
|
|
129
|
+
1. `get_section_graph.energy` / `get_performance_state.energy_level`
|
|
130
|
+
→ density-based (active-track ratio per section). After the
|
|
131
|
+
Batch 6 cross-engine unification these two are identical.
|
|
132
|
+
Use when asking "how busy is this section?"
|
|
133
|
+
|
|
134
|
+
2. `get_emotional_arc.tension` (this tool)
|
|
135
|
+
→ narrative-arc signal weighted by harmonic instability,
|
|
136
|
+
section placement, and payoff/contrast. Use when asking
|
|
137
|
+
"where does the song want to go emotionally?" — tension
|
|
138
|
+
can be HIGH in a sparse-but-anticipatory section (low
|
|
139
|
+
density) and LOW in a busy-but-resolved section (high
|
|
140
|
+
density).
|
|
141
|
+
|
|
142
|
+
3. `get_performance_state.energy_window.target_energy`
|
|
143
|
+
→ forward-looking — next-scene target, not current state.
|
|
144
|
+
|
|
145
|
+
If the three readings disagree for the same section, that's the
|
|
146
|
+
DESIGN: density ≠ tension ≠ intended destination. Pick the one
|
|
147
|
+
that matches your question.
|
|
124
148
|
"""
|
|
125
149
|
from . import _composition_engine as engine
|
|
126
150
|
|