livepilot 1.10.5 → 1.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +92 -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-evaluation/references/capability-modes.md +1 -1
- 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 +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- 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 +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- 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/runtime/tools.py +8 -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 +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- 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 +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- 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 +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- 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
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -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
|
|
|
@@ -14,6 +14,7 @@ These tools power the composition intelligence layer:
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import logging
|
|
17
18
|
from typing import Optional
|
|
18
19
|
|
|
19
20
|
from fastmcp import Context
|
|
@@ -22,6 +23,8 @@ from ..server import mcp
|
|
|
22
23
|
from ..memory.technique_store import TechniqueStore
|
|
23
24
|
from . import _composition_engine as engine
|
|
24
25
|
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
25
28
|
_memory_store = TechniqueStore()
|
|
26
29
|
|
|
27
30
|
|
|
@@ -49,7 +52,8 @@ def _build_clip_matrix(ableton, scene_count: int, track_count: int) -> list[list
|
|
|
49
52
|
matrix_data = ableton.send_command("get_scene_matrix")
|
|
50
53
|
raw_matrix = matrix_data.get("matrix", [])
|
|
51
54
|
return raw_matrix
|
|
52
|
-
except Exception:
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
logger.warning("get_scene_matrix failed, using empty matrix: %s", exc)
|
|
53
57
|
return [[] for _ in range(scene_count)]
|
|
54
58
|
|
|
55
59
|
|
|
@@ -95,8 +99,8 @@ def analyze_composition(ctx: Context) -> dict:
|
|
|
95
99
|
clips = arr.get("clips", [])
|
|
96
100
|
if clips:
|
|
97
101
|
arr_clips[track["index"]] = clips
|
|
98
|
-
except Exception:
|
|
99
|
-
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
logger.debug("arrangement_clips track=%s skipped: %s", track.get("index"), exc)
|
|
100
104
|
|
|
101
105
|
if not sections and arr_clips:
|
|
102
106
|
sections = engine.build_section_graph_from_arrangement(
|
|
@@ -111,7 +115,8 @@ def analyze_composition(ctx: Context) -> dict:
|
|
|
111
115
|
"track_index": track["index"]
|
|
112
116
|
})
|
|
113
117
|
track_data.append(ti)
|
|
114
|
-
except Exception:
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
logger.debug("get_track_info track=%s fallback: %s", track.get("index"), exc)
|
|
115
120
|
track_data.append({"index": track["index"], "name": track.get("name", ""),
|
|
116
121
|
"devices": []})
|
|
117
122
|
|
|
@@ -130,8 +135,8 @@ def analyze_composition(ctx: Context) -> dict:
|
|
|
130
135
|
})
|
|
131
136
|
notes = result.get("notes", [])
|
|
132
137
|
track_notes.extend(notes)
|
|
133
|
-
except Exception:
|
|
134
|
-
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
logger.debug("get_notes t=%d s=%d skipped: %s", t_idx, s_idx, exc)
|
|
135
140
|
all_notes_by_track[t_idx] = track_notes
|
|
136
141
|
|
|
137
142
|
# Map notes to sections
|
|
@@ -244,7 +249,8 @@ def get_phrase_grid(
|
|
|
244
249
|
"clip_index": scene_idx,
|
|
245
250
|
})
|
|
246
251
|
notes_by_track[t_idx] = result.get("notes", [])
|
|
247
|
-
except Exception:
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
logger.debug("get_notes t=%d s=%d empty: %s", t_idx, scene_idx, exc)
|
|
248
254
|
notes_by_track[t_idx] = []
|
|
249
255
|
|
|
250
256
|
phrases = engine.detect_phrases(section, notes_by_track)
|
|
@@ -382,8 +388,13 @@ def get_harmony_field(
|
|
|
382
388
|
|
|
383
389
|
section = sections[section_index]
|
|
384
390
|
|
|
385
|
-
# Find a track with notes to analyze harmony
|
|
386
|
-
#
|
|
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.
|
|
387
398
|
from . import _theory_engine as theory_engine
|
|
388
399
|
from . import _harmony_engine as harmony_engine
|
|
389
400
|
|
|
@@ -392,27 +403,65 @@ def get_harmony_field(
|
|
|
392
403
|
progression_info = None
|
|
393
404
|
voice_leading_info = None
|
|
394
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]]] = []
|
|
395
413
|
for t_idx in section.tracks_active:
|
|
396
414
|
try:
|
|
397
|
-
# Get notes via TCP (valid Remote Script command)
|
|
398
415
|
result = ableton.send_command("get_notes", {
|
|
399
416
|
"track_index": t_idx, "clip_index": section_index,
|
|
400
417
|
})
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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)
|
|
416
465
|
|
|
417
466
|
# analyze_harmony: chordify + roman numeral analysis directly
|
|
418
467
|
if not harmony_analysis:
|
|
@@ -464,8 +513,8 @@ def get_harmony_field(
|
|
|
464
513
|
"pattern": pattern,
|
|
465
514
|
"classification": classification,
|
|
466
515
|
}
|
|
467
|
-
except Exception:
|
|
468
|
-
|
|
516
|
+
except Exception as exc:
|
|
517
|
+
logger.warning("neo-Riemannian classify failed: %s", exc)
|
|
469
518
|
|
|
470
519
|
# Populate voice_leading_info from chord groups
|
|
471
520
|
if harmony_analysis and not voice_leading_info:
|
|
@@ -483,13 +532,14 @@ def get_harmony_field(
|
|
|
483
532
|
"issue_count": len(all_vl_issues),
|
|
484
533
|
"quality": "clean" if not all_vl_issues else "has_issues",
|
|
485
534
|
}
|
|
486
|
-
except Exception:
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
535
|
+
except Exception as exc:
|
|
536
|
+
logger.warning("voice_leading analysis failed: %s", exc)
|
|
537
|
+
except Exception as exc:
|
|
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)
|
|
493
543
|
|
|
494
544
|
hf = engine.build_harmony_field(
|
|
495
545
|
section_id=section.section_id,
|
|
@@ -535,7 +585,8 @@ def get_transition_analysis(ctx: Context) -> dict:
|
|
|
535
585
|
try:
|
|
536
586
|
ti = ableton.send_command("get_track_info", {"track_index": t_idx})
|
|
537
587
|
track_data.append(ti)
|
|
538
|
-
except Exception:
|
|
588
|
+
except Exception as exc:
|
|
589
|
+
logger.debug("get_track_info transition t=%d fallback: %s", t_idx, exc)
|
|
539
590
|
track_data.append({"index": t_idx, "name": track.get("name", ""), "devices": []})
|
|
540
591
|
|
|
541
592
|
for section in sections:
|
|
@@ -623,7 +674,8 @@ def get_section_outcomes(
|
|
|
623
674
|
techniques = _memory_store.list_techniques(
|
|
624
675
|
type_filter="composition_outcome", sort_by="updated_at", limit=limit,
|
|
625
676
|
)
|
|
626
|
-
except Exception:
|
|
677
|
+
except Exception as exc:
|
|
678
|
+
logger.warning("list_techniques(composition_outcome) failed: %s", exc)
|
|
627
679
|
techniques = []
|
|
628
680
|
|
|
629
681
|
outcomes = []
|
|
@@ -633,8 +685,8 @@ def get_section_outcomes(
|
|
|
633
685
|
payload = full.get("payload", {})
|
|
634
686
|
if isinstance(payload, dict):
|
|
635
687
|
outcomes.append(payload)
|
|
636
|
-
except Exception:
|
|
637
|
-
|
|
688
|
+
except Exception as exc:
|
|
689
|
+
logger.debug("technique %s payload read failed: %s", t.get("id"), exc)
|
|
638
690
|
|
|
639
691
|
result = engine.analyze_section_outcomes(outcomes)
|
|
640
692
|
|
|
@@ -11,6 +11,10 @@ from typing import Any, Optional
|
|
|
11
11
|
from fastmcp import Context
|
|
12
12
|
|
|
13
13
|
from ..server import mcp, _identify_port_holder
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
|
|
16
20
|
def _ensure_list(value: Any) -> list:
|
|
@@ -130,9 +134,9 @@ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
|
|
|
130
134
|
track_info = _get_ableton(ctx).send_command("get_track_info", {
|
|
131
135
|
"track_index": int(track_index),
|
|
132
136
|
})
|
|
133
|
-
except Exception:
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
logger.debug("_postflight_loaded_device failed: %s", exc)
|
|
134
139
|
return annotated
|
|
135
|
-
|
|
136
140
|
devices = track_info.get("devices", []) if isinstance(track_info, dict) else []
|
|
137
141
|
if not isinstance(devices, list) or not devices:
|
|
138
142
|
return annotated
|
|
@@ -156,9 +160,8 @@ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
|
|
|
156
160
|
"device_index": int(match["index"]),
|
|
157
161
|
})
|
|
158
162
|
param_count = full_info.get("parameter_count", 0)
|
|
159
|
-
except Exception:
|
|
160
|
-
|
|
161
|
-
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
logger.debug("_postflight_loaded_device failed: %s", exc)
|
|
162
165
|
device_info = _annotate_device_info({
|
|
163
166
|
"name": match.get("name"),
|
|
164
167
|
"class_name": match.get("class_name"),
|
|
@@ -246,7 +249,28 @@ def set_device_parameter(
|
|
|
246
249
|
parameter_index: Optional[int] = None,
|
|
247
250
|
) -> dict:
|
|
248
251
|
"""Set a device parameter by name or index.
|
|
249
|
-
|
|
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
|
+
"""
|
|
250
274
|
_validate_track_index(track_index)
|
|
251
275
|
_validate_device_index(device_index)
|
|
252
276
|
if parameter_name is None and parameter_index is None:
|
|
@@ -597,7 +621,8 @@ def _require_analyzer(cache) -> None:
|
|
|
597
621
|
ctx.lifespan_context["ableton"].send_command("get_master_track")
|
|
598
622
|
if ctx else {}
|
|
599
623
|
)
|
|
600
|
-
except Exception:
|
|
624
|
+
except Exception as exc:
|
|
625
|
+
logger.debug("_require_analyzer failed: %s", exc)
|
|
601
626
|
track = {}
|
|
602
627
|
|
|
603
628
|
devices = track.get("devices", []) if isinstance(track, dict) else []
|
|
@@ -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()
|