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.
Files changed (111) 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 +92 -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-evaluation/references/capability-modes.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +8 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  18. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  19. package/m4l_device/livepilot_bridge.js +226 -3
  20. package/manifest.json +3 -3
  21. package/mcp_server/__init__.py +1 -1
  22. package/mcp_server/atlas/__init__.py +93 -26
  23. package/mcp_server/composer/sample_resolver.py +10 -6
  24. package/mcp_server/composer/tools.py +10 -6
  25. package/mcp_server/connection.py +6 -1
  26. package/mcp_server/creative_constraints/tools.py +214 -40
  27. package/mcp_server/experiment/engine.py +16 -14
  28. package/mcp_server/experiment/tools.py +9 -9
  29. package/mcp_server/hook_hunter/analyzer.py +62 -9
  30. package/mcp_server/hook_hunter/tools.py +74 -18
  31. package/mcp_server/m4l_bridge.py +32 -6
  32. package/mcp_server/memory/taste_graph.py +7 -2
  33. package/mcp_server/mix_engine/tools.py +8 -3
  34. package/mcp_server/musical_intelligence/detectors.py +32 -0
  35. package/mcp_server/musical_intelligence/tools.py +15 -10
  36. package/mcp_server/performance_engine/tools.py +117 -30
  37. package/mcp_server/preview_studio/engine.py +89 -8
  38. package/mcp_server/preview_studio/tools.py +43 -21
  39. package/mcp_server/project_brain/automation_graph.py +71 -19
  40. package/mcp_server/project_brain/builder.py +2 -0
  41. package/mcp_server/project_brain/tools.py +73 -15
  42. package/mcp_server/reference_engine/profile_builder.py +129 -3
  43. package/mcp_server/reference_engine/tools.py +54 -11
  44. package/mcp_server/runtime/capability_probe.py +10 -4
  45. package/mcp_server/runtime/execution_router.py +50 -0
  46. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  47. package/mcp_server/runtime/remote_commands.py +4 -2
  48. package/mcp_server/runtime/tools.py +8 -2
  49. package/mcp_server/sample_engine/analyzer.py +131 -4
  50. package/mcp_server/sample_engine/critics.py +29 -8
  51. package/mcp_server/sample_engine/models.py +20 -1
  52. package/mcp_server/sample_engine/tools.py +74 -31
  53. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  54. package/mcp_server/semantic_moves/tools.py +5 -1
  55. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  56. package/mcp_server/server.py +78 -11
  57. package/mcp_server/services/motif_service.py +9 -3
  58. package/mcp_server/session_continuity/models.py +4 -0
  59. package/mcp_server/session_continuity/tools.py +7 -3
  60. package/mcp_server/session_continuity/tracker.py +23 -9
  61. package/mcp_server/song_brain/builder.py +110 -12
  62. package/mcp_server/song_brain/tools.py +94 -25
  63. package/mcp_server/sound_design/tools.py +112 -1
  64. package/mcp_server/splice_client/client.py +19 -6
  65. package/mcp_server/stuckness_detector/detector.py +90 -0
  66. package/mcp_server/stuckness_detector/tools.py +49 -5
  67. package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
  68. package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
  69. package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
  70. package/mcp_server/tools/_agent_os_engine/models.py +132 -0
  71. package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
  72. package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
  73. package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
  74. package/mcp_server/tools/_composition_engine/__init__.py +67 -0
  75. package/mcp_server/tools/_composition_engine/analysis.py +174 -0
  76. package/mcp_server/tools/_composition_engine/critics.py +522 -0
  77. package/mcp_server/tools/_composition_engine/gestures.py +230 -0
  78. package/mcp_server/tools/_composition_engine/harmony.py +160 -0
  79. package/mcp_server/tools/_composition_engine/models.py +193 -0
  80. package/mcp_server/tools/_composition_engine/sections.py +414 -0
  81. package/mcp_server/tools/_harmony_engine.py +52 -8
  82. package/mcp_server/tools/_perception_engine.py +18 -11
  83. package/mcp_server/tools/_research_engine.py +98 -19
  84. package/mcp_server/tools/_theory_engine.py +138 -9
  85. package/mcp_server/tools/agent_os.py +43 -18
  86. package/mcp_server/tools/analyzer.py +105 -8
  87. package/mcp_server/tools/automation.py +6 -1
  88. package/mcp_server/tools/clips.py +45 -0
  89. package/mcp_server/tools/composition.py +90 -38
  90. package/mcp_server/tools/devices.py +32 -7
  91. package/mcp_server/tools/harmony.py +115 -14
  92. package/mcp_server/tools/midi_io.py +13 -1
  93. package/mcp_server/tools/mixing.py +35 -1
  94. package/mcp_server/tools/motif.py +56 -5
  95. package/mcp_server/tools/planner.py +6 -2
  96. package/mcp_server/tools/research.py +37 -10
  97. package/mcp_server/tools/theory.py +108 -16
  98. package/mcp_server/transition_engine/critics.py +18 -11
  99. package/mcp_server/transition_engine/tools.py +6 -1
  100. package/mcp_server/translation_engine/tools.py +8 -6
  101. package/mcp_server/wonder_mode/engine.py +8 -3
  102. package/mcp_server/wonder_mode/tools.py +29 -21
  103. package/package.json +2 -2
  104. package/remote_script/LivePilot/__init__.py +57 -2
  105. package/remote_script/LivePilot/clips.py +69 -0
  106. package/remote_script/LivePilot/mixing.py +117 -0
  107. package/remote_script/LivePilot/router.py +13 -1
  108. package/scripts/generate_tool_catalog.py +13 -38
  109. package/scripts/sync_metadata.py +231 -14
  110. package/mcp_server/tools/_agent_os_engine.py +0 -947
  111. 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
- pass
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
- pass
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
- # 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.
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
- notes = result.get("notes", [])
402
- if not notes:
403
- continue
404
-
405
- # identify_scale: run key detection directly
406
- if not scale_info:
407
- detected = theory_engine.detect_key(notes, mode_detection=True)
408
- top = {
409
- "key": f"{detected['tonic_name']} {detected['mode'].replace('_', ' ')}",
410
- "confidence": detected["confidence"],
411
- "mode": detected["mode"].replace("_", " "),
412
- "mode_id": detected["mode"],
413
- "tonic": detected["tonic_name"],
414
- }
415
- 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)
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
- pass
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
- pass
488
-
489
- if scale_info and harmony_analysis:
490
- break
491
- except Exception:
492
- continue
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
- pass
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
- pass
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
- 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
+ """
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
- 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()