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
|
@@ -114,7 +114,7 @@ def stop_clip(ctx: Context, track_index: int, clip_index: int) -> dict:
|
|
|
114
114
|
|
|
115
115
|
@mcp.tool()
|
|
116
116
|
def set_clip_name(ctx: Context, track_index: int, clip_index: int, name: str) -> dict:
|
|
117
|
-
"""Rename a clip."""
|
|
117
|
+
"""Rename a clip in the Session view. The new name appears on the clip slot and in Device Chain displays."""
|
|
118
118
|
_validate_track_index(track_index)
|
|
119
119
|
_validate_clip_index(clip_index)
|
|
120
120
|
if not name.strip():
|
|
@@ -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,29 @@ 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)
|
|
146
|
+
|
|
147
|
+
# Extension guard: after path resolution, confirm the final file really
|
|
148
|
+
# has a MIDI extension. Blocks a model-supplied path like
|
|
149
|
+
# "/etc/cron.d/evil" that accidentally drops its extension through the
|
|
150
|
+
# resolve() step, or a caller that passed "evil.mid/../evil".
|
|
151
|
+
if out_path.suffix.lower() not in {".mid", ".midi"}:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"Refusing to write non-MIDI file: {out_path}. "
|
|
154
|
+
f"export_clip_midi requires a .mid or .midi extension."
|
|
155
|
+
)
|
|
134
156
|
|
|
135
157
|
midi = MIDIFile(1)
|
|
136
158
|
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
|
|