livepilot 1.10.6 → 1.10.8
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/CHANGELOG.md +168 -0
- package/README.md +12 -10
- package/bin/livepilot.js +168 -30
- package/installer/install.js +117 -11
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +215 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +132 -33
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- package/mcp_server/creative_constraints/tools.py +206 -33
- package/mcp_server/experiment/engine.py +7 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +60 -9
- package/mcp_server/m4l_bridge.py +68 -12
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/performance_engine/tools.py +112 -29
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +22 -6
- 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 +55 -5
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +47 -6
- package/mcp_server/runtime/execution_router.py +66 -2
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +10 -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 +42 -4
- package/mcp_server/sample_engine/tools.py +48 -14
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +15 -4
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +75 -5
- package/mcp_server/services/singletons.py +68 -0
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tracker.py +14 -1
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +77 -13
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +29 -8
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +41 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
- package/mcp_server/tools/_composition_engine/__init__.py +2 -2
- package/mcp_server/tools/_composition_engine/harmony.py +90 -0
- package/mcp_server/tools/_composition_engine/sections.py +47 -4
- package/mcp_server/tools/_harmony_engine.py +52 -8
- 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 +20 -3
- package/mcp_server/tools/analyzer.py +105 -6
- package/mcp_server/tools/clips.py +46 -1
- package/mcp_server/tools/composition.py +66 -23
- package/mcp_server/tools/devices.py +22 -1
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +23 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +49 -3
- package/mcp_server/tools/research.py +24 -0
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +77 -2
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/router.py +13 -1
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcpbignore +0 -57
- package/AGENTS.md +0 -46
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -131
- package/scripts/sync_metadata.py +0 -132
|
@@ -194,6 +194,15 @@ def detect_phrases(
|
|
|
194
194
|
|
|
195
195
|
Uses note density changes and gap detection to find phrase boundaries.
|
|
196
196
|
Falls back to regular grid (4 or 8 bar phrases).
|
|
197
|
+
|
|
198
|
+
BUG-B22 fix: most clips are 4-8 bar loops. In an 8-bar section with
|
|
199
|
+
4-bar clips, notes have start_time 0..16 (one clip cycle). The old
|
|
200
|
+
algorithm placed them at absolute bars section.start_bar + 0..4 only,
|
|
201
|
+
leaving bars 4..7 of the section reading as "empty" — which produced
|
|
202
|
+
note_density=0 for the second half even though Ableton loops those
|
|
203
|
+
clips to fill the section. We now infer each track's clip length
|
|
204
|
+
from its max note start_time and wrap note bars modulo that length,
|
|
205
|
+
so a looping clip's density spreads across the whole section.
|
|
197
206
|
"""
|
|
198
207
|
section_length = section.length_bars()
|
|
199
208
|
if section_length <= 0:
|
|
@@ -204,12 +213,46 @@ def detect_phrases(
|
|
|
204
213
|
for bar in range(section.start_bar, section.end_bar):
|
|
205
214
|
bar_densities[bar] = 0
|
|
206
215
|
|
|
216
|
+
section_bar_count = section.end_bar - section.start_bar
|
|
217
|
+
|
|
207
218
|
for track_notes in notes_by_track.values():
|
|
219
|
+
if not track_notes:
|
|
220
|
+
continue
|
|
221
|
+
# Infer this track's clip span (in bars) from the max start_time.
|
|
222
|
+
# The clip LOOPS to fill the section, so notes at start_time=0
|
|
223
|
+
# repeat every span bars. Round UP so a 3.5-beat phrase counts
|
|
224
|
+
# as 1 bar (not 0).
|
|
225
|
+
max_start_beat = max(
|
|
226
|
+
float(n.get("start_time", 0) or 0) for n in track_notes
|
|
227
|
+
)
|
|
228
|
+
clip_span_bars = max(
|
|
229
|
+
1,
|
|
230
|
+
int((max_start_beat / beats_per_bar) + 1),
|
|
231
|
+
)
|
|
232
|
+
# If we can't determine a sensible span, fall back to section length
|
|
233
|
+
if clip_span_bars <= 0:
|
|
234
|
+
clip_span_bars = section_bar_count
|
|
235
|
+
|
|
208
236
|
for note in track_notes:
|
|
209
|
-
start_beat = note.get("start_time", 0)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
237
|
+
start_beat = float(note.get("start_time", 0) or 0)
|
|
238
|
+
clip_bar = int(start_beat / beats_per_bar)
|
|
239
|
+
# Fill-copy the note across all loop iterations that fit
|
|
240
|
+
# inside the section. For a 4-bar clip in an 8-bar section
|
|
241
|
+
# that means each note contributes to bars 0..3 AND 4..7.
|
|
242
|
+
if clip_span_bars >= section_bar_count:
|
|
243
|
+
# Clip is already section-long (or longer) — no looping
|
|
244
|
+
positions = [clip_bar]
|
|
245
|
+
else:
|
|
246
|
+
# Wrap by modulo — project across the section
|
|
247
|
+
positions = list(range(
|
|
248
|
+
clip_bar % clip_span_bars,
|
|
249
|
+
section_bar_count,
|
|
250
|
+
clip_span_bars,
|
|
251
|
+
))
|
|
252
|
+
for offset in positions:
|
|
253
|
+
note_bar = section.start_bar + offset
|
|
254
|
+
if section.start_bar <= note_bar < section.end_bar:
|
|
255
|
+
bar_densities[note_bar] = bar_densities.get(note_bar, 0) + 1
|
|
213
256
|
|
|
214
257
|
# Find phrase boundaries using density drops (gaps)
|
|
215
258
|
boundaries = [section.start_bar]
|
|
@@ -195,13 +195,45 @@ def find_shortest_path(
|
|
|
195
195
|
# ---------------------------------------------------------------------------
|
|
196
196
|
|
|
197
197
|
def classify_transform_sequence(chords: list[tuple[int, str]]) -> list[str]:
|
|
198
|
-
"""Identify the
|
|
199
|
-
|
|
200
|
-
Tries
|
|
201
|
-
|
|
198
|
+
"""Identify the neo-Riemannian transform between each consecutive pair.
|
|
199
|
+
|
|
200
|
+
Tries (in order):
|
|
201
|
+
1. Single transforms: P, L, R
|
|
202
|
+
2. 2-step compounds: PL, PR, LP, LR, RP, RL, PP, LL, RR
|
|
203
|
+
3. 3-step compounds: PLR, PRL, LPR, LRP, RLP, RPL, PLP, PRP, LPL, …
|
|
204
|
+
(BUG-B24: needed for progressions that step through mediants)
|
|
205
|
+
4. Diatonic-step primitives: S2↑ / S2↓ (whole-step root motion,
|
|
206
|
+
same quality) and S1↑ / S1↓ (half-step root motion, same
|
|
207
|
+
quality). These aren't classical neo-Riemannian transforms —
|
|
208
|
+
but Gm → Am (D minor iv → v) IS a valid progression, so the
|
|
209
|
+
transform alphabet needs SOME label for it. Marking it with a
|
|
210
|
+
dedicated symbol is cleaner than returning "?", which cascades
|
|
211
|
+
into misleading "diatonic cycle fragment" classifications
|
|
212
|
+
downstream.
|
|
202
213
|
"""
|
|
203
|
-
|
|
204
|
-
|
|
214
|
+
_TWO_STEP = ["PL", "PR", "LP", "LR", "RP", "RL",
|
|
215
|
+
"PP", "LL", "RR"]
|
|
216
|
+
_THREE_STEP = [
|
|
217
|
+
"PLR", "PRL", "LPR", "LRP", "RLP", "RPL",
|
|
218
|
+
"PLP", "LPL", "PRP", "RPR", "LRL", "RLR",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
def _try_primitive_step(a: tuple[int, str], b: tuple[int, str]) -> str:
|
|
222
|
+
"""Detect same-quality step motion → S1/S2 primitive."""
|
|
223
|
+
if a[1] != b[1]:
|
|
224
|
+
return "?"
|
|
225
|
+
interval = (b[0] - a[0]) % 12
|
|
226
|
+
# Prefer the signed direction symbol for readability
|
|
227
|
+
if interval == 1:
|
|
228
|
+
return "S1u" # semitone up
|
|
229
|
+
if interval == 11:
|
|
230
|
+
return "S1d" # semitone down
|
|
231
|
+
if interval == 2:
|
|
232
|
+
return "S2u" # whole-step up (Gm → Am in Dm)
|
|
233
|
+
if interval == 10:
|
|
234
|
+
return "S2d" # whole-step down
|
|
235
|
+
return "?"
|
|
236
|
+
|
|
205
237
|
result = []
|
|
206
238
|
for i in range(len(chords) - 1):
|
|
207
239
|
found = "?"
|
|
@@ -210,15 +242,27 @@ def classify_transform_sequence(chords: list[tuple[int, str]]) -> list[str]:
|
|
|
210
242
|
if fn(*chords[i]) == chords[i + 1]:
|
|
211
243
|
found = label
|
|
212
244
|
break
|
|
213
|
-
# Try 2-step
|
|
245
|
+
# Try 2-step compounds
|
|
246
|
+
if found == "?":
|
|
247
|
+
for compound in _TWO_STEP:
|
|
248
|
+
try:
|
|
249
|
+
if apply_transforms(*chords[i], compound) == chords[i + 1]:
|
|
250
|
+
found = compound
|
|
251
|
+
break
|
|
252
|
+
except (ValueError, KeyError):
|
|
253
|
+
continue
|
|
254
|
+
# Try 3-step compounds (BUG-B24)
|
|
214
255
|
if found == "?":
|
|
215
|
-
for compound in
|
|
256
|
+
for compound in _THREE_STEP:
|
|
216
257
|
try:
|
|
217
258
|
if apply_transforms(*chords[i], compound) == chords[i + 1]:
|
|
218
259
|
found = compound
|
|
219
260
|
break
|
|
220
261
|
except (ValueError, KeyError):
|
|
221
262
|
continue
|
|
263
|
+
# Final fallback: same-quality step motion (BUG-B24)
|
|
264
|
+
if found == "?":
|
|
265
|
+
found = _try_primitive_step(chords[i], chords[i + 1])
|
|
222
266
|
result.append(found)
|
|
223
267
|
return result
|
|
224
268
|
|
|
@@ -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 = []
|
|
@@ -151,11 +151,28 @@ def build_world_model(ctx: Context) -> dict:
|
|
|
151
151
|
if key_data:
|
|
152
152
|
detected_key = key_data["value"] if isinstance(key_data["value"], dict) else {"key": key_data["value"]}
|
|
153
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.
|
|
154
169
|
flucoma_data = spectral.get("flucoma_status")
|
|
155
|
-
if flucoma_data:
|
|
156
|
-
|
|
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)
|
|
157
174
|
else:
|
|
158
|
-
flucoma_status = {"flucoma_available": False}
|
|
175
|
+
flucoma_status = {"flucoma_available": False, "active_streams": 0}
|
|
159
176
|
|
|
160
177
|
# Build model
|
|
161
178
|
wm = engine.build_world_model_from_data(
|
|
@@ -6,12 +6,20 @@ These tools are optional — all core tools work without the device.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import logging
|
|
9
10
|
import os
|
|
11
|
+
import re # used below in filename parsing helpers
|
|
12
|
+
from typing import Optional
|
|
10
13
|
|
|
11
14
|
from fastmcp import Context
|
|
12
15
|
|
|
13
16
|
from ..server import mcp, _identify_port_holder
|
|
14
17
|
|
|
18
|
+
# Logger must be defined before any helper that uses it — _require_analyzer
|
|
19
|
+
# below calls logger.debug on an exception path, so defining the logger later
|
|
20
|
+
# in the file risked NameError under unusual import orderings.
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
15
23
|
CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
|
|
16
24
|
|
|
17
25
|
|
|
@@ -276,12 +284,6 @@ async def get_clip_file_path(
|
|
|
276
284
|
bridge = _get_m4l(ctx)
|
|
277
285
|
return await bridge.send_command("get_clip_file_path", track_index, clip_index)
|
|
278
286
|
|
|
279
|
-
import os # for filename parsing in smart-defaults helper
|
|
280
|
-
import re
|
|
281
|
-
import logging
|
|
282
|
-
|
|
283
|
-
logger = logging.getLogger(__name__)
|
|
284
|
-
|
|
285
287
|
# ── Sample loading helpers (P0-1, P1-1, P2-6 fixes) ────────────────────────
|
|
286
288
|
#
|
|
287
289
|
# Critical bug 2026-04-14 (see docs/2026-04-14-bugs-discovered.md):
|
|
@@ -968,3 +970,100 @@ def check_flucoma(ctx: Context) -> dict:
|
|
|
968
970
|
streams[key] = cache.get(key) is not None
|
|
969
971
|
active = sum(1 for v in streams.values() if v)
|
|
970
972
|
return {"flucoma_available": active > 0, "active_streams": active, "streams": streams}
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
# ── BUG-A2 + A3: deep-LOM properties via M4L bridge ──────────────────
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
@mcp.tool()
|
|
979
|
+
async def simpler_set_warp(
|
|
980
|
+
ctx: Context,
|
|
981
|
+
track_index: int,
|
|
982
|
+
device_index: int,
|
|
983
|
+
warping: bool,
|
|
984
|
+
warp_mode: Optional[int] = None,
|
|
985
|
+
) -> dict:
|
|
986
|
+
"""Toggle a Simpler's sample warping + set the warp algorithm (BUG-A2).
|
|
987
|
+
|
|
988
|
+
Python's Remote Script ControlSurface API can't reach Simpler's
|
|
989
|
+
`warping` or `warp_mode` — they live on the sample child object
|
|
990
|
+
(SimplerDevice.sample.*) that only Max for Live's JavaScript LiveAPI
|
|
991
|
+
can step into. This tool routes through the M4L bridge to do the
|
|
992
|
+
write.
|
|
993
|
+
|
|
994
|
+
When enabling warping, pass the desired warp_mode too so Live doesn't
|
|
995
|
+
default to whatever was there last:
|
|
996
|
+
|
|
997
|
+
warp_mode 0 = Beats (good for drums / percussive loops)
|
|
998
|
+
warp_mode 1 = Tones (mono harmonic material)
|
|
999
|
+
warp_mode 2 = Texture (poly / ambient material)
|
|
1000
|
+
warp_mode 3 = Re-Pitch (classic pitch-shift feel)
|
|
1001
|
+
warp_mode 4 = Complex (music / full mixes — higher CPU)
|
|
1002
|
+
warp_mode 6 = Complex Pro (highest quality — highest CPU)
|
|
1003
|
+
|
|
1004
|
+
Args:
|
|
1005
|
+
track_index: 0+ for regular tracks
|
|
1006
|
+
device_index: Simpler device's position in the chain
|
|
1007
|
+
warping: True → enable sample warp; False → disable
|
|
1008
|
+
warp_mode: 0-6 (omit to leave the current mode unchanged)
|
|
1009
|
+
|
|
1010
|
+
Requires LivePilot Analyzer on master track.
|
|
1011
|
+
"""
|
|
1012
|
+
if warp_mode is not None and warp_mode not in (0, 1, 2, 3, 4, 6):
|
|
1013
|
+
raise ValueError("warp_mode must be 0,1,2,3,4,6 (no 5 — Live skips it)")
|
|
1014
|
+
cache = _get_spectral(ctx)
|
|
1015
|
+
_require_analyzer(cache)
|
|
1016
|
+
bridge = _get_m4l(ctx)
|
|
1017
|
+
return await bridge.send_command(
|
|
1018
|
+
"simpler_set_warp",
|
|
1019
|
+
int(track_index),
|
|
1020
|
+
int(device_index),
|
|
1021
|
+
1 if warping else 0,
|
|
1022
|
+
-1 if warp_mode is None else int(warp_mode),
|
|
1023
|
+
timeout=10.0,
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
@mcp.tool()
|
|
1028
|
+
async def compressor_set_sidechain(
|
|
1029
|
+
ctx: Context,
|
|
1030
|
+
track_index: int,
|
|
1031
|
+
device_index: int,
|
|
1032
|
+
source_type: str = "",
|
|
1033
|
+
source_channel: str = "",
|
|
1034
|
+
) -> dict:
|
|
1035
|
+
"""Configure a Compressor's sidechain INPUT ROUTING (BUG-A3).
|
|
1036
|
+
|
|
1037
|
+
Complements set_device_parameter's `S/C On` toggle: that enables the
|
|
1038
|
+
sidechain, this picks WHICH track/channel feeds the detector. The
|
|
1039
|
+
routing properties (`sidechain_input_routing_type`,
|
|
1040
|
+
`sidechain_input_routing_channel`) aren't in Compressor's automatable
|
|
1041
|
+
parameter list, but Python's Remote Script reaches them directly as
|
|
1042
|
+
device properties (same LOM pattern as set_track_routing).
|
|
1043
|
+
|
|
1044
|
+
Args:
|
|
1045
|
+
track_index: 0+ regular, -1/-2 returns, -1000 master
|
|
1046
|
+
device_index: Compressor position in the chain
|
|
1047
|
+
source_type: sidechain source display name
|
|
1048
|
+
(e.g. "1-Kick", "Ext. In", "No Input")
|
|
1049
|
+
source_channel: tap point on the source
|
|
1050
|
+
(e.g. "Post FX", "Pre FX", "Post Mixer")
|
|
1051
|
+
|
|
1052
|
+
Omit a param to leave that property unchanged. If a display name
|
|
1053
|
+
doesn't match, the error message includes the full list of available
|
|
1054
|
+
options from the running Live session.
|
|
1055
|
+
|
|
1056
|
+
Routes through the Remote Script (TCP) — does NOT require the M4L
|
|
1057
|
+
analyzer. This is the Python-side path introduced after the M4L
|
|
1058
|
+
bridge approach hit LiveAPI shape issues in Live 12.3.6.
|
|
1059
|
+
"""
|
|
1060
|
+
params: dict = {
|
|
1061
|
+
"track_index": int(track_index),
|
|
1062
|
+
"device_index": int(device_index),
|
|
1063
|
+
}
|
|
1064
|
+
if source_type:
|
|
1065
|
+
params["source_type"] = str(source_type)
|
|
1066
|
+
if source_channel:
|
|
1067
|
+
params["source_channel"] = str(source_channel)
|
|
1068
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
1069
|
+
return ableton.send_command("set_compressor_sidechain", params)
|