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.
Files changed (78) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcp.json.disabled +9 -0
  3. package/.mcpbignore +3 -0
  4. package/AGENTS.md +3 -3
  5. package/BUGS.md +1570 -0
  6. package/CHANGELOG.md +42 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +7 -7
  9. package/bin/livepilot.js +28 -8
  10. package/livepilot/.Codex-plugin/plugin.json +2 -2
  11. package/livepilot/.claude-plugin/plugin.json +2 -2
  12. package/livepilot/skills/livepilot-core/SKILL.md +4 -4
  13. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  14. package/livepilot/skills/livepilot-release/SKILL.md +8 -8
  15. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  17. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  18. package/m4l_device/livepilot_bridge.js +214 -2
  19. package/manifest.json +3 -3
  20. package/mcp_server/__init__.py +1 -1
  21. package/mcp_server/atlas/__init__.py +93 -26
  22. package/mcp_server/creative_constraints/tools.py +206 -33
  23. package/mcp_server/experiment/engine.py +7 -9
  24. package/mcp_server/hook_hunter/analyzer.py +62 -9
  25. package/mcp_server/hook_hunter/tools.py +60 -9
  26. package/mcp_server/m4l_bridge.py +21 -6
  27. package/mcp_server/musical_intelligence/detectors.py +32 -0
  28. package/mcp_server/performance_engine/tools.py +112 -29
  29. package/mcp_server/preview_studio/engine.py +89 -8
  30. package/mcp_server/preview_studio/tools.py +22 -6
  31. package/mcp_server/project_brain/automation_graph.py +71 -19
  32. package/mcp_server/project_brain/builder.py +2 -0
  33. package/mcp_server/project_brain/tools.py +55 -5
  34. package/mcp_server/reference_engine/profile_builder.py +129 -3
  35. package/mcp_server/reference_engine/tools.py +47 -6
  36. package/mcp_server/runtime/execution_router.py +50 -0
  37. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  38. package/mcp_server/runtime/remote_commands.py +4 -2
  39. package/mcp_server/sample_engine/analyzer.py +131 -4
  40. package/mcp_server/sample_engine/critics.py +29 -8
  41. package/mcp_server/sample_engine/models.py +20 -1
  42. package/mcp_server/sample_engine/tools.py +48 -14
  43. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  44. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  45. package/mcp_server/server.py +68 -2
  46. package/mcp_server/session_continuity/models.py +4 -0
  47. package/mcp_server/session_continuity/tracker.py +14 -1
  48. package/mcp_server/song_brain/builder.py +110 -12
  49. package/mcp_server/song_brain/tools.py +77 -13
  50. package/mcp_server/sound_design/tools.py +112 -1
  51. package/mcp_server/stuckness_detector/detector.py +90 -0
  52. package/mcp_server/stuckness_detector/tools.py +41 -0
  53. package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
  54. package/mcp_server/tools/_composition_engine/__init__.py +2 -2
  55. package/mcp_server/tools/_composition_engine/harmony.py +90 -0
  56. package/mcp_server/tools/_composition_engine/sections.py +47 -4
  57. package/mcp_server/tools/_harmony_engine.py +52 -8
  58. package/mcp_server/tools/_research_engine.py +98 -19
  59. package/mcp_server/tools/_theory_engine.py +138 -9
  60. package/mcp_server/tools/agent_os.py +20 -3
  61. package/mcp_server/tools/analyzer.py +98 -0
  62. package/mcp_server/tools/clips.py +45 -0
  63. package/mcp_server/tools/composition.py +66 -23
  64. package/mcp_server/tools/devices.py +22 -1
  65. package/mcp_server/tools/harmony.py +115 -14
  66. package/mcp_server/tools/midi_io.py +13 -1
  67. package/mcp_server/tools/mixing.py +35 -1
  68. package/mcp_server/tools/motif.py +49 -3
  69. package/mcp_server/tools/research.py +24 -0
  70. package/mcp_server/tools/theory.py +108 -16
  71. package/mcp_server/transition_engine/critics.py +18 -11
  72. package/package.json +2 -2
  73. package/remote_script/LivePilot/__init__.py +57 -2
  74. package/remote_script/LivePilot/clips.py +69 -0
  75. package/remote_script/LivePilot/mixing.py +117 -0
  76. package/remote_script/LivePilot/router.py +13 -1
  77. package/scripts/generate_tool_catalog.py +13 -38
  78. 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
- # Use theory engine functions directly instead of TCP calls to MCP tools
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
- notes = result.get("notes", [])
408
- if not notes:
409
- continue
410
-
411
- # identify_scale: run key detection directly
412
- if not scale_info:
413
- detected = theory_engine.detect_key(notes, mode_detection=True)
414
- top = {
415
- "key": f"{detected['tonic_name']} {detected['mode'].replace('_', ' ')}",
416
- "confidence": detected["confidence"],
417
- "mode": detected["mode"].replace("_", " "),
418
- "mode_id": detected["mode"],
419
- "tonic": detected["tonic_name"],
420
- }
421
- scale_info = {"top_match": top}
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
- logger.debug("harmony scan on track %d skipped: %s", t_idx, exc)
499
- continue
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
- track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
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
- from_midi = harmony.chord_to_midi(*result["path"][i])
135
- to_midi = harmony.chord_to_midi(*result["path"][i + 1])
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(from_midi, to_midi):
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": from_midi,
142
- "to": to_midi,
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
- if len(clean) >= 2:
197
- pair = clean[:2]
198
- if pair in ("PL", "LP") and all(c in "PL" for c in clean):
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 pair in ("PR", "RP") and all(c in "PR" for c in clean):
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 pair in ("LR", "RL") and all(c in "LR" for c in clean):
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(clean, classification)
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
- out_path = _safe_output_path(_output_dir(), filename)
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
- return _get_ableton(ctx).send_command("get_track_meters", params)
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(ctx: Context) -> dict:
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": [m.to_dict() for m in motifs],
63
- "motif_count": len(motifs),
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