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
@@ -162,15 +162,52 @@ def targeted_research(
162
162
  findings: list[ResearchFinding] = []
163
163
  sources_searched = []
164
164
 
165
- # 1. Device atlas findings
165
+ # 1. Device atlas findings.
166
+ # BUG-B43 fix: device_atlas_results is a list of search_browser
167
+ # RESPONSES (each with {path, items: [...], count, ...}) — NOT a
168
+ # list of device entries. The old code read response.get("name")
169
+ # which always returned "" because the response has no top-level
170
+ # name. Every finding came back as "Device: Unknown". We now
171
+ # flatten the responses, look up each item's real atlas metadata,
172
+ # and build one finding per resolved device.
166
173
  if device_atlas_results:
167
174
  sources_searched.append("device_atlas")
168
- for entry in device_atlas_results:
175
+ flattened_entries: list[dict] = []
176
+ for response in device_atlas_results:
177
+ if not isinstance(response, dict):
178
+ continue
179
+ # Accept old shape (single device dict) for forward compat
180
+ if response.get("name") and not response.get("items"):
181
+ flattened_entries.append(response)
182
+ continue
183
+ items = response.get("items") or response.get("results") or []
184
+ for item in items:
185
+ if not isinstance(item, dict):
186
+ continue
187
+ flattened_entries.append({
188
+ "name": item.get("name", ""),
189
+ "uri": item.get("uri", ""),
190
+ "category": response.get("path", ""),
191
+ "is_loadable": item.get("is_loadable", False),
192
+ })
193
+
194
+ for entry in flattened_entries:
169
195
  name = entry.get("name", "")
196
+ if not name:
197
+ continue # skip phantom empties
198
+ # Try to enrich with atlas lookup — gives real description,
199
+ # character_tags, genres.
200
+ try:
201
+ from ..atlas import _atlas_instance as _atlas
202
+ if _atlas is not None:
203
+ full = _atlas.lookup(name)
204
+ if full:
205
+ entry = {**full, **entry}
206
+ except Exception:
207
+ pass
208
+
170
209
  text = _format_device_finding(entry)
171
210
  relevance = _score_finding_relevance(text, query_info["keywords"])
172
-
173
- # Boost relevance if device was in our predicted list
174
211
  if name in query_info["likely_devices"]:
175
212
  relevance = min(1.0, relevance + 0.3)
176
213
 
@@ -179,7 +216,10 @@ def targeted_research(
179
216
  source_id=name,
180
217
  relevance=round(relevance, 3),
181
218
  content=text,
182
- metadata={"device_name": name, "category": entry.get("category", "")},
219
+ metadata={
220
+ "device_name": name,
221
+ "category": entry.get("category", ""),
222
+ },
183
223
  ))
184
224
 
185
225
  # 2. Memory findings (technique cards, outcomes, research notes)
@@ -522,21 +562,60 @@ def get_style_tactics(
522
562
  if any(query in p.lower() for p in tactic.arrangement_patterns):
523
563
  results.append(tactic)
524
564
 
525
- # Search user memory tactics
565
+ # Search user memory tactics.
566
+ # BUG-B18 fix: TechniqueStore.search() strips the payload from
567
+ # summaries, so `mem.get("payload", {})` was always empty and the
568
+ # old match-by-payload.artist_or_genre code never fired. Users who
569
+ # saved 3 "Prefuse73" techniques via memory_learn got back 0
570
+ # tactics from get_style_tactics("prefuse73"). We now match on
571
+ # name + tags + qualities.summary + payload (if present) and
572
+ # adapt the memory-entry to a StyleTactic regardless of whether
573
+ # the caller formatted the payload in the exact style_tactic shape.
526
574
  if memory_tactics:
527
575
  for mem in memory_tactics:
528
- payload = mem.get("payload", {})
529
- if isinstance(payload, dict):
530
- mem_genre = payload.get("artist_or_genre", "").lower()
531
- mem_name = payload.get("tactic_name", "").lower()
532
- if query in mem_genre or query in mem_name:
533
- results.append(StyleTactic(
534
- artist_or_genre=payload.get("artist_or_genre", ""),
535
- tactic_name=payload.get("tactic_name", ""),
536
- arrangement_patterns=payload.get("arrangement_patterns", []),
537
- device_chain=payload.get("device_chain", []),
538
- automation_gestures=payload.get("automation_gestures", []),
539
- verification=payload.get("verification", []),
540
- ))
576
+ if not isinstance(mem, dict):
577
+ continue
578
+ # Build the searchable text from whichever shape the memory
579
+ # entry uses. This is lenient on purpose — saved techniques
580
+ # don't need to pre-commit to a schema to surface here.
581
+ name = str(mem.get("name", ""))
582
+ tags = mem.get("tags", []) or []
583
+ qualities = mem.get("qualities", {}) or {}
584
+ summary = str(qualities.get("summary", "") or "")
585
+ payload = mem.get("payload", {}) or {}
586
+
587
+ searchable = " ".join([
588
+ name.lower(), summary.lower(),
589
+ " ".join(str(t).lower() for t in tags),
590
+ str(payload.get("artist_or_genre", "")).lower(),
591
+ str(payload.get("tactic_name", "")).lower(),
592
+ ])
593
+ if query not in searchable:
594
+ continue
595
+
596
+ # Adapt to StyleTactic — prefer payload fields when present,
597
+ # fall back to the summary for arrangement_patterns, etc.
598
+ artist_or_genre = str(
599
+ payload.get("artist_or_genre")
600
+ or next((t for t in tags if query in str(t).lower()), "")
601
+ or query
602
+ )
603
+ tactic_name = str(payload.get("tactic_name") or name or summary[:40])
604
+ arrangement_patterns = (
605
+ payload.get("arrangement_patterns")
606
+ or [summary] if summary else []
607
+ )
608
+ device_chain = payload.get("device_chain", []) or []
609
+ automation_gestures = payload.get("automation_gestures", []) or []
610
+ verification = payload.get("verification", []) or []
611
+
612
+ results.append(StyleTactic(
613
+ artist_or_genre=artist_or_genre,
614
+ tactic_name=tactic_name,
615
+ arrangement_patterns=arrangement_patterns,
616
+ device_chain=device_chain,
617
+ automation_gestures=automation_gestures,
618
+ verification=verification,
619
+ ))
541
620
 
542
621
  return results
@@ -218,16 +218,98 @@ def detect_key(notes: list[dict], mode_detection: bool = True) -> dict:
218
218
 
219
219
 
220
220
  def chord_name(midi_pitches: list[int]) -> str:
221
- """Identify chord from MIDI pitches -> 'C-major triad'."""
222
- pcs = sorted(set(p % 12 for p in midi_pitches))
223
- if not pcs:
221
+ """Identify chord from MIDI pitches -> 'C-major triad'.
222
+
223
+ Root-selection rules (BUG-B2 / BUG-B5):
224
+ 1) Prefer the bass note (lowest MIDI pitch) as root — matches
225
+ musical convention and handles partial voicings correctly.
226
+ 2) Accept exact CHORD_PATTERNS match first.
227
+ 3) If no exact match, allow subset match (partial chord e.g.
228
+ Dm7(no5)) and superset match (add-tone e.g. Gm7(add11)).
229
+ 4) Only fall back to the pitch-class-0 guess when nothing else
230
+ works — previous code always returned pcs[0] on miss, which
231
+ mis-labeled Dm7 as a "C chord" just because C has pc=0.
232
+ """
233
+ if not midi_pitches:
224
234
  return "unknown"
225
- # Try each pitch class as potential root
226
- for root in pcs:
227
- intervals = tuple(sorted((pc - root) % 12 for pc in pcs))
235
+ pcs_set = set(p % 12 for p in midi_pitches)
236
+ pcs_sorted_pc = sorted(pcs_set) # numerical pc order, used for fallback only
237
+ bass_pc = min(midi_pitches) % 12
238
+
239
+ # Ordered candidate roots: bass first, then remaining pcs (low→high).
240
+ # This gives musical priority to the bass note without ignoring cases
241
+ # where a non-bass root yields a cleaner exact match (2nd-pass scoring).
242
+ ordered_roots: list[int] = [bass_pc] + [pc for pc in pcs_sorted_pc if pc != bass_pc]
243
+
244
+ # ── Pass 1: exact CHORD_PATTERNS match ─────────────────────────────
245
+ exact_matches: list[tuple[int, tuple[int, ...]]] = []
246
+ for root in ordered_roots:
247
+ intervals = tuple(sorted((pc - root) % 12 for pc in pcs_set))
228
248
  if intervals in CHORD_PATTERNS:
229
- return f"{NOTE_NAMES[root]}-{CHORD_PATTERNS[intervals]}"
230
- return f"{NOTE_NAMES[pcs[0]]} chord"
249
+ exact_matches.append((root, intervals))
250
+
251
+ if exact_matches:
252
+ # Prefer the match where root == bass_pc
253
+ for root, intervals in exact_matches:
254
+ if root == bass_pc:
255
+ return f"{NOTE_NAMES[root]}-{CHORD_PATTERNS[intervals]}"
256
+ # Otherwise the first (bass-first iteration) wins
257
+ root, intervals = exact_matches[0]
258
+ return f"{NOTE_NAMES[root]}-{CHORD_PATTERNS[intervals]}"
259
+
260
+ # ── Pass 2: subset match (partial chord — missing tones) ───────────
261
+ # e.g. D-F-C → (0, 3, 10) is a subset of (0, 3, 7, 10) = minor seventh.
262
+ # Report the canonical name with "(no X)" where X is the missing interval.
263
+ interval_names = {
264
+ 0: "root", 2: "2", 3: "♭3", 4: "3", 5: "4", 6: "♭5",
265
+ 7: "5", 8: "♭6", 9: "6", 10: "♭7", 11: "7",
266
+ }
267
+ # Prefer bass-pc first.
268
+ for root in ordered_roots:
269
+ intervals_set = set((pc - root) % 12 for pc in pcs_set)
270
+ if 0 not in intervals_set:
271
+ continue
272
+ for pattern, label in CHORD_PATTERNS.items():
273
+ pattern_set = set(pattern)
274
+ if intervals_set.issubset(pattern_set) and intervals_set != pattern_set:
275
+ missing = sorted(pattern_set - intervals_set)
276
+ missing_names = ",".join(interval_names.get(m, str(m)) for m in missing)
277
+ return f"{NOTE_NAMES[root]}-{label} (no {missing_names})"
278
+
279
+ # ── Pass 3: superset match (extended chord — added tensions) ───────
280
+ # e.g. G-Bb-D-F-A → pattern minor-seventh (0,3,7,10) is a subset of
281
+ # {0,2,3,7,10}; the extra 2 is an added 9 (or 11 depending on voicing).
282
+ # Report "Gm7(add2)".
283
+ add_interval_names = {
284
+ 1: "♭9", 2: "9", 4: "♯9", 5: "11", 6: "♯11",
285
+ 8: "♭13", 9: "13", 11: "maj7",
286
+ }
287
+ for root in ordered_roots:
288
+ intervals_set = set((pc - root) % 12 for pc in pcs_set)
289
+ if 0 not in intervals_set:
290
+ continue
291
+ # Try each pattern as the core, extras as tensions. Track the
292
+ # chosen pattern size so we prefer 7ths over triads (the bug in
293
+ # the first draft was using len(set(label_string)) — chars, not
294
+ # intervals — which broke the tie-break for BUG-B2.
295
+ best_superset: tuple[str, list[int], int] | None = None
296
+ for pattern, label in CHORD_PATTERNS.items():
297
+ pattern_set = set(pattern)
298
+ if pattern_set.issubset(intervals_set) and pattern_set != intervals_set:
299
+ extras = sorted(intervals_set - pattern_set)
300
+ # Prefer the longest pattern (seventh chords win over triads)
301
+ if best_superset is None or len(pattern_set) > best_superset[2]:
302
+ best_superset = (label, extras, len(pattern_set))
303
+ if best_superset is not None:
304
+ label, extras, _ = best_superset
305
+ add_names = ",".join(add_interval_names.get(e, str(e)) for e in extras)
306
+ return f"{NOTE_NAMES[root]}-{label} (add {add_names})"
307
+
308
+ # ── Pass 4: fallback — name by bass note, not pcs[0] ───────────────
309
+ # Previous behavior returned NOTE_NAMES[pcs[0]] (numerically lowest pc,
310
+ # which put C first for any chord containing C). Bass-note is musically
311
+ # correct — if we can't identify the quality, at least the root is right.
312
+ return f"{NOTE_NAMES[bass_pc]} chord"
231
313
 
232
314
 
233
315
  def roman_numeral(chord_pcs: list[int], tonic: int, mode: str) -> dict:
@@ -399,8 +481,18 @@ def roman_figure_to_pitches(figure: str, tonic: int, mode: str) -> dict:
399
481
  p = base_midi + ((pc - root_pc) % 12)
400
482
  midi.append(p)
401
483
 
484
+ # BUG-B23: the original figure string's case can disagree with the
485
+ # resolved quality (e.g. "IV" resolving to a minor triad on the 4th
486
+ # scale degree of D minor). Convention says uppercase numerals encode
487
+ # major/augmented and lowercase encode minor/diminished. Return a
488
+ # normalized figure so callers receive internally consistent data;
489
+ # the raw input figure stays reflected in 'figure_requested' for
490
+ # debugging / compatibility.
491
+ normalized_figure = _normalize_figure_case(figure, quality)
492
+
402
493
  return {
403
- "figure": figure,
494
+ "figure": normalized_figure,
495
+ "figure_requested": figure,
404
496
  "root_pc": root_pc,
405
497
  "pitches": [pitch_name(m) for m in midi],
406
498
  "midi_pitches": midi,
@@ -408,6 +500,43 @@ def roman_figure_to_pitches(figure: str, tonic: int, mode: str) -> dict:
408
500
  }
409
501
 
410
502
 
503
+ def _normalize_figure_case(figure: str, quality: str) -> str:
504
+ """Return *figure* with its numeral's case matched to *quality*.
505
+
506
+ Uppercase numerals (I, II, …, VII) conventionally encode major-family
507
+ qualities; lowercase (i, ii, …, vii) encode minor-family. We preserve
508
+ any leading accidentals (b/#) and trailing suffix (7, maj7, °, etc.)
509
+ and only flip the numeral itself. Safe on edge cases: if the figure
510
+ doesn't start with a recognized numeral, it's returned unchanged.
511
+ """
512
+ if not figure:
513
+ return figure
514
+ # Split off leading accidentals
515
+ prefix = ""
516
+ remaining = figure
517
+ while remaining and remaining[0] in ("b", "#"):
518
+ prefix += remaining[0]
519
+ remaining = remaining[1:]
520
+ # Match numeral greedily (longest first so VII wins over VI wins over V)
521
+ numeral = ""
522
+ for rn in ["VII", "VI", "IV", "III", "II", "V", "I"]:
523
+ if remaining.upper().startswith(rn):
524
+ numeral = rn
525
+ break
526
+ if not numeral:
527
+ return figure
528
+ suffix = remaining[len(numeral):]
529
+ # Minor family: lowercase. Major family: uppercase.
530
+ q = quality.lower() if isinstance(quality, str) else ""
531
+ is_minor_family = (
532
+ q.startswith("minor")
533
+ or q.startswith("half-diminished")
534
+ or q.startswith("diminished")
535
+ )
536
+ new_numeral = numeral.lower() if is_minor_family else numeral
537
+ return prefix + new_numeral + suffix
538
+
539
+
411
540
  def check_voice_leading(prev_pitches: list[int], curr_pitches: list[int]) -> list[dict]:
412
541
  """Check voice leading issues between two chords."""
413
542
  issues = []
@@ -10,6 +10,7 @@ These tools power the Agent OS cyclical loop:
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import logging
13
14
  from typing import Optional
14
15
 
15
16
  from fastmcp import Context
@@ -18,6 +19,8 @@ from ..server import mcp
18
19
  from ..memory.technique_store import TechniqueStore
19
20
  from . import _agent_os_engine as engine
20
21
 
22
+ logger = logging.getLogger(__name__)
23
+
21
24
  _memory_store = TechniqueStore()
22
25
 
23
26
 
@@ -125,8 +128,9 @@ def build_world_model(ctx: Context) -> dict:
125
128
  "track_index": track["index"]
126
129
  })
127
130
  track_infos.append(ti)
128
- except Exception:
129
- pass # Skip tracks that fail — don't block world model build
131
+ except Exception as exc:
132
+ # Skip tracks that fail — don't block world model build
133
+ logger.debug("world-model track %s skipped: %s", track.get("index"), exc)
130
134
 
131
135
  # Fetch spectral data (may be unavailable)
132
136
  spectrum = None
@@ -147,11 +151,28 @@ def build_world_model(ctx: Context) -> dict:
147
151
  if key_data:
148
152
  detected_key = key_data["value"] if isinstance(key_data["value"], dict) else {"key": key_data["value"]}
149
153
 
154
+ # BUG-E6 fix: derive flucoma_available from the same 6-stream probe
155
+ # that check_flucoma uses. Previously we read a dedicated
156
+ # "flucoma_status" key that the M4L bridge doesn't emit, so the
157
+ # fallback `{"flucoma_available": False}` always won even when all
158
+ # 6 FluCoMa streams were actively delivering data.
159
+ _flu_streams = ("spectral_shape", "mel_bands", "chroma",
160
+ "onset", "novelty", "loudness")
161
+ active = sum(1 for k in _flu_streams if spectral.get(k) is not None)
162
+ flucoma_status = {
163
+ "flucoma_available": active > 0,
164
+ "active_streams": active,
165
+ }
166
+ # Keep any explicit flucoma_status payload the bridge may emit
167
+ # alongside as extra metadata — without letting it override the
168
+ # stream-based truth.
150
169
  flucoma_data = spectral.get("flucoma_status")
151
- if flucoma_data:
152
- flucoma_status = flucoma_data["value"] if isinstance(flucoma_data["value"], dict) else {}
170
+ if flucoma_data and isinstance(flucoma_data.get("value"), dict):
171
+ extras = {k: v for k, v in flucoma_data["value"].items()
172
+ if k not in flucoma_status}
173
+ flucoma_status.update(extras)
153
174
  else:
154
- flucoma_status = {"flucoma_available": False}
175
+ flucoma_status = {"flucoma_available": False, "active_streams": 0}
155
176
 
156
177
  # Build model
157
178
  wm = engine.build_world_model_from_data(
@@ -184,13 +205,14 @@ def build_world_model(ctx: Context) -> dict:
184
205
  try:
185
206
  matrix_data = ableton.send_command("get_scene_matrix")
186
207
  clip_matrix = matrix_data.get("matrix", [])
187
- except Exception:
188
- pass
208
+ except Exception as exc:
209
+ logger.debug("scene_matrix fetch for structural critic skipped: %s", exc)
189
210
 
190
211
  sections = comp_engine.build_section_graph_from_scenes(scenes, clip_matrix, track_count)
191
212
  structural_issues = comp_engine.run_form_critic(sections)
192
- except Exception:
193
- pass # Composition engine unavailable — degrade gracefully
213
+ except Exception as exc:
214
+ # Composition engine unavailable — degrade gracefully
215
+ logger.warning("structural critic unavailable: %s", exc)
194
216
 
195
217
  result = wm.to_dict()
196
218
  result["issues"] = {
@@ -276,7 +298,8 @@ def analyze_outcomes(
276
298
  techniques = _memory_store.list_techniques(
277
299
  type_filter="outcome", sort_by="updated_at", limit=limit,
278
300
  )
279
- except Exception:
301
+ except Exception as exc:
302
+ logger.warning("analyze_outcomes list_techniques failed: %s", exc)
280
303
  techniques = []
281
304
 
282
305
  # Extract payloads from full technique records
@@ -288,8 +311,8 @@ def analyze_outcomes(
288
311
  payload = full.get("payload", {})
289
312
  if isinstance(payload, dict):
290
313
  outcomes.append(payload)
291
- except Exception:
292
- pass
314
+ except Exception as exc:
315
+ logger.debug("outcome payload %s skipped: %s", t.get("id"), exc)
293
316
 
294
317
  return engine.analyze_outcome_history(outcomes)
295
318
 
@@ -316,7 +339,8 @@ def get_technique_card(
316
339
  techniques = _memory_store.search(
317
340
  query=query, type_filter="technique_card", limit=limit,
318
341
  )
319
- except Exception:
342
+ except Exception as exc:
343
+ logger.warning("technique_card search(%r) failed: %s", query, exc)
320
344
  techniques = []
321
345
 
322
346
  cards = []
@@ -333,8 +357,8 @@ def get_technique_card(
333
357
  "rating": t.get("rating", 0),
334
358
  "replay_count": t.get("replay_count", 0),
335
359
  })
336
- except Exception:
337
- pass
360
+ except Exception as exc:
361
+ logger.debug("technique_card %s payload skipped: %s", t.get("id"), exc)
338
362
 
339
363
  return {
340
364
  "query": query,
@@ -367,7 +391,8 @@ def get_taste_profile(
367
391
  techniques = _memory_store.list_techniques(
368
392
  type_filter="outcome", sort_by="updated_at", limit=limit,
369
393
  )
370
- except Exception:
394
+ except Exception as exc:
395
+ logger.warning("taste_profile list_techniques failed: %s", exc)
371
396
  techniques = []
372
397
 
373
398
  outcomes = []
@@ -377,8 +402,8 @@ def get_taste_profile(
377
402
  payload = full.get("payload", {})
378
403
  if isinstance(payload, dict):
379
404
  outcomes.append(payload)
380
- except Exception:
381
- pass
405
+ except Exception as exc:
406
+ logger.debug("taste_profile outcome %s skipped: %s", t.get("id"), exc)
382
407
 
383
408
  return engine.get_taste_profile(outcomes)
384
409
 
@@ -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
 
@@ -43,7 +44,8 @@ def _require_analyzer(cache) -> None:
43
44
  ctx.lifespan_context["ableton"].send_command("get_master_track")
44
45
  if ctx else {}
45
46
  )
46
- except Exception:
47
+ except Exception as exc:
48
+ logger.debug("_require_analyzer failed: %s", exc)
47
49
  track = {}
48
50
 
49
51
  devices = track.get("devices", []) if isinstance(track, dict) else []
@@ -254,7 +256,6 @@ async def walk_device_tree(
254
256
  bridge = _get_m4l(ctx)
255
257
  return await bridge.send_command("walk_rack", track_index, device_index)
256
258
 
257
-
258
259
  # ── Phase 2: Sample Operations ─────────────────────────────────────────
259
260
 
260
261
 
@@ -276,10 +277,11 @@ async def get_clip_file_path(
276
277
  bridge = _get_m4l(ctx)
277
278
  return await bridge.send_command("get_clip_file_path", track_index, clip_index)
278
279
 
279
-
280
280
  import os # for filename parsing in smart-defaults helper
281
281
  import re
282
+ import logging
282
283
 
284
+ logger = logging.getLogger(__name__)
283
285
 
284
286
  # ── Sample loading helpers (P0-1, P1-1, P2-6 fixes) ────────────────────────
285
287
  #
@@ -386,7 +388,8 @@ async def _simpler_post_load_hygiene(
386
388
  "device_index": device_index,
387
389
  "parameters": hygiene_params,
388
390
  })
389
- except Exception:
391
+ except Exception as exc:
392
+ logger.debug("_simpler_post_load_hygiene failed: %s", exc)
390
393
  # non-fatal — verification already succeeded
391
394
  pass
392
395
 
@@ -617,7 +620,6 @@ async def warp_simpler(
617
620
  bridge = _get_m4l(ctx)
618
621
  return await bridge.send_command("warp_simpler", track_index, device_index, beats)
619
622
 
620
-
621
623
  # ── Phase 2: Warp Markers ──────────────────────────────────────────────
622
624
 
623
625
 
@@ -702,7 +704,6 @@ async def remove_warp_marker(
702
704
  "remove_warp_marker", track_index, clip_index, beat_time
703
705
  )
704
706
 
705
-
706
707
  # ── Phase 2: Clip & Display ────────────────────────────────────────────
707
708
 
708
709
 
@@ -760,7 +761,6 @@ async def get_display_values(
760
761
  bridge = _get_m4l(ctx)
761
762
  return await bridge.send_command("get_display_values", track_index, device_index, timeout=15.0)
762
763
 
763
-
764
764
  # ── Phase 3: Audio Capture ─────────────────────────────────────────────
765
765
 
766
766
 
@@ -819,6 +819,7 @@ async def capture_audio(
819
819
  dst_path = os.path.join(CAPTURE_DIR, dst_name)
820
820
  try:
821
821
  import shutil
822
+
822
823
  shutil.move(src_path, dst_path)
823
824
  result["file_path"] = dst_path
824
825
  except OSError:
@@ -844,7 +845,6 @@ async def capture_stop(ctx: Context) -> dict:
844
845
  await bridge.cancel_capture_future()
845
846
  return await bridge.send_command("capture_stop")
846
847
 
847
-
848
848
  # ── Phase 4: FluCoMa Real-Time ───────────────────────────────────────────
849
849
 
850
850
  PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
@@ -969,3 +969,100 @@ def check_flucoma(ctx: Context) -> dict:
969
969
  streams[key] = cache.get(key) is not None
970
970
  active = sum(1 for v in streams.values() if v)
971
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)
@@ -14,6 +14,9 @@ from pydantic import BaseModel, Field
14
14
 
15
15
  from ..curves import generate_curve, generate_from_recipe, list_recipes
16
16
  from ..server import mcp
17
+ import logging
18
+
19
+ logger = logging.getLogger(__name__)
17
20
 
18
21
 
19
22
  def _get_ableton(ctx: Context):
@@ -23,6 +26,7 @@ def _get_ableton(ctx: Context):
23
26
  def _ensure_list(v: Any) -> list:
24
27
  if isinstance(v, str):
25
28
  import json
29
+
26
30
  try:
27
31
  return json.loads(v)
28
32
  except json.JSONDecodeError as exc:
@@ -372,7 +376,8 @@ def apply_automation_recipe(
372
376
  if abs(p_max - p_min) > 1.5 or p_min < -0.5:
373
377
  for pt in points:
374
378
  pt["value"] = p_min + pt["value"] * (p_max - p_min)
375
- except Exception:
379
+ except Exception as exc:
380
+ logger.debug("apply_automation_recipe failed: %s", exc)
376
381
  pass # Fail open — write values as-is if we can't read the range
377
382
 
378
383
  # Safety clamp: auto_pan amplitude should be limited to avoid full L/R swing