livepilot 1.9.15 → 1.9.16

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 (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +40 -0
  4. package/README.md +1 -1
  5. package/livepilot/.Codex-plugin/plugin.json +1 -1
  6. package/livepilot/.claude-plugin/plugin.json +1 -1
  7. package/livepilot/skills/livepilot-core/references/overview.md +1 -1
  8. package/m4l_device/livepilot_bridge.js +27 -13
  9. package/mcp_server/__init__.py +1 -1
  10. package/mcp_server/connection.py +24 -2
  11. package/mcp_server/curves.py +3 -3
  12. package/mcp_server/evaluation/fabric.py +1 -1
  13. package/mcp_server/m4l_bridge.py +9 -1
  14. package/mcp_server/memory/technique_store.py +25 -17
  15. package/mcp_server/mix_engine/critics.py +1 -1
  16. package/mcp_server/mix_engine/tools.py +14 -8
  17. package/mcp_server/performance_engine/safety.py +6 -3
  18. package/mcp_server/project_brain/refresh.py +8 -2
  19. package/mcp_server/project_brain/tools.py +12 -12
  20. package/mcp_server/reference_engine/tools.py +16 -15
  21. package/mcp_server/runtime/action_ledger_models.py +10 -3
  22. package/mcp_server/runtime/capability_state.py +3 -2
  23. package/mcp_server/runtime/tools.py +6 -3
  24. package/mcp_server/tools/agent_os.py +47 -39
  25. package/mcp_server/tools/composition.py +114 -32
  26. package/mcp_server/tools/devices.py +15 -1
  27. package/mcp_server/tools/midi_io.py +3 -1
  28. package/mcp_server/tools/research.py +31 -31
  29. package/mcp_server/tools/tracks.py +3 -3
  30. package/mcp_server/translation_engine/tools.py +50 -16
  31. package/package.json +1 -1
  32. package/remote_script/LivePilot/__init__.py +1 -1
  33. package/remote_script/LivePilot/arrangement.py +9 -1
  34. package/remote_script/LivePilot/clips.py +22 -6
  35. package/remote_script/LivePilot/notes.py +9 -1
  36. package/remote_script/LivePilot/server.py +6 -6
@@ -9,8 +9,11 @@ from __future__ import annotations
9
9
  from fastmcp import Context
10
10
 
11
11
  from ..server import mcp
12
+ from ..memory.technique_store import TechniqueStore
12
13
  from .capability_state import build_capability_state
13
14
 
15
+ _memory_store = TechniqueStore()
16
+
14
17
 
15
18
  @mcp.tool()
16
19
  def get_capability_state(ctx: Context) -> dict:
@@ -40,11 +43,11 @@ def get_capability_state(ctx: Context) -> dict:
40
43
  snap = spectral.get("spectrum")
41
44
  analyzer_fresh = snap is not None
42
45
 
43
- # ── Probe memory ────────────────────────────────────────────────
46
+ # ── Probe memory (direct TechniqueStore, not TCP) ────────────────
44
47
  memory_ok = False
45
48
  try:
46
- mem_result = ableton.send_command("memory_list", {"type": "technique"})
47
- memory_ok = isinstance(mem_result, dict) and "error" not in mem_result
49
+ _memory_store.list_techniques(limit=1)
50
+ memory_ok = True
48
51
  except Exception:
49
52
  memory_ok = False
50
53
 
@@ -15,8 +15,11 @@ from typing import Optional
15
15
  from fastmcp import Context
16
16
 
17
17
  from ..server import mcp
18
+ from ..memory.technique_store import TechniqueStore
18
19
  from . import _agent_os_engine as engine
19
20
 
21
+ _memory_store = TechniqueStore()
22
+
20
23
 
21
24
  def _get_ableton(ctx: Context):
22
25
  return ctx.lifespan_context["ableton"]
@@ -268,25 +271,25 @@ def analyze_outcomes(
268
271
  The more outcomes stored (via memory_learn type="outcome"),
269
272
  the better the taste analysis becomes.
270
273
  """
271
- ableton = _get_ableton(ctx)
272
-
273
- # Fetch outcome memories
274
+ # Fetch outcome memories directly from TechniqueStore
274
275
  try:
275
- memory_result = ableton.send_command("memory_list", {
276
- "type": "outcome",
277
- "limit": limit,
278
- "sort_by": "updated_at",
279
- })
280
- techniques = memory_result.get("techniques", [])
276
+ techniques = _memory_store.list_techniques(
277
+ type_filter="outcome", sort_by="updated_at", limit=limit,
278
+ )
281
279
  except Exception:
282
280
  techniques = []
283
281
 
284
- # Extract payloads from techniques
282
+ # Extract payloads from full technique records
285
283
  outcomes = []
286
284
  for t in techniques:
287
- payload = t.get("payload", {})
288
- if isinstance(payload, dict):
289
- outcomes.append(payload)
285
+ # list_techniques returns compact summaries; get full record for payload
286
+ try:
287
+ full = _memory_store.get(t["id"])
288
+ payload = full.get("payload", {})
289
+ if isinstance(payload, dict):
290
+ outcomes.append(payload)
291
+ except Exception:
292
+ pass
290
293
 
291
294
  return engine.analyze_outcome_history(outcomes)
292
295
 
@@ -308,29 +311,30 @@ def get_technique_card(
308
311
  query: search term (e.g., "wider pad", "punchy kick", "sidechain bass")
309
312
  limit: max results
310
313
  """
311
- ableton = _get_ableton(ctx)
312
-
314
+ # Search technique cards directly from TechniqueStore
313
315
  try:
314
- memory_result = ableton.send_command("memory_recall", {
315
- "query": query,
316
- "type": "technique_card",
317
- "limit": limit,
318
- })
319
- techniques = memory_result.get("techniques", [])
316
+ techniques = _memory_store.search(
317
+ query=query, type_filter="technique_card", limit=limit,
318
+ )
320
319
  except Exception:
321
320
  techniques = []
322
321
 
323
322
  cards = []
324
323
  for t in techniques:
325
- payload = t.get("payload", {})
326
- if isinstance(payload, dict):
327
- cards.append({
328
- "id": t.get("id"),
329
- "name": t.get("name"),
330
- "card": payload,
331
- "rating": t.get("rating", 0),
332
- "replay_count": t.get("replay_count", 0),
333
- })
324
+ # search() returns summaries without payload; get full record
325
+ try:
326
+ full = _memory_store.get(t["id"])
327
+ payload = full.get("payload", {})
328
+ if isinstance(payload, dict):
329
+ cards.append({
330
+ "id": t.get("id"),
331
+ "name": t.get("name"),
332
+ "card": payload,
333
+ "rating": t.get("rating", 0),
334
+ "replay_count": t.get("replay_count", 0),
335
+ })
336
+ except Exception:
337
+ pass
334
338
 
335
339
  return {
336
340
  "query": query,
@@ -358,19 +362,23 @@ def get_taste_profile(
358
362
  Returns: {taste_vector, preferred_dimensions, avoided_dimensions,
359
363
  keep_rate, sample_size}
360
364
  """
361
- ableton = _get_ableton(ctx)
362
-
365
+ # Fetch outcome memories directly from TechniqueStore
363
366
  try:
364
- memory_result = ableton.send_command("memory_list", {
365
- "type": "outcome",
366
- "limit": limit,
367
- "sort_by": "updated_at",
368
- })
369
- techniques = memory_result.get("techniques", [])
367
+ techniques = _memory_store.list_techniques(
368
+ type_filter="outcome", sort_by="updated_at", limit=limit,
369
+ )
370
370
  except Exception:
371
371
  techniques = []
372
372
 
373
- outcomes = [t.get("payload", {}) for t in techniques if isinstance(t.get("payload"), dict)]
373
+ outcomes = []
374
+ for t in techniques:
375
+ try:
376
+ full = _memory_store.get(t["id"])
377
+ payload = full.get("payload", {})
378
+ if isinstance(payload, dict):
379
+ outcomes.append(payload)
380
+ except Exception:
381
+ pass
374
382
 
375
383
  return engine.get_taste_profile(outcomes)
376
384
 
@@ -19,8 +19,11 @@ from typing import Optional
19
19
  from fastmcp import Context
20
20
 
21
21
  from ..server import mcp
22
+ from ..memory.technique_store import TechniqueStore
22
23
  from . import _composition_engine as engine
23
24
 
25
+ _memory_store = TechniqueStore()
26
+
24
27
 
25
28
  def _get_ableton(ctx: Context):
26
29
  return ctx.lifespan_context["ableton"]
@@ -229,13 +232,16 @@ def get_phrase_grid(
229
232
 
230
233
  section = sections[section_index]
231
234
 
232
- # Collect notes for active tracks
235
+ # Collect notes for active tracks — use the section's scene_index
236
+ # (which maps to the actual clip slot), not the section_index
237
+ # (which is a position in the section graph)
233
238
  notes_by_track: dict[int, list] = {}
239
+ scene_idx = section.scene_index if hasattr(section, "scene_index") else section_index
234
240
  for t_idx in section.tracks_active:
235
241
  try:
236
242
  result = ableton.send_command("get_notes", {
237
243
  "track_index": t_idx,
238
- "clip_index": section_index,
244
+ "clip_index": scene_idx,
239
245
  })
240
246
  notes_by_track[t_idx] = result.get("notes", [])
241
247
  except Exception:
@@ -377,6 +383,10 @@ def get_harmony_field(
377
383
  section = sections[section_index]
378
384
 
379
385
  # Find a track with notes to analyze harmony
386
+ # Use theory engine functions directly instead of TCP calls to MCP tools
387
+ from . import _theory_engine as theory_engine
388
+ from . import _harmony_engine as harmony_engine
389
+
380
390
  scale_info = None
381
391
  harmony_analysis = None
382
392
  progression_info = None
@@ -384,29 +394,97 @@ def get_harmony_field(
384
394
 
385
395
  for t_idx in section.tracks_active:
386
396
  try:
387
- # Try identify_scale
388
- si = ableton.send_command("identify_scale", {
389
- "track_index": t_idx, "clip_index": section_index
390
- })
391
- if si.get("top_match"):
392
- scale_info = si
393
-
394
- # Try analyze_harmony
395
- ha = ableton.send_command("analyze_harmony", {
396
- "track_index": t_idx, "clip_index": section_index
397
+ # Get notes via TCP (valid Remote Script command)
398
+ result = ableton.send_command("get_notes", {
399
+ "track_index": t_idx, "clip_index": section_index,
397
400
  })
398
- if ha.get("chords"):
399
- harmony_analysis = ha
400
-
401
- # Classify progression if we have chords
402
- chord_names = [c.get("chord_name", "") for c in ha.get("chords", []) if c.get("chord_name")]
403
- if len(chord_names) >= 2:
404
- try:
405
- progression_info = ableton.send_command("classify_progression", {
406
- "chords": chord_names[:8]
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}
416
+
417
+ # analyze_harmony: chordify + roman numeral analysis directly
418
+ if not harmony_analysis:
419
+ key_info = theory_engine.detect_key(notes)
420
+ tonic = key_info["tonic"]
421
+ mode = key_info["mode"]
422
+ chord_groups = theory_engine.chordify(notes)
423
+ if chord_groups:
424
+ chords = []
425
+ for group in chord_groups:
426
+ pitches = group["pitches"]
427
+ pcs = group["pitch_classes"]
428
+ rn = theory_engine.roman_numeral(pcs, tonic, mode)
429
+ cn = theory_engine.chord_name(pitches)
430
+ chords.append({
431
+ "beat": group["beat"],
432
+ "duration": group["duration"],
433
+ "chord_name": cn,
434
+ "roman_numeral": rn["figure"],
435
+ "figure": rn["figure"],
436
+ "quality": rn["quality"],
407
437
  })
408
- except Exception:
409
- pass
438
+ if chords:
439
+ harmony_analysis = {
440
+ "key": f"{key_info['tonic_name']} {mode.replace('_', ' ')}",
441
+ "chords": chords,
442
+ }
443
+
444
+ # classify_progression directly
445
+ chord_names = [c["chord_name"] for c in chords if c.get("chord_name")]
446
+ if len(chord_names) >= 2:
447
+ try:
448
+ parsed = [harmony_engine.parse_chord(c) for c in chord_names[:8]]
449
+ transforms = harmony_engine.classify_transform_sequence(parsed)
450
+ pattern = "".join(transforms)
451
+ classification = "free neo-Riemannian progression"
452
+ clean = pattern.replace("?", "")
453
+ if len(clean) >= 2:
454
+ pair = clean[:2]
455
+ if pair in ("PL", "LP") and all(c in "PL" for c in clean):
456
+ classification = "hexatonic cycle fragment"
457
+ elif pair in ("PR", "RP") and all(c in "PR" for c in clean):
458
+ classification = "octatonic cycle fragment"
459
+ elif pair in ("LR", "RL") and all(c in "LR" for c in clean):
460
+ classification = "diatonic cycle fragment"
461
+ progression_info = {
462
+ "chords": chord_names[:8],
463
+ "transforms": transforms,
464
+ "pattern": pattern,
465
+ "classification": classification,
466
+ }
467
+ except Exception:
468
+ pass
469
+
470
+ # Populate voice_leading_info from chord groups
471
+ if harmony_analysis and not voice_leading_info:
472
+ try:
473
+ chord_groups_vl = theory_engine.chordify(notes)
474
+ if len(chord_groups_vl) >= 2:
475
+ all_vl_issues = []
476
+ for vi in range(1, min(len(chord_groups_vl), 9)):
477
+ prev_p = chord_groups_vl[vi - 1]["pitches"]
478
+ curr_p = chord_groups_vl[vi]["pitches"]
479
+ issues = theory_engine.check_voice_leading(prev_p, curr_p)
480
+ all_vl_issues.extend(issues)
481
+ voice_leading_info = {
482
+ "issues": all_vl_issues,
483
+ "issue_count": len(all_vl_issues),
484
+ "quality": "clean" if not all_vl_issues else "has_issues",
485
+ }
486
+ except Exception:
487
+ pass
410
488
 
411
489
  if scale_info and harmony_analysis:
412
490
  break
@@ -540,19 +618,23 @@ def get_section_outcomes(
540
618
  section_type: filter to a specific type (intro, verse, chorus, etc.)
541
619
  Leave empty for all types.
542
620
  """
543
- ableton = _get_ableton(ctx)
544
-
621
+ # Fetch composition outcomes directly from TechniqueStore
545
622
  try:
546
- memory_result = ableton.send_command("memory_list", {
547
- "type": "composition_outcome",
548
- "limit": limit,
549
- "sort_by": "updated_at",
550
- })
551
- techniques = memory_result.get("techniques", [])
623
+ techniques = _memory_store.list_techniques(
624
+ type_filter="composition_outcome", sort_by="updated_at", limit=limit,
625
+ )
552
626
  except Exception:
553
627
  techniques = []
554
628
 
555
- outcomes = [t.get("payload", {}) for t in techniques if isinstance(t.get("payload"), dict)]
629
+ outcomes = []
630
+ for t in techniques:
631
+ try:
632
+ full = _memory_store.get(t["id"])
633
+ payload = full.get("payload", {})
634
+ if isinstance(payload, dict):
635
+ outcomes.append(payload)
636
+ except Exception:
637
+ pass
556
638
 
557
639
  result = engine.analyze_section_outcomes(outcomes)
558
640
 
@@ -145,11 +145,25 @@ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
145
145
  if match is None:
146
146
  match = devices[-1]
147
147
 
148
+ # get_track_info returns device summaries without a parameters list,
149
+ # so use the 'parameter_count' field if present, otherwise fetch
150
+ # the actual device info for an accurate count.
151
+ param_count = match.get("parameter_count", len(match.get("parameters", [])))
152
+ if param_count == 0 and match.get("index") is not None:
153
+ try:
154
+ full_info = _get_ableton(ctx).send_command("get_device_info", {
155
+ "track_index": int(track_index),
156
+ "device_index": int(match["index"]),
157
+ })
158
+ param_count = full_info.get("parameter_count", 0)
159
+ except Exception:
160
+ pass
161
+
148
162
  device_info = _annotate_device_info({
149
163
  "name": match.get("name"),
150
164
  "class_name": match.get("class_name"),
151
165
  "is_active": match.get("is_active"),
152
- "parameter_count": len(match.get("parameters", [])),
166
+ "parameter_count": param_count,
153
167
  })
154
168
 
155
169
  merged = dict(annotated)
@@ -62,7 +62,9 @@ def _safe_output_path(directory: Path, filename: str) -> Path:
62
62
  if not safe_name:
63
63
  raise ValueError(f"Invalid filename: {filename!r}")
64
64
  out = (directory / safe_name).resolve()
65
- if not str(out).startswith(str(directory.resolve())):
65
+ # Use os.sep suffix to prevent prefix collisions (e.g., /foo/bar vs /foo/barbaz)
66
+ parent = str(directory.resolve()) + os.sep
67
+ if not (str(out) + os.sep).startswith(parent):
66
68
  raise ValueError(f"Filename escapes output directory: {filename!r}")
67
69
  return out
68
70
 
@@ -15,8 +15,11 @@ from typing import Optional
15
15
  from fastmcp import Context
16
16
 
17
17
  from ..server import mcp
18
+ from ..memory.technique_store import TechniqueStore
18
19
  from . import _research_engine as research_engine
19
20
 
21
+ _memory_store = TechniqueStore()
22
+
20
23
 
21
24
  def _get_ableton(ctx: Context):
22
25
  return ctx.lifespan_context["ableton"]
@@ -50,37 +53,33 @@ def research_technique(
50
53
  # 1. Analyze query to predict relevant devices
51
54
  query_info = research_engine.analyze_query(query)
52
55
 
53
- # 2. Search device atlas for relevant devices
56
+ # 2. Search device atlas for relevant devices (Fix 2: correct params)
54
57
  device_atlas_results = []
55
58
  for device_name in query_info.get("likely_devices", [])[:5]:
56
59
  try:
57
- ref = ableton.send_command("search_browser", {"query": device_name, "category": "instruments"})
60
+ ref = ableton.send_command("search_browser", {
61
+ "path": "instruments",
62
+ "name_filter": device_name,
63
+ })
58
64
  if ref and not ref.get("error"):
59
65
  device_atlas_results.append(ref)
60
66
  except Exception:
61
67
  pass
62
68
 
63
- # 3. Search memory for related techniques
69
+ # 3. Search memory for related techniques (direct TechniqueStore)
64
70
  memory_results = []
65
71
  try:
66
- # Search technique cards
67
- mem = ableton.send_command("memory_list", {
68
- "type": "technique_card",
69
- "limit": 10,
70
- "sort_by": "updated_at",
71
- })
72
- memory_results.extend(mem.get("techniques", []))
72
+ memory_results.extend(
73
+ _memory_store.list_techniques(type_filter="technique_card", sort_by="updated_at", limit=10)
74
+ )
73
75
  except Exception:
74
76
  pass
75
77
 
76
78
  try:
77
- # Also search research memories
78
- mem = ableton.send_command("memory_list", {
79
- "type": "research",
80
- "limit": 5,
81
- "sort_by": "updated_at",
82
- })
83
- memory_results.extend(mem.get("techniques", []))
79
+ # "research" is not a valid type in TechniqueStore — search broadly
80
+ memory_results.extend(
81
+ _memory_store.search(query=query, limit=5)
82
+ )
84
83
  except Exception:
85
84
  pass
86
85
 
@@ -131,19 +130,24 @@ def get_emotional_arc(ctx: Context) -> dict:
131
130
  }
132
131
 
133
132
  # Try to build harmony fields for richer analysis
133
+ # Use theory engine directly instead of TCP call to MCP tool
134
+ from . import _theory_engine as theory_engine
135
+
134
136
  harmony_fields = []
135
137
  for i, section in enumerate(sections):
136
138
  hf = engine.HarmonyField(section_id=section.section_id)
137
- # Try to get harmony data
139
+ # Try to get harmony data by fetching notes then running engine
138
140
  for t_idx in section.tracks_active[:3]:
139
141
  try:
140
- si = ableton.send_command("identify_scale", {
142
+ result = ableton.send_command("get_notes", {
141
143
  "track_index": t_idx, "clip_index": i,
142
144
  })
143
- if si.get("top_match"):
144
- hf.key = si["top_match"].get("tonic", "")
145
- hf.mode = si["top_match"].get("mode", "")
146
- hf.confidence = si["top_match"].get("confidence", 0.0)
145
+ notes = result.get("notes", [])
146
+ if notes:
147
+ detected = theory_engine.detect_key(notes, mode_detection=True)
148
+ hf.key = detected.get("tonic_name", "")
149
+ hf.mode = detected.get("mode", "")
150
+ hf.confidence = detected.get("confidence", 0.0)
147
151
  break
148
152
  except Exception:
149
153
  continue
@@ -193,16 +197,12 @@ def get_style_tactics(
193
197
  if not artist_or_genre or not artist_or_genre.strip():
194
198
  return {"error": "artist_or_genre cannot be empty"}
195
199
 
196
- ableton = _get_ableton(ctx)
197
-
198
- # Search user memory for saved tactics
200
+ # Search user memory for saved tactics (direct TechniqueStore)
199
201
  memory_tactics = []
200
202
  try:
201
- mem = ableton.send_command("memory_list", {
202
- "type": "style_tactic",
203
- "limit": 10,
204
- })
205
- memory_tactics = mem.get("techniques", [])
203
+ memory_tactics = _memory_store.search(
204
+ query=artist_or_genre, limit=10,
205
+ )
206
206
  except Exception:
207
207
  pass
208
208
 
@@ -167,7 +167,7 @@ def stop_track_clips(ctx: Context, track_index: int) -> dict:
167
167
  @mcp.tool()
168
168
  def set_group_fold(ctx: Context, track_index: int, folded: bool) -> dict:
169
169
  """Fold or unfold a group track to show/hide its children."""
170
- _validate_track_index(track_index)
170
+ _validate_track_index(track_index, allow_return=False)
171
171
  return _get_ableton(ctx).send_command("set_group_fold", {
172
172
  "track_index": track_index,
173
173
  "folded": folded,
@@ -176,10 +176,10 @@ def set_group_fold(ctx: Context, track_index: int, folded: bool) -> dict:
176
176
 
177
177
  @mcp.tool()
178
178
  def set_track_input_monitoring(ctx: Context, track_index: int, state: int) -> dict:
179
- """Set input monitoring (0=In, 1=Auto, 2=Off). Only for regular tracks, not return tracks."""
179
+ """Set input monitoring (0=Off, 1=In, 2=Auto). Only for regular tracks, not return tracks."""
180
180
  _validate_track_index(track_index)
181
181
  if state not in (0, 1, 2):
182
- raise ValueError("Monitoring state must be 0=In, 1=Auto, or 2=Off")
182
+ raise ValueError("Monitoring state must be 0=Off, 1=In, or 2=Auto")
183
183
  return _get_ableton(ctx).send_command("set_track_input_monitoring", {
184
184
  "track_index": track_index,
185
185
  "state": state,
@@ -16,30 +16,64 @@ from .critics import build_translation_report, run_all_translation_critics
16
16
 
17
17
 
18
18
  def _fetch_translation_data(ctx: Context) -> dict:
19
- """Fetch mix snapshot data needed for translation analysis."""
19
+ """Fetch mix snapshot data needed for translation analysis.
20
+
21
+ Builds snapshot from real available data:
22
+ - Spectrum from SpectralCache (direct access, not TCP)
23
+ - Stereo width estimated from track pan values
24
+ - Foreground detection from role inference
25
+ """
20
26
  ableton = ctx.lifespan_context["ableton"]
21
27
 
22
- # Get mix snapshot contains spectral and stereo info
23
- snapshot = {}
28
+ # Get spectral data directly from SpectralCache
29
+ spectrum_bands = {}
24
30
  try:
25
- snapshot = ableton.send_command("get_mix_snapshot", {})
31
+ spectral = ctx.lifespan_context.get("spectral")
32
+ if spectral and spectral.is_connected:
33
+ spec_data = spectral.get("spectrum")
34
+ if spec_data and isinstance(spec_data["value"], dict):
35
+ spectrum_bands = spec_data["value"]
26
36
  except Exception:
27
37
  pass
28
38
 
29
- # Extract spectral bands from snapshot
30
- spectrum = snapshot.get("spectrum", {})
31
- stereo = snapshot.get("stereo", {})
39
+ # Estimate stereo width from track pans via session info
40
+ stereo_width = 0.0
41
+ center_strength = 0.5
42
+ has_foreground = True
43
+ foreground_masked = False
44
+ try:
45
+ session_info = ableton.send_command("get_session_info", {})
46
+ tracks = session_info.get("tracks", [])
47
+ if tracks:
48
+ # Pan may be at top level or nested under mixer.panning
49
+ def _get_pan(t: dict) -> float:
50
+ mixer = t.get("mixer")
51
+ if isinstance(mixer, dict):
52
+ return abs(mixer.get("panning", 0.0))
53
+ return abs(t.get("pan", 0.0))
54
+ pan_values = [_get_pan(t) for t in tracks if not t.get("muted", False)]
55
+ if pan_values:
56
+ # Wider pans = more stereo width
57
+ stereo_width = min(1.0, sum(pan_values) / max(len(pan_values), 1))
58
+ # Center strength = proportion of tracks near center
59
+ center_count = sum(1 for p in pan_values if p < 0.15)
60
+ center_strength = center_count / max(len(pan_values), 1)
61
+
62
+ # Simple foreground detection: at least one unmuted, non-quiet track
63
+ has_foreground = any(not t.get("muted", False) for t in tracks)
64
+ except Exception:
65
+ pass
32
66
 
33
67
  return {
34
- "stereo_width": stereo.get("side_activity", 0.0),
35
- "center_strength": stereo.get("center_strength", 0.5),
36
- "sub_energy": spectrum.get("sub", 0.0),
37
- "low_energy": spectrum.get("low", 0.0),
38
- "low_mid_energy": spectrum.get("low_mid", 0.0),
39
- "high_energy": spectrum.get("high", 0.0),
40
- "presence_energy": spectrum.get("presence", 0.0),
41
- "has_foreground": snapshot.get("has_foreground", True),
42
- "foreground_masked": snapshot.get("foreground_masked", False),
68
+ "stereo_width": stereo_width,
69
+ "center_strength": center_strength,
70
+ "sub_energy": spectrum_bands.get("sub", 0.0),
71
+ "low_energy": spectrum_bands.get("low", 0.0),
72
+ "low_mid_energy": spectrum_bands.get("low_mid", 0.0),
73
+ "high_energy": spectrum_bands.get("high", 0.0),
74
+ "presence_energy": spectrum_bands.get("presence", 0.0),
75
+ "has_foreground": has_foreground,
76
+ "foreground_masked": foreground_masked,
43
77
  }
44
78
 
45
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.15",
3
+ "version": "1.9.16",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
5
  "description": "Agentic production system for Ableton Live 12 — 236 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
6
6
  "author": "Pilot Studio",
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.9.14"
8
+ __version__ = "1.9.16"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from .server import LivePilotServer
@@ -352,6 +352,12 @@ def modify_arrangement_notes(song, params):
352
352
  note.velocity = float(mod["velocity"])
353
353
  if "probability" in mod:
354
354
  note.probability = float(mod["probability"])
355
+ if "mute" in mod:
356
+ note.mute = bool(mod["mute"])
357
+ if "velocity_deviation" in mod:
358
+ note.velocity_deviation = float(mod["velocity_deviation"])
359
+ if "release_velocity" in mod:
360
+ note.release_velocity = float(mod["release_velocity"])
355
361
  modified_count += 1
356
362
 
357
363
  song.begin_undo_step()
@@ -557,7 +563,9 @@ def transpose_arrangement_notes(song, params):
557
563
  clip = arr_clips[clip_index]
558
564
 
559
565
  from_time = float(params.get("from_time", 0.0))
560
- time_span = float(params.get("time_span", clip.length))
566
+ # Default span covers from from_time to end of clip, not the full clip length
567
+ default_span = max(0.0, clip.length - from_time) if clip.length > 0 else 32768.0
568
+ time_span = float(params.get("time_span", default_span))
561
569
 
562
570
  all_notes = clip.get_notes_extended(0, 128, from_time, time_span)
563
571