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.
Files changed (163) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +12 -10
  3. package/bin/livepilot.js +168 -30
  4. package/installer/install.js +117 -11
  5. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  6. package/m4l_device/livepilot_bridge.js +215 -3
  7. package/mcp_server/__init__.py +1 -1
  8. package/mcp_server/atlas/__init__.py +132 -33
  9. package/mcp_server/atlas/tools.py +56 -15
  10. package/mcp_server/composer/layer_planner.py +27 -0
  11. package/mcp_server/composer/prompt_parser.py +15 -6
  12. package/mcp_server/connection.py +11 -3
  13. package/mcp_server/corpus/__init__.py +14 -4
  14. package/mcp_server/creative_constraints/tools.py +206 -33
  15. package/mcp_server/experiment/engine.py +7 -9
  16. package/mcp_server/hook_hunter/analyzer.py +62 -9
  17. package/mcp_server/hook_hunter/tools.py +60 -9
  18. package/mcp_server/m4l_bridge.py +68 -12
  19. package/mcp_server/musical_intelligence/detectors.py +32 -0
  20. package/mcp_server/performance_engine/tools.py +112 -29
  21. package/mcp_server/preview_studio/engine.py +89 -8
  22. package/mcp_server/preview_studio/tools.py +22 -6
  23. package/mcp_server/project_brain/automation_graph.py +71 -19
  24. package/mcp_server/project_brain/builder.py +2 -0
  25. package/mcp_server/project_brain/tools.py +55 -5
  26. package/mcp_server/reference_engine/profile_builder.py +129 -3
  27. package/mcp_server/reference_engine/tools.py +47 -6
  28. package/mcp_server/runtime/execution_router.py +66 -2
  29. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  30. package/mcp_server/runtime/remote_commands.py +10 -2
  31. package/mcp_server/sample_engine/analyzer.py +131 -4
  32. package/mcp_server/sample_engine/critics.py +29 -8
  33. package/mcp_server/sample_engine/models.py +42 -4
  34. package/mcp_server/sample_engine/tools.py +48 -14
  35. package/mcp_server/semantic_moves/__init__.py +1 -0
  36. package/mcp_server/semantic_moves/compiler.py +9 -1
  37. package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
  38. package/mcp_server/semantic_moves/mix_compilers.py +170 -0
  39. package/mcp_server/semantic_moves/mix_moves.py +1 -1
  40. package/mcp_server/semantic_moves/models.py +5 -0
  41. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  42. package/mcp_server/semantic_moves/tools.py +15 -4
  43. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  44. package/mcp_server/server.py +75 -5
  45. package/mcp_server/services/singletons.py +68 -0
  46. package/mcp_server/session_continuity/models.py +4 -0
  47. package/mcp_server/session_continuity/tracker.py +14 -1
  48. package/mcp_server/song_brain/builder.py +110 -12
  49. package/mcp_server/song_brain/tools.py +77 -13
  50. package/mcp_server/sound_design/tools.py +112 -1
  51. package/mcp_server/splice_client/client.py +29 -8
  52. package/mcp_server/stuckness_detector/detector.py +90 -0
  53. package/mcp_server/stuckness_detector/tools.py +41 -0
  54. package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
  55. package/mcp_server/tools/_composition_engine/__init__.py +2 -2
  56. package/mcp_server/tools/_composition_engine/harmony.py +90 -0
  57. package/mcp_server/tools/_composition_engine/sections.py +47 -4
  58. package/mcp_server/tools/_harmony_engine.py +52 -8
  59. package/mcp_server/tools/_research_engine.py +98 -19
  60. package/mcp_server/tools/_theory_engine.py +138 -9
  61. package/mcp_server/tools/agent_os.py +20 -3
  62. package/mcp_server/tools/analyzer.py +105 -6
  63. package/mcp_server/tools/clips.py +46 -1
  64. package/mcp_server/tools/composition.py +66 -23
  65. package/mcp_server/tools/devices.py +22 -1
  66. package/mcp_server/tools/harmony.py +115 -14
  67. package/mcp_server/tools/midi_io.py +23 -1
  68. package/mcp_server/tools/mixing.py +35 -1
  69. package/mcp_server/tools/motif.py +49 -3
  70. package/mcp_server/tools/research.py +24 -0
  71. package/mcp_server/tools/theory.py +108 -16
  72. package/mcp_server/tools/tracks.py +1 -1
  73. package/mcp_server/tools/transport.py +1 -1
  74. package/mcp_server/transition_engine/critics.py +18 -11
  75. package/mcp_server/translation_engine/tools.py +8 -4
  76. package/package.json +25 -3
  77. package/remote_script/LivePilot/__init__.py +77 -2
  78. package/remote_script/LivePilot/arrangement.py +12 -2
  79. package/remote_script/LivePilot/browser.py +16 -6
  80. package/remote_script/LivePilot/clips.py +69 -0
  81. package/remote_script/LivePilot/devices.py +10 -5
  82. package/remote_script/LivePilot/mixing.py +117 -0
  83. package/remote_script/LivePilot/notes.py +13 -2
  84. package/remote_script/LivePilot/router.py +13 -1
  85. package/remote_script/LivePilot/server.py +51 -13
  86. package/remote_script/LivePilot/version_detect.py +7 -4
  87. package/server.json +20 -0
  88. package/.claude-plugin/marketplace.json +0 -21
  89. package/.mcpbignore +0 -57
  90. package/AGENTS.md +0 -46
  91. package/CODE_OF_CONDUCT.md +0 -27
  92. package/CONTRIBUTING.md +0 -131
  93. package/SECURITY.md +0 -48
  94. package/livepilot/.Codex-plugin/plugin.json +0 -8
  95. package/livepilot/.claude-plugin/plugin.json +0 -8
  96. package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
  97. package/livepilot/commands/arrange.md +0 -47
  98. package/livepilot/commands/beat.md +0 -77
  99. package/livepilot/commands/evaluate.md +0 -49
  100. package/livepilot/commands/memory.md +0 -22
  101. package/livepilot/commands/mix.md +0 -44
  102. package/livepilot/commands/perform.md +0 -42
  103. package/livepilot/commands/session.md +0 -13
  104. package/livepilot/commands/sounddesign.md +0 -43
  105. package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
  106. package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
  107. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
  108. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
  109. package/livepilot/skills/livepilot-core/SKILL.md +0 -184
  110. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
  111. package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
  112. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
  113. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
  114. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
  115. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
  116. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
  117. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
  118. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
  119. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
  120. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
  121. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
  122. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
  123. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
  124. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
  125. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
  126. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
  127. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
  128. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
  129. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
  130. package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
  131. package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
  132. package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
  133. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
  134. package/livepilot/skills/livepilot-core/references/overview.md +0 -290
  135. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
  136. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
  137. package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
  138. package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
  139. package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
  140. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
  141. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
  142. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
  143. package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
  144. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
  145. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
  146. package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
  147. package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
  148. package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
  149. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
  150. package/livepilot/skills/livepilot-release/SKILL.md +0 -130
  151. package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
  152. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
  153. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
  154. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
  155. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
  156. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
  157. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
  158. package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
  159. package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
  160. package/manifest.json +0 -91
  161. package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
  162. package/scripts/generate_tool_catalog.py +0 -131
  163. 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
- note_bar = section.start_bar + int(start_beat / beats_per_bar)
211
- if section.start_bar <= note_bar < section.end_bar:
212
- bar_densities[note_bar] = bar_densities.get(note_bar, 0) + 1
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 PRL transform between each consecutive pair of chords.
199
-
200
- Tries single transforms (P, L, R) first, then 2-step compound
201
- transforms (PL, PR, LP, LR, RP, RL) for richer classification.
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
- _COMPOUNDS = ["PL", "PR", "LP", "LR", "RP", "RL",
204
- "PP", "LL", "RR"]
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 compound transforms
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 _COMPOUNDS:
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
- 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 = []
@@ -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
- 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)
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)