livepilot 1.10.5 → 1.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/execution_router.py +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +13 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -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
|
-
|
|
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={
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
for
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|