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
@@ -187,6 +187,77 @@ def build_song_brain(ctx: Context) -> dict:
187
187
  }
188
188
 
189
189
 
190
+ def classify_energy_shape(arc: list[float]) -> dict:
191
+ """Classify the shape of an energy arc for user-facing explanation.
192
+
193
+ BUG-B13 fix: the old single-max-position classifier labeled
194
+ [0.7, 0.9, 0.9, 0.5, 0.6, 0.9, 0.4] (peaks at 1-2 AND 5) as
195
+ "front-loaded" because max()'s first occurrence is at index 1.
196
+ We now find ALL peaks above a dynamic threshold and classify by
197
+ count + distribution.
198
+
199
+ Returns {"shape": str, "peak_positions": list[int] | None}.
200
+ """
201
+ arc = [x for x in (arc or []) if x is not None]
202
+ if len(arc) < 3:
203
+ return {"shape": "short form — limited arc data", "peak_positions": None}
204
+
205
+ max_energy = max(arc)
206
+ arc_min = min(arc)
207
+ dynamic_mid = (arc_min + max_energy) / 2.0
208
+ peak_threshold = max(max_energy * 0.9, dynamic_mid)
209
+ peak_indices = [i for i, v in enumerate(arc) if v >= peak_threshold]
210
+
211
+ # Collapse runs of adjacent peak indices into their starting index —
212
+ # [1, 2, 5] has peaks at "position ~1" and "position 5", NOT three
213
+ # distinct peaks. Without this, front-loaded arcs where bars 0 and 1
214
+ # are both above threshold would misfire the dual-peak branch.
215
+ distinct_peaks: list[int] = []
216
+ for idx in peak_indices:
217
+ if not distinct_peaks or idx - distinct_peaks[-1] > 1:
218
+ distinct_peaks.append(idx)
219
+
220
+ n = len(arc)
221
+ first_third = {i for i in range(0, n // 3 + 1)}
222
+ last_third = {i for i in range(2 * n // 3, n)}
223
+ in_first = any(i in first_third for i in peak_indices)
224
+ in_last = any(i in last_third for i in peak_indices)
225
+ in_middle = any(
226
+ i not in first_third and i not in last_third
227
+ for i in peak_indices
228
+ )
229
+
230
+ # Plateau FIRST — when the dynamic range is narrow (<0.3) and most
231
+ # of the arc sits at/near the max, it's a plateau, not a multi-peak
232
+ # shape. Has to win over dual-peak so [0.7, 0.8, 0.8, 0.75, 0.8, …]
233
+ # doesn't get labeled "dual-peak at 2 and 6" when it's clearly flat.
234
+ if len(peak_indices) >= max(n - 2, 2) and (
235
+ max_energy - arc_min < 0.3
236
+ ):
237
+ shape = "plateau — sustained energy with limited dynamic range"
238
+ # Multi-peak: at least 2 DISTINCT peaks (after collapsing adjacent runs),
239
+ # separated by >= n/3 positions. Adjacent peaks are a single plateau, not two.
240
+ elif len(distinct_peaks) >= 2 and (
241
+ max(distinct_peaks) - min(distinct_peaks) >= max(n // 3, 2)
242
+ ):
243
+ shape = (
244
+ f"dual-peak — energy peaks at positions "
245
+ f"{distinct_peaks[0]+1} and {distinct_peaks[-1]+1}"
246
+ )
247
+ elif in_first and not in_middle and not in_last:
248
+ shape = "front-loaded — peaks early"
249
+ elif in_last and not in_first and not in_middle:
250
+ shape = "slow burn — builds to late peak"
251
+ elif in_middle and not in_first and not in_last:
252
+ shape = "centered arc — peaks in the middle"
253
+ else:
254
+ shape = (
255
+ f"mixed — peaks at positions "
256
+ f"{', '.join(str(i+1) for i in peak_indices)}"
257
+ )
258
+ return {"shape": shape, "peak_positions": peak_indices}
259
+
260
+
190
261
  @mcp.tool()
191
262
  def explain_song_identity(ctx: Context) -> dict:
192
263
  """Explain the current song's identity in human musical language.
@@ -228,20 +299,13 @@ def explain_song_identity(ctx: Context) -> dict:
228
299
  for s in brain.section_purposes
229
300
  ]
230
301
 
231
- # Energy shape
302
+ # Energy shape — BUG-B13 fix: dual-peak detection. See
303
+ # classify_energy_shape() for logic.
232
304
  if brain.energy_arc:
233
- arc = brain.energy_arc
234
- if len(arc) >= 3:
235
- peak_idx = arc.index(max(arc))
236
- peak_pct = peak_idx / max(len(arc) - 1, 1)
237
- if peak_pct < 0.3:
238
- explanation["energy_shape"] = "front-loaded — peaks early"
239
- elif peak_pct > 0.7:
240
- explanation["energy_shape"] = "slow burn — builds to late peak"
241
- else:
242
- explanation["energy_shape"] = "centered arc — peaks in the middle"
243
- else:
244
- explanation["energy_shape"] = "short form — limited arc data"
305
+ shape_info = classify_energy_shape(brain.energy_arc)
306
+ explanation["energy_shape"] = shape_info["shape"]
307
+ if shape_info["peak_positions"] is not None:
308
+ explanation["peak_positions"] = shape_info["peak_positions"]
245
309
 
246
310
  # Open questions
247
311
  if brain.open_questions:
@@ -195,6 +195,46 @@ def _fetch_sound_design_data(ctx: Context, track_index: int) -> dict:
195
195
  }
196
196
 
197
197
 
198
+ # BUG-B35: some roles are SUPPOSED to be simple. A kick, snare, or sub-bass
199
+ # patch with one block + a saturator is textbook electronic drum design —
200
+ # not "weak identity". Gate the "too_few_blocks" + "no_modulation_sources"
201
+ # critics on track role so we don't pester users about their perfectly
202
+ # serviceable DS Kick patches.
203
+ _SIMPLE_ROLE_TOKENS = (
204
+ "kick", "snare", "clap", "rim", "hat", "hihat", "hi-hat",
205
+ "drum", "drums", "perc", "percussion", "conga", "shaker",
206
+ "tambourine", "cowbell", "tom", "crash", "ride", "cymbal",
207
+ "808", "sub", "sub bass", "sub_bass",
208
+ )
209
+
210
+
211
+ def _is_simple_role_track(track_name: str) -> bool:
212
+ """True when the track name matches a role where simple patches are
213
+ the correct creative choice (kick/snare/drums/sub)."""
214
+ if not track_name:
215
+ return False
216
+ lowered = str(track_name).lower()
217
+ return any(tok in lowered for tok in _SIMPLE_ROLE_TOKENS)
218
+
219
+
220
+ _ROLE_SUPPRESSIBLE_ISSUES = frozenset({
221
+ "too_few_blocks",
222
+ "no_modulation_sources",
223
+ })
224
+
225
+
226
+ def _filter_role_appropriate_issues(
227
+ issues: list,
228
+ track_name: str,
229
+ ) -> list:
230
+ """Drop issues whose type is in _ROLE_SUPPRESSIBLE_ISSUES when the
231
+ track role (inferred from name) is one where simplicity is expected.
232
+ Issues pass through unchanged for pad / lead / synth / bass roles."""
233
+ if not _is_simple_role_track(track_name):
234
+ return issues
235
+ return [i for i in issues if i.issue_type not in _ROLE_SUPPRESSIBLE_ISSUES]
236
+
237
+
198
238
  # ── MCP Tools ────────────────────────────────────────────────────────
199
239
 
200
240
 
@@ -217,6 +257,10 @@ def analyze_sound_design(ctx: Context, track_index: int) -> dict:
217
257
  layers=layers,
218
258
  )
219
259
  issues = run_all_sound_design_critics(state)
260
+ # BUG-B35: gate role-sensitive critics by track name
261
+ issues = _filter_role_appropriate_issues(
262
+ issues, data["track_info"].get("name", "")
263
+ )
220
264
  moves = plan_sound_design_moves(issues, state)
221
265
 
222
266
  return {
@@ -246,6 +290,9 @@ def get_sound_design_issues(ctx: Context, track_index: int) -> dict:
246
290
  layers=layers,
247
291
  )
248
292
  issues = run_all_sound_design_critics(state)
293
+ issues = _filter_role_appropriate_issues(
294
+ issues, data["track_info"].get("name", "")
295
+ )
249
296
 
250
297
  return {
251
298
  "issues": [i.to_dict() for i in issues],
@@ -260,6 +307,11 @@ def plan_sound_design_move(ctx: Context, track_index: int) -> dict:
260
307
  Runs critics and planner, returns sorted moves with
261
308
  estimated impact and risk scores.
262
309
 
310
+ BUG-B36 fix: when zero sound-design issues but sibling mix/
311
+ composition engines flag problems on the same track, returns a
312
+ `cross_engine_hint` pointing the user to the right tool instead
313
+ of silently reporting empty.
314
+
263
315
  Args:
264
316
  track_index: Index of the track to analyze.
265
317
  """
@@ -272,14 +324,73 @@ def plan_sound_design_move(ctx: Context, track_index: int) -> dict:
272
324
  layers=layers,
273
325
  )
274
326
  issues = run_all_sound_design_critics(state)
327
+ issues = _filter_role_appropriate_issues(
328
+ issues, data["track_info"].get("name", "")
329
+ )
275
330
  moves = plan_sound_design_moves(issues, state)
276
331
 
277
- return {
332
+ result: dict = {
278
333
  "moves": [m.to_dict() for m in moves],
279
334
  "move_count": len(moves),
280
335
  "issue_count": len(issues),
281
336
  }
282
337
 
338
+ # BUG-B36: when nothing to do on the sound-design side, probe sibling
339
+ # engines for issues on this track and emit a discoverability hint.
340
+ if not moves:
341
+ cross_hint = _cross_engine_hint_for_track(ctx, track_index)
342
+ if cross_hint:
343
+ result["cross_engine_hint"] = cross_hint
344
+
345
+ return result
346
+
347
+
348
+ def _cross_engine_hint_for_track(
349
+ ctx: Context, track_index: int,
350
+ ) -> Optional[str]:
351
+ """Look at mix issues for this track and emit a hint pointing to
352
+ the right sibling tool when sound-design has nothing to say.
353
+
354
+ Best-effort — any failure returns None so plan_sound_design_move
355
+ never breaks on telemetry hiccups. Calls into the mix-engine's
356
+ pure function directly (not an MCP round-trip) to avoid the
357
+ one-client-per-port contention.
358
+ """
359
+ try:
360
+ from ..mix_engine.critics import run_all_mix_critics
361
+ from ..mix_engine.state_builder import build_mix_state
362
+ from ..mix_engine.tools import _fetch_mix_data
363
+ data = _fetch_mix_data(ctx)
364
+ mix_state = build_mix_state(
365
+ session_info=data.get("session_info", {}),
366
+ track_infos=data.get("track_infos", []),
367
+ spectrum=data.get("spectrum"),
368
+ rms_data=data.get("rms_data"),
369
+ )
370
+ mix_issues = run_all_mix_critics(mix_state)
371
+ except Exception:
372
+ return None
373
+
374
+ if not mix_issues:
375
+ return None
376
+ track_issues = [
377
+ i for i in mix_issues
378
+ if getattr(i, "track_index", None) == track_index
379
+ ]
380
+ if not track_issues:
381
+ return None
382
+ top = max(
383
+ track_issues,
384
+ key=lambda i: float(getattr(i, "severity", 0) or 0),
385
+ )
386
+ issue_type = str(getattr(top, "issue_type", None) or getattr(top, "type", "issue"))
387
+ sev = float(getattr(top, "severity", 0) or 0)
388
+ return (
389
+ f"No sound-design issues on this track, but mix critic flagged "
390
+ f"'{issue_type}' (severity {sev:.2f}). "
391
+ f"Try plan_mix_move for the same track_index."
392
+ )
393
+
283
394
 
284
395
  @mcp.tool()
285
396
  def get_patch_model(ctx: Context, track_index: int) -> dict:
@@ -27,6 +27,16 @@ _SPLICE_APP_SUPPORT = os.path.expanduser(
27
27
  # Credit safety floor — never drain below this
28
28
  CREDIT_HARD_FLOOR = 5
29
29
 
30
+ # Per-call gRPC timeouts. The previous implementation passed no timeout, so
31
+ # a hung Splice process could block the MCP event loop until gRPC's default
32
+ # (often infinite) deadline fired. Keep generous enough for cold searches
33
+ # but bounded enough that a dead socket fails the tool call, not the server.
34
+ SEARCH_TIMEOUT = 10.0
35
+ INFO_TIMEOUT = 5.0
36
+ CREDITS_TIMEOUT = 5.0
37
+ SYNC_TIMEOUT = 30.0
38
+ DOWNLOAD_TRIGGER_TIMEOUT = 5.0
39
+
30
40
 
31
41
  def _try_import_grpc():
32
42
  """Import grpcio lazily — graceful degradation if not installed."""
@@ -148,7 +158,7 @@ class SpliceGRPCClient:
148
158
  Page=page,
149
159
  Purchased=purchased,
150
160
  )
151
- response = await self.stub.SearchSamples(request)
161
+ response = await self.stub.SearchSamples(request, timeout=SEARCH_TIMEOUT)
152
162
  return self._parse_search_response(response)
153
163
  except Exception as exc:
154
164
  logger.warning(f"Splice search failed: {exc}")
@@ -213,7 +223,8 @@ class SpliceGRPCClient:
213
223
  try:
214
224
  # Trigger download
215
225
  await self.stub.DownloadSample(
216
- pb2.DownloadSampleRequest(FileHash=file_hash)
226
+ pb2.DownloadSampleRequest(FileHash=file_hash),
227
+ timeout=DOWNLOAD_TRIGGER_TIMEOUT,
217
228
  )
218
229
  # Wait for file to appear on disk
219
230
  return await self._wait_for_download(file_hash, timeout)
@@ -226,11 +237,16 @@ class SpliceGRPCClient:
226
237
  ) -> Optional[str]:
227
238
  """Poll SampleInfo until LocalPath is populated."""
228
239
  pb2 = self._pb2
229
- deadline = asyncio.get_event_loop().time() + timeout
230
- while asyncio.get_event_loop().time() < deadline:
240
+ # asyncio.get_event_loop() is deprecated when called inside an
241
+ # already-running coroutine on Python 3.10+. Use get_running_loop()
242
+ # which is the documented replacement.
243
+ loop = asyncio.get_running_loop()
244
+ deadline = loop.time() + timeout
245
+ while loop.time() < deadline:
231
246
  try:
232
247
  response = await self.stub.SampleInfo(
233
- pb2.SampleInfoRequest(FileHash=file_hash)
248
+ pb2.SampleInfoRequest(FileHash=file_hash),
249
+ timeout=INFO_TIMEOUT,
234
250
  )
235
251
  if response.Sample.LocalPath:
236
252
  return response.Sample.LocalPath
@@ -250,7 +266,8 @@ class SpliceGRPCClient:
250
266
  pb2 = self._pb2
251
267
  try:
252
268
  response = await self.stub.SampleInfo(
253
- pb2.SampleInfoRequest(FileHash=file_hash)
269
+ pb2.SampleInfoRequest(FileHash=file_hash),
270
+ timeout=INFO_TIMEOUT,
254
271
  )
255
272
  s = response.Sample
256
273
  return SpliceSample(
@@ -282,7 +299,8 @@ class SpliceGRPCClient:
282
299
  pb2 = self._pb2
283
300
  try:
284
301
  response = await self.stub.ValidateLogin(
285
- pb2.ValidateLoginRequest()
302
+ pb2.ValidateLoginRequest(),
303
+ timeout=CREDITS_TIMEOUT,
286
304
  )
287
305
  return SpliceCredits(
288
306
  credits=response.User.Credits,
@@ -315,7 +333,10 @@ class SpliceGRPCClient:
315
333
  return False
316
334
  pb2 = self._pb2
317
335
  try:
318
- await self.stub.SyncSounds(pb2.SyncSoundsRequest())
336
+ await self.stub.SyncSounds(
337
+ pb2.SyncSoundsRequest(),
338
+ timeout=SYNC_TIMEOUT,
339
+ )
319
340
  return True
320
341
  except Exception as exc:
321
342
  logger.debug("sync_sounds failed: %s", exc)
@@ -20,14 +20,30 @@ def detect_stuckness(
20
20
  session_info: Optional[dict] = None,
21
21
  song_brain: Optional[dict] = None,
22
22
  section_count: int = 0,
23
+ state_signals: Optional[dict] = None,
23
24
  ) -> StucknessReport:
24
25
  """Detect whether the session is stuck.
25
26
 
26
27
  Analyzes action history for repeated undos, local tweaking,
27
28
  long loops without structural edits, and other stuckness signals.
29
+
30
+ BUG-B6 / B20 fix: also accepts `state_signals` — a dict of
31
+ current-session-state indicators that may reveal stuckness even
32
+ when the action ledger is empty. Known keys (all optional):
33
+ fatigue_level: float 0-1 (from detect_repetition_fatigue)
34
+ motif_overuse_count: int (motifs exceeding overuse threshold)
35
+ emotional_arc_issues: list of issue-type strings
36
+ transition_issues: int
37
+ support_too_loud: bool (from analyze_mix)
38
+ automation_density: float (0 = no clip automation anywhere)
39
+
40
+ When state_signals are provided, they contribute to confidence
41
+ alongside ledger-based signals (ledger weighting is still
42
+ dominant — ledger signals indicate active-user-is-stuck behavior).
28
43
  """
29
44
  session_info = session_info or {}
30
45
  song_brain = song_brain or {}
46
+ state_signals = state_signals or {}
31
47
  signals: list[StucknessSignal] = []
32
48
 
33
49
  # 1. Repeated undos
@@ -60,6 +76,10 @@ def detect_stuckness(
60
76
  if identity_signal:
61
77
  signals.append(identity_signal)
62
78
 
79
+ # 7. BUG-B6 / B20 — state critics
80
+ state_derived = _state_signals_to_signal_list(state_signals)
81
+ signals.extend(state_derived)
82
+
63
83
  # Compute overall confidence
64
84
  if not signals:
65
85
  return StucknessReport(confidence=0.0, level="flowing")
@@ -98,6 +118,76 @@ def detect_stuckness(
98
118
  # ── Signal checkers ───────────────────────────────────────────────
99
119
 
100
120
 
121
+ def _state_signals_to_signal_list(state: dict) -> list[StucknessSignal]:
122
+ """Convert a state_signals dict (from sibling critics) into
123
+ StucknessSignal entries. BUG-B6 / B20: previously the detector
124
+ ignored session state entirely — so a session with fatigue_level=0.93
125
+ but an empty action ledger always reported "flowing". Now we surface
126
+ state-based stuckness but keep the signals at a slightly lower
127
+ weight than ledger signals (ledger = active-user-is-stuck behavior,
128
+ state = project-shape-is-stuck).
129
+ """
130
+ out: list[StucknessSignal] = []
131
+ if not state:
132
+ return out
133
+
134
+ # Fatigue / repetition
135
+ fatigue = state.get("fatigue_level")
136
+ if isinstance(fatigue, (int, float)) and fatigue >= 0.6:
137
+ out.append(StucknessSignal(
138
+ signal_type="state_repetition_fatigue",
139
+ # Scale from 0.6-1.0 → 0.5-0.85 (sub-ledger weight)
140
+ strength=min(0.85, 0.5 + (fatigue - 0.6) * 0.875),
141
+ evidence=(
142
+ f"repetition fatigue at {fatigue:.2f} — clips/sections "
143
+ f"overused"
144
+ ),
145
+ ))
146
+
147
+ motif_overuse = state.get("motif_overuse_count", 0)
148
+ if isinstance(motif_overuse, int) and motif_overuse >= 3:
149
+ out.append(StucknessSignal(
150
+ signal_type="state_motif_overuse",
151
+ strength=min(0.7, 0.3 + motif_overuse * 0.1),
152
+ evidence=f"{motif_overuse} motifs flagged as overused",
153
+ ))
154
+
155
+ arc_issues = state.get("emotional_arc_issues") or []
156
+ if isinstance(arc_issues, (list, tuple)) and arc_issues:
157
+ out.append(StucknessSignal(
158
+ signal_type="state_emotional_arc",
159
+ strength=min(0.7, 0.3 + len(arc_issues) * 0.1),
160
+ evidence=(
161
+ f"emotional-arc issues: {', '.join(str(i) for i in arc_issues[:3])}"
162
+ ),
163
+ ))
164
+
165
+ transition_issues = state.get("transition_issues", 0)
166
+ if isinstance(transition_issues, int) and transition_issues >= 3:
167
+ out.append(StucknessSignal(
168
+ signal_type="state_transition_issues",
169
+ strength=min(0.7, 0.25 + transition_issues * 0.08),
170
+ evidence=f"{transition_issues} transition issues detected",
171
+ ))
172
+
173
+ if state.get("support_too_loud"):
174
+ out.append(StucknessSignal(
175
+ signal_type="state_mix_imbalance",
176
+ strength=0.35,
177
+ evidence="mix critic flagged a support element as too loud",
178
+ ))
179
+
180
+ auto_density = state.get("automation_density")
181
+ if isinstance(auto_density, (int, float)) and auto_density <= 0.05:
182
+ out.append(StucknessSignal(
183
+ signal_type="state_no_automation",
184
+ strength=0.35,
185
+ evidence="no clip automation detected — arrangement is static",
186
+ ))
187
+
188
+ return out
189
+
190
+
101
191
  def _check_repeated_undos(history: list[dict]) -> Optional[StucknessSignal]:
102
192
  """Check for repeated undone moves (kept=False in ledger entries)."""
103
193
  recent = history[-20:] if len(history) > 20 else history
@@ -62,6 +62,43 @@ def _get_session_and_brain(ctx: Context) -> tuple[dict, dict, int]:
62
62
  return session_info, song_brain, section_count
63
63
 
64
64
 
65
+ def _gather_state_signals(ctx: Context, song_brain: dict) -> dict:
66
+ """BUG-B6 / B20: collect current-session-state stuckness indicators
67
+ that the detector can merge with ledger-based signals.
68
+
69
+ All lookups are best-effort — if a sibling module isn't available
70
+ or its data is stale, we omit the signal (don't guess).
71
+ """
72
+ signals: dict = {}
73
+
74
+ # Repetition fatigue from musical_intelligence.detectors
75
+ try:
76
+ from ..musical_intelligence.tools import _current_fatigue_cache # type: ignore
77
+ if isinstance(_current_fatigue_cache, dict):
78
+ fl = _current_fatigue_cache.get("fatigue_level")
79
+ if isinstance(fl, (int, float)):
80
+ signals["fatigue_level"] = float(fl)
81
+ overuse = _current_fatigue_cache.get("motif_overuse_count")
82
+ if isinstance(overuse, int):
83
+ signals["motif_overuse_count"] = overuse
84
+ except Exception as exc:
85
+ logger.debug("_gather_state_signals fatigue fetch failed: %s", exc)
86
+
87
+ # Emotional-arc issues directly from song brain (already fetched)
88
+ arc_issues = []
89
+ if isinstance(song_brain, dict):
90
+ oqs = song_brain.get("open_questions") or []
91
+ for q in oqs:
92
+ if isinstance(q, dict):
93
+ qtype = q.get("question_type") or q.get("type") or ""
94
+ if "arc" in str(qtype).lower() or "payoff" in str(qtype).lower():
95
+ arc_issues.append(qtype)
96
+ if arc_issues:
97
+ signals["emotional_arc_issues"] = arc_issues
98
+
99
+ return signals
100
+
101
+
65
102
  @mcp.tool()
66
103
  def detect_stuckness(ctx: Context) -> dict:
67
104
  """Detect whether the session is losing momentum.
@@ -79,12 +116,14 @@ def detect_stuckness(ctx: Context) -> dict:
79
116
  """
80
117
  history = _get_action_history(ctx)
81
118
  session_info, song_brain, section_count = _get_session_and_brain(ctx)
119
+ state_signals = _gather_state_signals(ctx, song_brain)
82
120
 
83
121
  report = detector.detect_stuckness(
84
122
  action_history=history,
85
123
  session_info=session_info,
86
124
  song_brain=song_brain,
87
125
  section_count=section_count,
126
+ state_signals=state_signals,
88
127
  )
89
128
 
90
129
  return report.to_dict()
@@ -110,12 +149,14 @@ def suggest_momentum_rescue(
110
149
 
111
150
  history = _get_action_history(ctx)
112
151
  session_info, song_brain, section_count = _get_session_and_brain(ctx)
152
+ state_signals = _gather_state_signals(ctx, song_brain)
113
153
 
114
154
  report = detector.detect_stuckness(
115
155
  action_history=history,
116
156
  session_info=session_info,
117
157
  song_brain=song_brain,
118
158
  section_count=section_count,
159
+ state_signals=state_signals,
119
160
  )
120
161
 
121
162
  if report.level == "flowing":
@@ -37,6 +37,30 @@ def run_sonic_critic(
37
37
  peak = sonic.get("peak")
38
38
  target_dims = set(goal.targets.keys())
39
39
 
40
+ # BUG-B42: if every spectrum band is zero AND rms is zero, playback
41
+ # is stopped (or nothing is routing to master). Spectrum-based
42
+ # critics (weak_foundation, harsh_highs, low_mid_congestion, etc.)
43
+ # would fire on zero data, reporting "no bass!" when the real cause
44
+ # is "no audio". Short-circuit to a playback_required advisory so
45
+ # callers don't chase phantom mix issues during static inspection.
46
+ _all_bands = all(float(bands.get(b, 0) or 0) == 0 for b in
47
+ ("sub", "low", "low_mid", "mid", "high_mid", "high",
48
+ "presence", "air"))
49
+ _silent = _all_bands and (rms is None or float(rms or 0) == 0)
50
+ if _silent:
51
+ return [Issue(
52
+ type="playback_required",
53
+ critic="sonic",
54
+ severity=0.1,
55
+ confidence=1.0,
56
+ affected_dimensions=list(MEASURABLE_PROXIES.keys()),
57
+ evidence=["spectrum and RMS both zero — playback stopped or no signal"],
58
+ recommended_actions=[
59
+ "Start playback before calling build_world_model / "
60
+ "analyze_mix so spectrum-based critics can evaluate.",
61
+ ],
62
+ )]
63
+
40
64
  # 1. Mud detection: low_mid congestion
41
65
  low_mid = bands.get("low_mid", 0)
42
66
  if low_mid > 0.7 and {"clarity", "weight", "warmth"} & target_dims:
@@ -44,7 +44,7 @@ from .gestures import (
44
44
  plan_gesture,
45
45
  resolve_gesture_template,
46
46
  )
47
- from .harmony import build_harmony_field
47
+ from .harmony import build_harmony_field, harmonic_score
48
48
  from .analysis import (
49
49
  COMPOSITION_DIMENSIONS,
50
50
  analyze_section_outcomes,
@@ -61,7 +61,7 @@ __all__ = [
61
61
  "run_form_critic", "run_section_identity_critic", "run_phrase_critic",
62
62
  "run_transition_critic", "run_emotional_arc_critic", "run_cross_section_critic",
63
63
  "GESTURE_TEMPLATES", "plan_gesture", "resolve_gesture_template",
64
- "build_harmony_field",
64
+ "build_harmony_field", "harmonic_score",
65
65
  "COMPOSITION_DIMENSIONS", "analyze_section_outcomes",
66
66
  "evaluate_composition_move", "build_composition_taste_model",
67
67
  ]
@@ -14,6 +14,96 @@ from typing import Any, Optional
14
14
 
15
15
  from .models import HarmonyField
16
16
 
17
+
18
+ # ── BUG-E3: harmonic-ness scoring ────────────────────────────────────────
19
+ # get_harmony_field used to take the FIRST track in section.tracks_active
20
+ # that had notes and lock in its key detection. When that track was
21
+ # percussion (all notes at a single pitch, staccato), detect_key would
22
+ # return a bogus "C major" for the whole section. The fix: score every
23
+ # track's notes for harmonic-ness, aggregate notes from tracks that pass
24
+ # a threshold, and run key detection on the combined pool.
25
+
26
+ _PERC_NAME_TOKENS = (
27
+ "kick", "snare", "clap", "hat", "hihat", "hi-hat", "hh", "drum",
28
+ "drums", "perc", "percussion", "rim", "crash", "ride", "tom",
29
+ "cymbal", "shaker", "tambourine", "cowbell", "808", "909",
30
+ "breakbeat", "break", "stick", "click",
31
+ )
32
+ _HARMONIC_NAME_TOKENS = (
33
+ "pad", "pads", "bass", "sub", "lead", "chord", "chords", "keys",
34
+ "synth", "piano", "rhodes", "organ", "lush", "string", "strings",
35
+ "brass", "pluck", "arp", "melody", "harmony", "voice", "choir",
36
+ )
37
+
38
+
39
+ def harmonic_score(notes: list[dict], track_name: str = "") -> float:
40
+ """Rate how likely a track's notes carry harmonic content (0.0 - 1.0).
41
+
42
+ Used by get_harmony_field to decide which tracks to include in
43
+ aggregate key detection. Percussion (single-pitch, staccato) scores
44
+ near 0. Sustained chordal/melodic material scores near 1.
45
+
46
+ Signals combined:
47
+ - unique pitch classes (chords vary, kicks don't)
48
+ - median note duration (sustain vs staccato)
49
+ - pitch range (melody moves, drums don't)
50
+ - minimum pitch (above the GM drum range)
51
+ - track-name hint tokens (soft nudge)
52
+
53
+ Returns a score in [0.0, 1.0]. Callers typically threshold at 0.3 or 0.4.
54
+ """
55
+ if not notes:
56
+ return 0.0
57
+
58
+ pitches = [int(n.get("pitch", 60)) for n in notes]
59
+ durations = [float(n.get("duration", 0.0)) for n in notes]
60
+ pcs = set(p % 12 for p in pitches)
61
+
62
+ # Use statistics.median for a more stable middle value. Falling back
63
+ # to a manual median keeps this file free of an extra import.
64
+ sorted_durs = sorted(durations)
65
+ median_dur = sorted_durs[len(sorted_durs) // 2] if sorted_durs else 0.0
66
+ unique_pcs = len(pcs)
67
+ pitch_range = max(pitches) - min(pitches) if pitches else 0
68
+ min_pitch = min(pitches) if pitches else 0
69
+
70
+ score = 0.0
71
+ # Unique pitch-class diversity: 3+ distinct pcs is a strong harmonic signal
72
+ if unique_pcs >= 4:
73
+ score += 0.45
74
+ elif unique_pcs >= 3:
75
+ score += 0.35
76
+ elif unique_pcs >= 2:
77
+ score += 0.15
78
+
79
+ # Duration: sustained notes carry harmony; staccato is rhythmic
80
+ if median_dur >= 1.0:
81
+ score += 0.30
82
+ elif median_dur >= 0.5:
83
+ score += 0.25
84
+ elif median_dur >= 0.25:
85
+ score += 0.10
86
+
87
+ # Pitch range: melody spans more than an octave often; drums don't
88
+ if pitch_range >= 12:
89
+ score += 0.20
90
+ elif pitch_range >= 5:
91
+ score += 0.10
92
+
93
+ # Minimum pitch out of the GM drum-map range (35–51) suggests melody
94
+ if min_pitch >= 48:
95
+ score += 0.10
96
+
97
+ # Track-name hints — mild nudges either way
98
+ name_lower = str(track_name or "").lower()
99
+ if any(tok in name_lower for tok in _PERC_NAME_TOKENS):
100
+ score -= 0.45
101
+ if any(tok in name_lower for tok in _HARMONIC_NAME_TOKENS):
102
+ score += 0.20
103
+
104
+ return max(0.0, min(1.0, score))
105
+
106
+
17
107
  def build_harmony_field(
18
108
  section_id: str,
19
109
  harmony_analysis: Optional[dict] = None,