livepilot 1.9.24 → 1.10.1

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 (185) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +223 -0
  4. package/CONTRIBUTING.md +2 -2
  5. package/LICENSE +62 -21
  6. package/README.md +291 -276
  7. package/bin/livepilot.js +87 -0
  8. package/installer/codex.js +147 -0
  9. package/livepilot/.Codex-plugin/plugin.json +2 -2
  10. package/livepilot/.claude-plugin/plugin.json +2 -2
  11. package/livepilot/skills/livepilot-arrangement/SKILL.md +18 -1
  12. package/livepilot/skills/livepilot-core/SKILL.md +22 -5
  13. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
  14. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
  15. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
  16. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
  17. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
  18. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
  19. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
  20. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
  21. package/livepilot/skills/livepilot-core/references/overview.md +13 -9
  22. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
  23. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
  24. package/livepilot/skills/livepilot-devices/SKILL.md +39 -4
  25. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  26. package/livepilot/skills/livepilot-release/SKILL.md +23 -19
  27. package/livepilot/skills/livepilot-sample-engine/SKILL.md +105 -0
  28. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
  29. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
  30. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
  31. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
  32. package/livepilot/skills/livepilot-wonder/SKILL.md +17 -0
  33. package/livepilot.mcpb +0 -0
  34. package/m4l_device/livepilot_bridge.js +1 -1
  35. package/manifest.json +4 -4
  36. package/mcp_server/__init__.py +1 -1
  37. package/mcp_server/atlas/__init__.py +357 -0
  38. package/mcp_server/atlas/device_atlas.json +44067 -0
  39. package/mcp_server/atlas/enrichments/__init__.py +111 -0
  40. package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
  41. package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
  42. package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
  43. package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
  44. package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
  45. package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
  46. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
  47. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
  48. package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
  49. package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
  50. package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
  51. package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
  52. package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
  53. package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
  54. package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
  55. package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
  56. package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
  57. package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
  58. package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
  59. package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
  60. package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
  61. package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
  62. package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
  63. package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
  64. package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
  65. package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
  66. package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
  67. package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
  68. package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
  69. package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
  70. package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
  71. package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
  72. package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
  73. package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
  74. package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
  75. package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
  76. package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
  77. package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
  78. package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
  79. package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
  80. package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
  81. package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
  82. package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
  83. package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
  84. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
  85. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
  86. package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
  87. package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
  88. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
  89. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
  90. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
  91. package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
  92. package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
  93. package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
  94. package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
  95. package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
  96. package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
  97. package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
  98. package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
  99. package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
  100. package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
  101. package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
  102. package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
  103. package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
  104. package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
  105. package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
  106. package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
  107. package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
  108. package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
  109. package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
  110. package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
  111. package/mcp_server/atlas/scanner.py +236 -0
  112. package/mcp_server/atlas/tools.py +224 -0
  113. package/mcp_server/composer/__init__.py +1 -0
  114. package/mcp_server/composer/engine.py +532 -0
  115. package/mcp_server/composer/layer_planner.py +427 -0
  116. package/mcp_server/composer/prompt_parser.py +329 -0
  117. package/mcp_server/composer/sample_resolver.py +153 -0
  118. package/mcp_server/composer/tools.py +211 -0
  119. package/mcp_server/connection.py +53 -8
  120. package/mcp_server/corpus/__init__.py +377 -0
  121. package/mcp_server/device_forge/__init__.py +1 -0
  122. package/mcp_server/device_forge/builder.py +377 -0
  123. package/mcp_server/device_forge/models.py +142 -0
  124. package/mcp_server/device_forge/templates.py +483 -0
  125. package/mcp_server/device_forge/tools.py +162 -0
  126. package/mcp_server/m4l_bridge.py +1 -0
  127. package/mcp_server/memory/taste_accessors.py +47 -0
  128. package/mcp_server/preview_studio/engine.py +9 -2
  129. package/mcp_server/preview_studio/tools.py +78 -35
  130. package/mcp_server/project_brain/tools.py +34 -0
  131. package/mcp_server/runtime/capability_probe.py +21 -2
  132. package/mcp_server/runtime/execution_router.py +184 -38
  133. package/mcp_server/runtime/live_version.py +102 -0
  134. package/mcp_server/runtime/mcp_dispatch.py +46 -0
  135. package/mcp_server/runtime/remote_commands.py +13 -5
  136. package/mcp_server/runtime/tools.py +66 -29
  137. package/mcp_server/sample_engine/__init__.py +1 -0
  138. package/mcp_server/sample_engine/analyzer.py +216 -0
  139. package/mcp_server/sample_engine/critics.py +390 -0
  140. package/mcp_server/sample_engine/models.py +193 -0
  141. package/mcp_server/sample_engine/moves.py +127 -0
  142. package/mcp_server/sample_engine/planner.py +186 -0
  143. package/mcp_server/sample_engine/slice_workflow.py +190 -0
  144. package/mcp_server/sample_engine/sources.py +540 -0
  145. package/mcp_server/sample_engine/techniques.py +908 -0
  146. package/mcp_server/sample_engine/tools.py +545 -0
  147. package/mcp_server/semantic_moves/__init__.py +3 -0
  148. package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
  149. package/mcp_server/semantic_moves/mix_moves.py +8 -8
  150. package/mcp_server/semantic_moves/models.py +7 -7
  151. package/mcp_server/semantic_moves/performance_moves.py +4 -4
  152. package/mcp_server/semantic_moves/sample_compilers.py +377 -0
  153. package/mcp_server/semantic_moves/sound_design_moves.py +4 -4
  154. package/mcp_server/semantic_moves/tools.py +63 -10
  155. package/mcp_server/semantic_moves/transition_moves.py +4 -4
  156. package/mcp_server/server.py +71 -1
  157. package/mcp_server/session_continuity/tracker.py +4 -1
  158. package/mcp_server/sound_design/critics.py +89 -1
  159. package/mcp_server/splice_client/__init__.py +1 -0
  160. package/mcp_server/splice_client/client.py +347 -0
  161. package/mcp_server/splice_client/models.py +96 -0
  162. package/mcp_server/splice_client/protos/__init__.py +1 -0
  163. package/mcp_server/splice_client/protos/app_pb2.py +319 -0
  164. package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
  165. package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
  166. package/mcp_server/tools/_conductor.py +16 -0
  167. package/mcp_server/tools/_planner_engine.py +24 -0
  168. package/mcp_server/tools/analyzer.py +2 -0
  169. package/mcp_server/tools/arrangement.py +69 -0
  170. package/mcp_server/tools/automation.py +15 -2
  171. package/mcp_server/tools/devices.py +117 -6
  172. package/mcp_server/tools/notes.py +37 -4
  173. package/mcp_server/tools/planner.py +3 -0
  174. package/mcp_server/wonder_mode/diagnosis.py +5 -0
  175. package/mcp_server/wonder_mode/engine.py +144 -14
  176. package/mcp_server/wonder_mode/tools.py +33 -1
  177. package/package.json +14 -4
  178. package/remote_script/LivePilot/__init__.py +8 -1
  179. package/remote_script/LivePilot/arrangement.py +114 -0
  180. package/remote_script/LivePilot/browser.py +56 -1
  181. package/remote_script/LivePilot/devices.py +246 -6
  182. package/remote_script/LivePilot/mixing.py +8 -3
  183. package/remote_script/LivePilot/server.py +5 -1
  184. package/remote_script/LivePilot/transport.py +3 -0
  185. package/remote_script/LivePilot/version_detect.py +78 -0
@@ -40,13 +40,60 @@ def _identify_port_holder(port: int) -> str | None:
40
40
  return None
41
41
 
42
42
 
43
+ def _master_has_livepilot_analyzer(ableton: AbletonConnection) -> bool:
44
+ """Check whether the analyzer device is currently on the master track."""
45
+ try:
46
+ track = ableton.send_command("get_master_track")
47
+ except Exception:
48
+ return False
49
+
50
+ devices = track.get("devices", []) if isinstance(track, dict) else []
51
+ for device in devices:
52
+ normalized = " ".join(
53
+ str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
54
+ )
55
+ if normalized == "livepilot analyzer":
56
+ return True
57
+ return False
58
+
59
+
60
+ async def _warm_analyzer_bridge(
61
+ ableton: AbletonConnection,
62
+ spectral: SpectralCache,
63
+ timeout: float = 3.0,
64
+ ) -> None:
65
+ """Give the analyzer stream a short startup window before first use."""
66
+ if not _master_has_livepilot_analyzer(ableton):
67
+ return
68
+
69
+ loop = asyncio.get_running_loop()
70
+ deadline = loop.time() + max(timeout, 0.0)
71
+ while loop.time() < deadline:
72
+ if spectral.is_connected:
73
+ return
74
+ await asyncio.sleep(0.05)
75
+
76
+
43
77
  @asynccontextmanager
44
78
  async def lifespan(server):
45
- """Create and yield the shared AbletonConnection + M4L bridge."""
79
+ """Create and yield the shared AbletonConnection + M4L bridge + registries."""
80
+ from .runtime.mcp_dispatch import build_mcp_dispatch_registry
81
+ from .splice_client.client import SpliceGRPCClient
82
+
46
83
  ableton = AbletonConnection()
47
84
  spectral = SpectralCache()
48
85
  receiver = SpectralReceiver(spectral)
49
86
  m4l = M4LBridge(spectral, receiver)
87
+ mcp_dispatch = build_mcp_dispatch_registry()
88
+
89
+ # Splice gRPC client — graceful degradation if Splice desktop isn't
90
+ # running or grpcio isn't installed. .connected will be False in that
91
+ # case and sample_resolver treats it as "no splice hits".
92
+ splice_client = SpliceGRPCClient()
93
+ try:
94
+ await splice_client.connect()
95
+ except Exception:
96
+ pass # client remains in disconnected state
50
97
 
51
98
  # Start UDP listener for incoming M4L spectral data (port 9880)
52
99
  loop = asyncio.get_running_loop()
@@ -80,17 +127,25 @@ async def lifespan(server):
80
127
  }
81
128
 
82
129
  try:
130
+ if bridge_state["transport"] is not None:
131
+ await _warm_analyzer_bridge(ableton, spectral)
83
132
  yield {
84
133
  "ableton": ableton,
85
134
  "spectral": spectral,
86
135
  "m4l": m4l,
87
136
  "_bridge_state": bridge_state,
137
+ "mcp_dispatch": mcp_dispatch,
138
+ "splice_client": splice_client,
88
139
  }
89
140
  finally:
90
141
  if bridge_state["transport"]:
91
142
  bridge_state["transport"].close()
92
143
  m4l.close()
93
144
  ableton.disconnect()
145
+ try:
146
+ await splice_client.disconnect()
147
+ except Exception:
148
+ pass
94
149
 
95
150
 
96
151
  mcp = FastMCP("LivePilot", lifespan=lifespan)
@@ -140,6 +195,10 @@ from .stuckness_detector import tools as stuckness_tools # noqa: F401, E40
140
195
  from .wonder_mode import tools as wonder_mode_tools # noqa: F401, E402
141
196
  from .session_continuity import tools as session_cont_tools # noqa: F401, E402
142
197
  from .creative_constraints import tools as constraints_tools # noqa: F401, E402
198
+ from .device_forge import tools as device_forge_tools # noqa: F401, E402
199
+ from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
200
+ from .atlas import tools as atlas_tools # noqa: F401, E402
201
+ from .composer import tools as composer_tools # noqa: F401, E402
143
202
 
144
203
 
145
204
  # ---------------------------------------------------------------------------
@@ -170,6 +229,14 @@ def _coerce_schema_property(prop: dict) -> None:
170
229
  # Recurse into array items so list[int]/list[float] params also accept strings
171
230
  if "items" in prop and isinstance(prop["items"], dict):
172
231
  _coerce_schema_property(prop["items"])
232
+ if "properties" in prop and isinstance(prop["properties"], dict):
233
+ for nested in prop["properties"].values():
234
+ if isinstance(nested, dict):
235
+ _coerce_schema_property(nested)
236
+ if "$defs" in prop and isinstance(prop["$defs"], dict):
237
+ for nested in prop["$defs"].values():
238
+ if isinstance(nested, dict):
239
+ _coerce_schema_property(nested)
173
240
 
174
241
 
175
242
  def _get_all_tools():
@@ -202,6 +269,9 @@ def _patch_tool_schemas() -> None:
202
269
  if name == "ctx":
203
270
  continue # skip the Context parameter
204
271
  _coerce_schema_property(prop)
272
+ for definition in tool.parameters.get("$defs", {}).values():
273
+ if isinstance(definition, dict):
274
+ _coerce_schema_property(definition)
205
275
 
206
276
 
207
277
  _patch_tool_schemas()
@@ -210,7 +210,10 @@ def rank_by_taste_and_identity(
210
210
  identity_effect = candidate.get("identity_effect", "preserves")
211
211
 
212
212
  # Taste score — how well does this fit cross-session preferences?
213
- boldness_pref = taste_graph.get("transition_boldness", 0.5)
213
+ # Routed through the canonical accessor so dimension_weights.transition_boldness
214
+ # is honored. Previously read the top-level key directly and always got 0.5.
215
+ from ..memory.taste_accessors import get_dimension_pref
216
+ boldness_pref = get_dimension_pref(taste_graph, "transition_boldness", default=0.5)
214
217
  taste_score = 1.0 - abs(novelty - boldness_pref) * 0.8
215
218
  taste_score = round(max(0.0, min(1.0, taste_score)), 3)
216
219
 
@@ -281,17 +281,105 @@ def run_layer_overlap_critic(
281
281
  return issues
282
282
 
283
283
 
284
+ # ── Corpus Intelligence Critic ──────────────────────────────────────
285
+
286
+
287
+ def run_corpus_critic(
288
+ patch: PatchModel,
289
+ goal: TimbralGoalVector,
290
+ ) -> list[SoundDesignIssue]:
291
+ """Use the device-knowledge corpus to flag missed opportunities.
292
+
293
+ Checks each device in the chain against the corpus for known
294
+ techniques, parameter sweet spots, and creative possibilities
295
+ that the current patch doesn't exploit.
296
+ """
297
+ try:
298
+ from ..corpus import get_corpus
299
+ except ImportError:
300
+ return []
301
+
302
+ corpus = get_corpus()
303
+ if not corpus.emotional_recipes and not corpus.device_knowledge:
304
+ return [] # Corpus not loaded
305
+
306
+ issues: list[SoundDesignIssue] = []
307
+
308
+ # Check if any device in the chain has corpus knowledge
309
+ for block in patch.blocks:
310
+ dk = corpus.get_device(block.device_name)
311
+ if dk and dk.techniques and block.block_type == "oscillator":
312
+ # Oscillator with known techniques — suggest if patch is simple
313
+ has_character_block = any(
314
+ b.block_type in ("saturation", "spectral")
315
+ for b in patch.blocks
316
+ )
317
+ if not has_character_block and len(dk.techniques) > 2:
318
+ issues.append(SoundDesignIssue(
319
+ issue_type="corpus_technique_available",
320
+ critic="corpus",
321
+ severity=0.25,
322
+ confidence=0.6,
323
+ affected_blocks=[block.device_name],
324
+ evidence=(
325
+ f"Corpus has {len(dk.techniques)} known techniques "
326
+ f"for {block.device_name} but chain lacks character "
327
+ f"processing (saturation/spectral). First technique: "
328
+ f"{dk.techniques[0][:80]}"
329
+ ),
330
+ recommended_moves=["modulation_injection", "filter_contour"],
331
+ ))
332
+
333
+ # Check if goal maps to a known emotional recipe
334
+ emotion_map = {
335
+ "warmth": ("warmth", goal.warmth),
336
+ "brightness": ("euphoria", goal.brightness),
337
+ "instability": ("tension", goal.instability),
338
+ "softness": ("melancholy", goal.softness),
339
+ }
340
+ for quality, (emotion_key, goal_value) in emotion_map.items():
341
+ if goal_value > 0.3:
342
+ recipe = corpus.suggest_for_emotion(emotion_key)
343
+ if recipe and recipe.techniques:
344
+ # Check if any corpus technique device is in the chain
345
+ chain_names_lower = {d.lower() for d in patch.device_chain}
346
+ recipe_devices = set()
347
+ for tech in recipe.techniques:
348
+ # Extract bold device names from technique strings
349
+ for match in re.finditer(r"\*\*(.+?)\*\*", tech):
350
+ recipe_devices.add(match.group(1).lower())
351
+
352
+ missing = recipe_devices - chain_names_lower
353
+ if missing and len(missing) <= 3:
354
+ issues.append(SoundDesignIssue(
355
+ issue_type="corpus_emotion_opportunity",
356
+ critic="corpus",
357
+ severity=0.2,
358
+ confidence=0.5,
359
+ affected_blocks=list(missing)[:3],
360
+ evidence=(
361
+ f"Goal wants {quality}={goal_value:.2f}. "
362
+ f"Corpus '{recipe.emotion}' recipe suggests "
363
+ f"devices not in chain: {', '.join(list(missing)[:3])}"
364
+ ),
365
+ recommended_moves=["filter_contour", "modulation_injection"],
366
+ ))
367
+
368
+ return issues
369
+
370
+
284
371
  # ── Run all critics ──────────────────────────────────────────────────
285
372
 
286
373
 
287
374
  def run_all_sound_design_critics(
288
375
  state: SoundDesignState,
289
376
  ) -> list[SoundDesignIssue]:
290
- """Run all five critics and aggregate issues."""
377
+ """Run all six critics and aggregate issues."""
291
378
  issues: list[SoundDesignIssue] = []
292
379
  issues.extend(run_static_timbre_critic(state.patch, state.goal))
293
380
  issues.extend(run_weak_identity_critic(state.patch))
294
381
  issues.extend(run_masking_role_critic(state.patch, state.layers))
295
382
  issues.extend(run_modulation_flatness_critic(state.patch))
296
383
  issues.extend(run_layer_overlap_critic(state.layers))
384
+ issues.extend(run_corpus_critic(state.patch, state.goal))
297
385
  return issues
@@ -0,0 +1 @@
1
+ """Splice gRPC client — connect to Splice desktop's local API for sample search and download."""
@@ -0,0 +1,347 @@
1
+ """SpliceGRPCClient — connect to Splice desktop's local gRPC API.
2
+
3
+ Splice runs a gRPC server (Go binary) on localhost with TLS.
4
+ Port is dynamic (read from port.conf). Certs are self-signed.
5
+
6
+ This client provides: search, download, sample info, credit check.
7
+ All methods are async. Graceful degradation when Splice is not running.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import glob
14
+ import logging
15
+ import os
16
+ from typing import Optional
17
+
18
+ from .models import SpliceCredits, SpliceSample, SpliceSearchResult
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Splice app support directory
23
+ _SPLICE_APP_SUPPORT = os.path.expanduser(
24
+ "~/Library/Application Support/com.splice.Splice"
25
+ )
26
+
27
+ # Credit safety floor — never drain below this
28
+ CREDIT_HARD_FLOOR = 5
29
+
30
+
31
+ def _try_import_grpc():
32
+ """Import grpcio lazily — graceful degradation if not installed."""
33
+ try:
34
+ import grpc
35
+ return grpc
36
+ except ImportError:
37
+ return None
38
+
39
+
40
+ def _try_import_protos():
41
+ """Import generated protobuf stubs lazily."""
42
+ try:
43
+ from .protos import app_pb2, app_pb2_grpc
44
+ return app_pb2, app_pb2_grpc
45
+ except ImportError:
46
+ return None, None
47
+
48
+
49
+ class SpliceGRPCClient:
50
+ """Async gRPC client for Splice desktop's App service."""
51
+
52
+ def __init__(self):
53
+ self.channel = None
54
+ self.stub = None
55
+ self.connected = False
56
+ self._port: Optional[int] = None
57
+ self._grpc = _try_import_grpc()
58
+ self._pb2, self._pb2_grpc = _try_import_protos()
59
+
60
+ @property
61
+ def available(self) -> bool:
62
+ """True if grpcio is installed and Splice app support exists."""
63
+ return (
64
+ self._grpc is not None
65
+ and self._pb2 is not None
66
+ and os.path.isdir(_SPLICE_APP_SUPPORT)
67
+ )
68
+
69
+ async def connect(self) -> bool:
70
+ """Connect to Splice's local gRPC server. Returns True on success."""
71
+ if not self.available:
72
+ logger.info("Splice gRPC not available (grpcio missing or Splice not installed)")
73
+ return False
74
+
75
+ port = self._read_port()
76
+ if not port:
77
+ logger.info("Cannot read Splice port from port.conf")
78
+ return False
79
+
80
+ cert_pem = self._read_cert()
81
+ if not cert_pem:
82
+ logger.info("Cannot read Splice TLS certificate")
83
+ return False
84
+
85
+ try:
86
+ grpc = self._grpc
87
+ credentials = grpc.ssl_channel_credentials(root_certificates=cert_pem)
88
+ self.channel = grpc.aio.secure_channel(
89
+ f"127.0.0.1:{port}", credentials
90
+ )
91
+ self.stub = self._pb2_grpc.AppStub(self.channel)
92
+ self._port = port
93
+ self.connected = True
94
+ logger.info(f"Connected to Splice gRPC on port {port}")
95
+ return True
96
+ except Exception as exc:
97
+ logger.warning(f"Failed to connect to Splice: {exc}")
98
+ self.connected = False
99
+ return False
100
+
101
+ async def disconnect(self):
102
+ """Close the gRPC channel."""
103
+ if self.channel:
104
+ await self.channel.close()
105
+ self.channel = None
106
+ self.stub = None
107
+ self.connected = False
108
+
109
+ # ── Search ──────────────────────────────────────────────────────
110
+
111
+ async def search_samples(
112
+ self,
113
+ query: str = "",
114
+ key: str = "",
115
+ chord_type: str = "",
116
+ bpm_min: int = 0,
117
+ bpm_max: int = 0,
118
+ tags: Optional[list[str]] = None,
119
+ genre: str = "",
120
+ sample_type: str = "",
121
+ sort: str = "",
122
+ per_page: int = 20,
123
+ page: int = 1,
124
+ purchased_only: bool = False,
125
+ ) -> SpliceSearchResult:
126
+ """Search Splice catalog. Returns ranked results with full metadata."""
127
+ if not self.connected:
128
+ return SpliceSearchResult()
129
+
130
+ pb2 = self._pb2
131
+ try:
132
+ # Build search request
133
+ purchased = 0 # All
134
+ if purchased_only:
135
+ purchased = 1 # OnlyPurchased
136
+
137
+ request = pb2.SearchSampleRequest(
138
+ SearchTerm=query,
139
+ Key=key.lower() if key else "",
140
+ ChordType=chord_type,
141
+ BPMMin=bpm_min,
142
+ BPMMax=bpm_max,
143
+ Tags=tags or [],
144
+ Genre=genre,
145
+ SampleType=sample_type,
146
+ SortFn=sort,
147
+ PerPage=per_page,
148
+ Page=page,
149
+ Purchased=purchased,
150
+ )
151
+ response = await self.stub.SearchSamples(request)
152
+ return self._parse_search_response(response)
153
+ except Exception as exc:
154
+ logger.warning(f"Splice search failed: {exc}")
155
+ return SpliceSearchResult()
156
+
157
+ def _parse_search_response(self, response) -> SpliceSearchResult:
158
+ """Convert protobuf SearchSampleResponse to our models."""
159
+ samples = []
160
+ for s in response.Samples:
161
+ samples.append(SpliceSample(
162
+ file_hash=s.FileHash,
163
+ filename=s.Filename,
164
+ local_path=s.LocalPath,
165
+ audio_key=s.AudioKey,
166
+ chord_type=s.ChordType,
167
+ bpm=s.BPM,
168
+ duration_ms=s.Duration,
169
+ genre=s.Genre,
170
+ sample_type=s.SampleType,
171
+ tags=list(s.Tags),
172
+ provider_name=s.ProviderName,
173
+ pack_uuid=s.PackUUID,
174
+ popularity=s.Popularity,
175
+ is_premium=s.IsPremium,
176
+ preview_url=s.PreviewURL,
177
+ waveform_url=s.WaveformURL,
178
+ is_downloaded=bool(s.LocalPath),
179
+ ))
180
+ return SpliceSearchResult(
181
+ total_hits=response.TotalHits,
182
+ samples=samples,
183
+ matching_tags=dict(response.MatchingTags),
184
+ )
185
+
186
+ # ── Download ────────────────────────────────────────────────────
187
+
188
+ async def download_sample(
189
+ self, file_hash: str, timeout: float = 30.0,
190
+ ) -> Optional[str]:
191
+ """Download a sample by file_hash. Returns local path when complete.
192
+
193
+ Costs 1 credit. Checks credit floor before downloading.
194
+ Returns None on failure.
195
+ """
196
+ if not self.connected:
197
+ return None
198
+
199
+ pb2 = self._pb2
200
+ try:
201
+ # Trigger download
202
+ await self.stub.DownloadSample(
203
+ pb2.DownloadSampleRequest(FileHash=file_hash)
204
+ )
205
+ # Wait for file to appear on disk
206
+ return await self._wait_for_download(file_hash, timeout)
207
+ except Exception as exc:
208
+ logger.warning(f"Splice download failed for {file_hash}: {exc}")
209
+ return None
210
+
211
+ async def _wait_for_download(
212
+ self, file_hash: str, timeout: float,
213
+ ) -> Optional[str]:
214
+ """Poll SampleInfo until LocalPath is populated."""
215
+ pb2 = self._pb2
216
+ deadline = asyncio.get_event_loop().time() + timeout
217
+ while asyncio.get_event_loop().time() < deadline:
218
+ try:
219
+ response = await self.stub.SampleInfo(
220
+ pb2.SampleInfoRequest(FileHash=file_hash)
221
+ )
222
+ if response.Sample.LocalPath:
223
+ return response.Sample.LocalPath
224
+ except Exception:
225
+ pass
226
+ await asyncio.sleep(0.5)
227
+ logger.warning(f"Download timed out for {file_hash}")
228
+ return None
229
+
230
+ # ── Sample Info ─────────────────────────────────────────────────
231
+
232
+ async def get_sample_info(self, file_hash: str) -> Optional[SpliceSample]:
233
+ """Get metadata for a specific sample."""
234
+ if not self.connected:
235
+ return None
236
+
237
+ pb2 = self._pb2
238
+ try:
239
+ response = await self.stub.SampleInfo(
240
+ pb2.SampleInfoRequest(FileHash=file_hash)
241
+ )
242
+ s = response.Sample
243
+ return SpliceSample(
244
+ file_hash=s.FileHash,
245
+ filename=s.Filename,
246
+ local_path=s.LocalPath,
247
+ audio_key=s.AudioKey,
248
+ chord_type=s.ChordType,
249
+ bpm=s.BPM,
250
+ duration_ms=s.Duration,
251
+ genre=s.Genre,
252
+ sample_type=s.SampleType,
253
+ tags=list(s.Tags),
254
+ provider_name=s.ProviderName,
255
+ pack_uuid=s.PackUUID,
256
+ is_downloaded=bool(s.LocalPath),
257
+ )
258
+ except Exception as exc:
259
+ logger.warning(f"SampleInfo failed: {exc}")
260
+ return None
261
+
262
+ # ── Credits ─────────────────────────────────────────────────────
263
+
264
+ async def get_credits(self) -> SpliceCredits:
265
+ """Get current credit balance and user info."""
266
+ if not self.connected:
267
+ return SpliceCredits()
268
+
269
+ pb2 = self._pb2
270
+ try:
271
+ response = await self.stub.ValidateLogin(
272
+ pb2.ValidateLoginRequest()
273
+ )
274
+ return SpliceCredits(
275
+ credits=response.User.Credits,
276
+ username=response.User.Username,
277
+ plan=response.User.SoundsStatus,
278
+ )
279
+ except Exception as exc:
280
+ logger.warning(f"Credit check failed: {exc}")
281
+ return SpliceCredits()
282
+
283
+ async def can_afford(self, credits_needed: int, budget: int) -> tuple[bool, int]:
284
+ """Check if we can afford credits_needed within budget.
285
+
286
+ Returns (can_afford, credits_remaining).
287
+ """
288
+ info = await self.get_credits()
289
+ remaining = info.credits
290
+ can = (
291
+ remaining > CREDIT_HARD_FLOOR
292
+ and credits_needed <= budget
293
+ and credits_needed <= (remaining - CREDIT_HARD_FLOOR)
294
+ )
295
+ return can, remaining
296
+
297
+ # ── Sync ────────────────────────────────────────────────────────
298
+
299
+ async def sync_sounds(self) -> bool:
300
+ """Trigger a full Splice library sync."""
301
+ if not self.connected:
302
+ return False
303
+ pb2 = self._pb2
304
+ try:
305
+ await self.stub.SyncSounds(pb2.SyncSoundsRequest())
306
+ return True
307
+ except Exception:
308
+ return False
309
+
310
+ # ── Connection Helpers ──────────────────────────────────────────
311
+
312
+ def _read_port(self) -> Optional[int]:
313
+ """Read Splice's current gRPC port from port.conf."""
314
+ port_file = os.path.join(_SPLICE_APP_SUPPORT, "port.conf")
315
+ if not os.path.isfile(port_file):
316
+ return None
317
+ try:
318
+ with open(port_file) as f:
319
+ content = f.read().strip()
320
+ # Format: "127.0.0.1:56765" or just "56765"
321
+ if ":" in content:
322
+ return int(content.split(":")[-1])
323
+ return int(content)
324
+ except (ValueError, OSError):
325
+ return None
326
+
327
+ def _read_cert(self) -> Optional[bytes]:
328
+ """Read Splice's self-signed TLS certificate."""
329
+ # Search in user-specific directories
330
+ patterns = [
331
+ os.path.join(_SPLICE_APP_SUPPORT, ".certs", "cert.pem"),
332
+ os.path.join(_SPLICE_APP_SUPPORT, "certs", "cert.pem"),
333
+ ]
334
+ # Also try user-specific paths
335
+ user_patterns = glob.glob(
336
+ os.path.join(_SPLICE_APP_SUPPORT, "users", "*", ".certs", "cert.pem")
337
+ )
338
+ patterns.extend(user_patterns)
339
+
340
+ for path in patterns:
341
+ if os.path.isfile(path):
342
+ try:
343
+ with open(path, "rb") as f:
344
+ return f.read()
345
+ except OSError:
346
+ continue
347
+ return None
@@ -0,0 +1,96 @@
1
+ """Splice client data models — Python representations of Splice gRPC messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass
10
+ class SpliceSample:
11
+ """A sample from the Splice catalog or local library."""
12
+
13
+ file_hash: str = ""
14
+ filename: str = ""
15
+ local_path: str = "" # empty if not downloaded
16
+ audio_key: str = "" # lowercase: "c#", "a", "eb"
17
+ chord_type: str = "" # "major", "minor", ""
18
+ bpm: int = 0
19
+ duration_ms: int = 0
20
+ genre: str = ""
21
+ sample_type: str = "" # "loop" or "oneshot"
22
+ tags: list[str] = field(default_factory=list)
23
+ provider_name: str = ""
24
+ pack_uuid: str = ""
25
+ popularity: int = 0
26
+ is_premium: bool = False
27
+ preview_url: str = ""
28
+ waveform_url: str = ""
29
+ is_downloaded: bool = False
30
+
31
+ @property
32
+ def key_display(self) -> str:
33
+ """Normalized key: 'c#' + 'minor' → 'C#m'."""
34
+ if not self.audio_key:
35
+ return ""
36
+ key = self.audio_key[0].upper() + self.audio_key[1:]
37
+ if self.chord_type.lower() in ("minor", "min"):
38
+ key += "m"
39
+ return key
40
+
41
+ @property
42
+ def duration_seconds(self) -> float:
43
+ return self.duration_ms / 1000.0 if self.duration_ms else 0.0
44
+
45
+ def to_dict(self) -> dict:
46
+ return {
47
+ "file_hash": self.file_hash,
48
+ "filename": self.filename,
49
+ "local_path": self.local_path,
50
+ "key": self.key_display,
51
+ "audio_key_raw": self.audio_key,
52
+ "chord_type": self.chord_type,
53
+ "bpm": self.bpm,
54
+ "duration": self.duration_seconds,
55
+ "genre": self.genre,
56
+ "sample_type": self.sample_type,
57
+ "tags": self.tags,
58
+ "provider": self.provider_name,
59
+ "pack_uuid": self.pack_uuid,
60
+ "popularity": self.popularity,
61
+ "is_downloaded": self.is_downloaded,
62
+ "is_premium": self.is_premium,
63
+ }
64
+
65
+
66
+ @dataclass
67
+ class SpliceSearchResult:
68
+ """Result from a Splice catalog search."""
69
+
70
+ total_hits: int = 0
71
+ samples: list[SpliceSample] = field(default_factory=list)
72
+ matching_tags: dict[str, int] = field(default_factory=dict)
73
+
74
+ def to_dict(self) -> dict:
75
+ return {
76
+ "total_hits": self.total_hits,
77
+ "sample_count": len(self.samples),
78
+ "samples": [s.to_dict() for s in self.samples],
79
+ "matching_tags": self.matching_tags,
80
+ }
81
+
82
+
83
+ @dataclass
84
+ class SpliceCredits:
85
+ """User credit status."""
86
+
87
+ credits: int = 0
88
+ username: str = ""
89
+ plan: str = ""
90
+
91
+ def to_dict(self) -> dict:
92
+ return {
93
+ "credits": self.credits,
94
+ "username": self.username,
95
+ "plan": self.plan,
96
+ }
@@ -0,0 +1 @@
1
+ """Generated protobuf stubs for Splice gRPC API."""