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
@@ -0,0 +1,46 @@
1
+ """Registry of in-process MCP tools callable from the async execution router.
2
+
3
+ These tools live as Python async functions in the MCP server — not TCP Remote
4
+ Script handlers and not M4L bridge commands. Plans that want to invoke them
5
+ go through this registry so the async router can dispatch them in-process.
6
+
7
+ Each entry is a thin wrapper around the real MCP tool import, keeping the
8
+ module cheap to import (no heavy server wiring until a caller actually
9
+ dispatches an MCP step).
10
+
11
+ To add a new in-process tool to plans:
12
+ 1. Add the tool name to MCP_TOOLS in execution_router.py so classify_step
13
+ returns "mcp_tool" for it.
14
+ 2. Add an _adapter function here that imports the real implementation and
15
+ adapts its kwargs from a plan-style params dict.
16
+ 3. Register the adapter in build_mcp_dispatch_registry.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Callable
22
+
23
+
24
+ async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
25
+ """Adapter for mcp_server.tools.analyzer.load_sample_to_simpler.
26
+
27
+ Accepts the plan-step params dict and unpacks into the real tool's kwargs.
28
+ """
29
+ from ..tools.analyzer import load_sample_to_simpler
30
+ return await load_sample_to_simpler(
31
+ ctx,
32
+ track_index=int(params["track_index"]),
33
+ file_path=str(params["file_path"]),
34
+ device_index=int(params.get("device_index", 0)),
35
+ )
36
+
37
+
38
+ def build_mcp_dispatch_registry() -> dict[str, Callable]:
39
+ """Return the canonical registry of MCP-only tools for plan execution.
40
+
41
+ Callers (typically the server lifespan init) should call this once and
42
+ pass the registry to execute_plan_steps_async via the mcp_registry kwarg.
43
+ """
44
+ return {
45
+ "load_sample_to_simpler": _load_sample_to_simpler,
46
+ }
@@ -38,18 +38,23 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
38
38
  "fire_scene", "set_scene_name", "set_scene_color", "set_scene_tempo",
39
39
  "get_scene_matrix", "fire_scene_clips", "stop_all_clips",
40
40
  "get_playing_clips",
41
- # devices (12)
41
+ # devices (15)
42
42
  "get_device_info", "get_device_parameters", "set_device_parameter",
43
43
  "batch_set_parameters", "toggle_device", "delete_device",
44
44
  "move_device", "load_device_by_uri", "find_and_load_device",
45
45
  "set_simpler_playback_mode", "get_rack_chains", "set_chain_volume",
46
+ "insert_device", # 12.3+ native device insertion
47
+ "insert_rack_chain", # 12.3+ rack chain insertion
48
+ "set_drum_chain_note", # 12.3+ drum chain note assignment
46
49
  # clip_automation (3)
47
50
  "get_clip_automation", "set_clip_automation", "clear_clip_automation",
48
- # browser (5)
51
+ # browser (6)
49
52
  "get_browser_tree", "get_browser_items", "search_browser",
50
53
  "load_browser_item", "get_device_presets",
51
- # arrangement (19)
54
+ "scan_browser_deep", # Atlas deep scan — returns full category tree
55
+ # arrangement (21)
52
56
  "get_arrangement_clips", "create_arrangement_clip",
57
+ "create_native_arrangement_clip",
53
58
  "add_arrangement_notes", "get_arrangement_notes",
54
59
  "remove_arrangement_notes", "remove_arrangement_notes_by_id",
55
60
  "modify_arrangement_notes", "duplicate_arrangement_notes",
@@ -57,7 +62,7 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
57
62
  "set_arrangement_clip_name", "jump_to_time",
58
63
  "capture_midi", "start_recording", "stop_recording",
59
64
  "get_cue_points", "jump_to_cue", "toggle_cue_point",
60
- "back_to_arranger",
65
+ "back_to_arranger", "force_arrangement",
61
66
  # diagnostics (1)
62
67
  "get_session_diagnostics",
63
68
  # ping (built-in)
@@ -75,7 +80,10 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
75
80
  "remove_warp_marker", "capture_audio", "capture_stop",
76
81
  "check_flucoma", "scrub_clip", "stop_scrub", "get_display_values",
77
82
  "get_plugin_params", "map_plugin_param", "get_plugin_presets",
78
- "load_sample_to_simpler",
83
+ # NOTE: load_sample_to_simpler used to live here, but it's actually an
84
+ # async Python MCP tool in mcp_server/tools/analyzer.py, not a bridge
85
+ # command. It has no case in livepilot_bridge.js and no @register handler
86
+ # in remote_script. See mcp_server/runtime/execution_router.MCP_TOOLS.
79
87
  })
80
88
 
81
89
  # Combined: all valid send_command targets
@@ -94,52 +94,75 @@ def get_session_kernel(
94
94
 
95
95
  # Core: session info + capability state
96
96
  session_info = ableton.send_command("get_session_info")
97
+ session_ok = isinstance(session_info, dict) and "error" not in session_info
97
98
 
98
99
  analyzer_ok = False
100
+ analyzer_fresh = False
99
101
  if spectral is not None:
100
102
  analyzer_ok = spectral.is_connected
103
+ if analyzer_ok:
104
+ analyzer_fresh = spectral.get("spectrum") is not None
101
105
 
102
106
  state = build_capability_state(
103
- session_ok=True,
107
+ session_ok=session_ok,
104
108
  analyzer_ok=analyzer_ok,
109
+ analyzer_fresh=analyzer_fresh,
105
110
  memory_ok=True,
106
111
  )
107
112
 
108
- # Optional subcomponents — degrade gracefully
109
- ledger_summary = {}
110
- taste_graph = {}
111
- anti_prefs = []
112
- session_mem = []
113
+ # Optional subcomponents — degrade gracefully, but reach into the SAME
114
+ # session-scoped stores the public memory tools read/write via
115
+ # ctx.lifespan_context.setdefault(...). Creating fresh stores here meant
116
+ # users who recorded anti-preferences, session memory, or taste signals
117
+ # through the MCP tools always saw an empty kernel.
118
+ ledger_summary: dict = {}
119
+ taste_graph: dict = {}
120
+ anti_prefs: list = []
121
+ session_mem: list = []
122
+ kernel_warnings: list[str] = []
113
123
 
114
124
  try:
115
- from .action_ledger import ActionLedger
116
- ledger = ActionLedger.instance()
117
- if ledger:
118
- ledger_summary = ledger.summary()
119
- except Exception:
120
- pass
121
-
125
+ from .action_ledger import SessionLedger
126
+ ledger = ctx.lifespan_context.get("action_ledger")
127
+ if ledger is None:
128
+ ledger = SessionLedger()
129
+ ctx.lifespan_context["action_ledger"] = ledger
130
+ recent = ledger.get_recent_moves(limit=10)
131
+ ledger_summary = {
132
+ "total_moves": len(ledger._entries),
133
+ "memory_candidate_count": len(ledger.get_memory_candidates()),
134
+ "last_move": ledger.get_last_move().to_dict() if ledger.get_last_move() else None,
135
+ "recent_moves": [entry.to_dict() for entry in recent],
136
+ }
137
+ except Exception as e:
138
+ kernel_warnings.append(f"ledger_unavailable: {e}")
139
+
140
+ # Taste graph + anti-prefs — share stores via lifespan_context, use the
141
+ # canonical build_taste_graph() so consumers see dimension_weights shape.
122
142
  try:
143
+ from ..memory.taste_graph import build_taste_graph
123
144
  from ..memory.taste_memory import TasteMemoryStore
124
- taste_store = TasteMemoryStore()
125
- taste_graph = {d.name: d.to_dict() for d in taste_store._dims.values()
126
- if d.evidence_count > 0}
127
- except Exception:
128
- pass
129
-
130
- try:
131
145
  from ..memory.anti_memory import AntiMemoryStore
132
- anti_store = AntiMemoryStore()
133
- anti_prefs = anti_store.list_all()
134
- except Exception:
135
- pass
146
+ from ..persistence.taste_store import PersistentTasteStore
147
+ taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
148
+ anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
149
+ persistent = ctx.lifespan_context.setdefault("persistent_taste", PersistentTasteStore())
150
+ graph = build_taste_graph(
151
+ taste_store=taste_store,
152
+ anti_store=anti_store,
153
+ persistent_store=persistent,
154
+ )
155
+ taste_graph = graph.to_dict()
156
+ anti_prefs = [p.to_dict() for p in anti_store.get_anti_preferences()]
157
+ except Exception as e:
158
+ kernel_warnings.append(f"taste_graph_unavailable: {e}")
136
159
 
137
160
  try:
138
161
  from ..memory.session_memory import SessionMemoryStore
139
- mem_store = SessionMemoryStore()
140
- session_mem = mem_store.recent(limit=10)
141
- except Exception:
142
- pass
162
+ mem_store = ctx.lifespan_context.setdefault("session_memory", SessionMemoryStore())
163
+ session_mem = [entry.to_dict() for entry in mem_store.get_recent(limit=10)]
164
+ except Exception as e:
165
+ kernel_warnings.append(f"session_memory_unavailable: {e}")
143
166
 
144
167
  kernel = build_session_kernel(
145
168
  session_info=session_info,
@@ -153,4 +176,18 @@ def get_session_kernel(
153
176
  anti_preferences=anti_prefs,
154
177
  )
155
178
 
156
- return kernel.to_dict()
179
+ # Populate routing hints from conductor when request context is available
180
+ if request_text.strip():
181
+ try:
182
+ from ..tools._conductor import classify_request
183
+ plan = classify_request(request_text)
184
+ kernel.recommended_engines = [r.engine for r in plan.routes[:3]]
185
+ kernel.recommended_workflow = plan.workflow_mode
186
+ except Exception as e:
187
+ kernel_warnings.append(f"conductor_routing_unavailable: {e}")
188
+
189
+ result_dict = kernel.to_dict()
190
+ if kernel_warnings:
191
+ # Additive — callers can ignore; debug-mode introspection benefits.
192
+ result_dict["warnings"] = kernel_warnings
193
+ return result_dict
@@ -0,0 +1 @@
1
+ """Sample Engine — intelligence layer for sample discovery, analysis, and manipulation."""
@@ -0,0 +1,216 @@
1
+ """SampleAnalyzer — filename parsing, material classification, mode recommendation.
2
+
3
+ Pure computation for the offline parts. Spectral analysis requires M4L bridge
4
+ and is handled in tools.py which calls these functions + bridge data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from typing import Optional
12
+
13
+ from .models import SampleProfile
14
+
15
+
16
+ # ── Filename Metadata Parsing ───────────────────────────────────────
17
+
18
+ # Key patterns: C, Cm, C#, C#m, Cb, Cbm, Csharp, Csharpmin, etc.
19
+ _KEY_PATTERN = re.compile(
20
+ r'\b([A-G])([b#]|sharp|flat)?(m|min|minor|maj|major)?\b',
21
+ re.IGNORECASE,
22
+ )
23
+
24
+ # BPM patterns: 120bpm, 120_bpm, 120 BPM, or standalone 60-300 range
25
+ _BPM_PATTERN = re.compile(
26
+ r'\b(\d{2,3})\s*(?:bpm)\b', re.IGNORECASE,
27
+ )
28
+ _BPM_STANDALONE = re.compile(
29
+ r'(?:^|[_\-\s])(\d{2,3})(?:[_\-\s]|$)',
30
+ )
31
+
32
+ _KEY_NORMALIZE = {
33
+ "sharp": "#", "flat": "b",
34
+ "min": "m", "minor": "m", "maj": "", "major": "",
35
+ }
36
+
37
+
38
+ def parse_filename_metadata(filename: str) -> dict:
39
+ """Extract key and BPM from a filename string.
40
+
41
+ Returns dict with 'key' (str|None) and 'bpm' (float|None).
42
+ """
43
+ stem = os.path.splitext(os.path.basename(filename))[0]
44
+ # Replace common separators with spaces for easier matching
45
+ normalized = stem.replace("-", " ").replace("_", " ")
46
+
47
+ key = _extract_key(normalized)
48
+ bpm = _extract_bpm(normalized)
49
+
50
+ return {"key": key, "bpm": bpm}
51
+
52
+
53
+ def _extract_key(text: str) -> Optional[str]:
54
+ """Extract musical key from text."""
55
+ matches = list(_KEY_PATTERN.finditer(text))
56
+ for match in matches:
57
+ root = match.group(1).upper()
58
+ accidental = match.group(2) or ""
59
+ quality = match.group(3) or ""
60
+
61
+ # Normalize accidentals
62
+ accidental = _KEY_NORMALIZE.get(accidental.lower(), accidental)
63
+ quality = _KEY_NORMALIZE.get(quality.lower(), quality) if quality else ""
64
+
65
+ # Avoid false positives: single letters that are common words
66
+ full = root + accidental + quality
67
+ if len(full) == 1 and root in ("A", "B", "C", "D", "E", "F", "G"):
68
+ # Single letter — only accept if it looks like it's in a key context
69
+ # Check surrounding chars
70
+ start = match.start()
71
+ end = match.end()
72
+ before = text[start - 1] if start > 0 else " "
73
+ after = text[end] if end < len(text) else " "
74
+ if before.isalpha() or after.isalpha():
75
+ continue # Part of a word, not a key
76
+ return full
77
+ return None
78
+
79
+
80
+ def _extract_bpm(text: str) -> Optional[float]:
81
+ """Extract BPM from text."""
82
+ # Try explicit bpm markers first
83
+ match = _BPM_PATTERN.search(text)
84
+ if match:
85
+ bpm = float(match.group(1))
86
+ if 40 <= bpm <= 300:
87
+ return bpm
88
+
89
+ # Try standalone numbers in valid range
90
+ for match in _BPM_STANDALONE.finditer(text):
91
+ bpm = float(match.group(1))
92
+ if 60 <= bpm <= 250:
93
+ return bpm
94
+ return None
95
+
96
+
97
+ # ── Material Classification ─────────────────────────────────────────
98
+
99
+ _MATERIAL_KEYWORDS: dict[str, list[str]] = {
100
+ "vocal": ["vocal", "vox", "voice", "singer", "acapella", "spoken"],
101
+ "drum_loop": ["drum", "beat", "break", "breakbeat", "loop", "groove",
102
+ "hihat", "hat", "ride", "cymbal", "perc", "percussion",
103
+ "shaker", "tamb", "conga", "bongo", "top"],
104
+ "one_shot": ["kick", "snare", "clap", "snap", "tom", "rim", "hit",
105
+ "oneshot", "one shot", "stab", "shot", "impact"],
106
+ "instrument_loop": ["guitar", "piano", "keys", "bass", "synth",
107
+ "strings", "brass", "horn", "organ", "riff",
108
+ "chord", "arp", "pluck"],
109
+ "texture": ["ambient", "pad", "drone", "atmosphere", "noise",
110
+ "texture", "wash", "evolving", "soundscape"],
111
+ "foley": ["foley", "field", "recording", "room", "nature",
112
+ "water", "metal", "wood", "glass", "paper"],
113
+ "fx": ["fx", "effect", "riser", "sweep", "whoosh", "boom",
114
+ "transition", "downlifter", "uplifter"],
115
+ }
116
+
117
+
118
+ def classify_material_from_name(name: str) -> str:
119
+ """Classify sample material type from filename/name keywords."""
120
+ lower = name.lower().replace("-", " ").replace("_", " ")
121
+
122
+ # Score each type by keyword matches
123
+ scores: dict[str, int] = {}
124
+ for material_type, keywords in _MATERIAL_KEYWORDS.items():
125
+ score = sum(1 for kw in keywords if kw in lower)
126
+ if score > 0:
127
+ scores[material_type] = score
128
+
129
+ if not scores:
130
+ return "unknown"
131
+
132
+ return max(scores, key=scores.get)
133
+
134
+
135
+ # ── Simpler Mode Recommendation ────────────────────────────────────
136
+
137
+
138
+ def suggest_simpler_mode(profile: SampleProfile) -> str:
139
+ """Recommend Simpler playback mode based on material analysis.
140
+
141
+ Returns: "classic", "one_shot", or "slice"
142
+ """
143
+ if profile.duration_seconds < 0.5 or profile.material_type == "one_shot":
144
+ return "classic"
145
+ if profile.material_type == "fx":
146
+ return "classic"
147
+ if profile.material_type in ("texture", "foley"):
148
+ return "classic"
149
+ if profile.material_type in ("drum_loop", "instrument_loop",
150
+ "vocal", "full_mix"):
151
+ return "slice"
152
+ # Unknown material with decent length — slice is more useful
153
+ if profile.duration_seconds > 2.0:
154
+ return "slice"
155
+ return "classic"
156
+
157
+
158
+ def suggest_slice_method(profile: SampleProfile) -> str:
159
+ """Recommend slice-by method for Simpler's Slice mode."""
160
+ if profile.material_type == "drum_loop":
161
+ return "transient"
162
+ if profile.material_type == "instrument_loop":
163
+ return "beat"
164
+ if profile.material_type == "vocal":
165
+ return "region"
166
+ if profile.material_type == "full_mix":
167
+ return "beat"
168
+ return "transient"
169
+
170
+
171
+ def suggest_warp_mode(profile: SampleProfile) -> str:
172
+ """Recommend Ableton warp mode for the sample material."""
173
+ mode_map = {
174
+ "drum_loop": "beats",
175
+ "one_shot": "complex",
176
+ "instrument_loop": "complex_pro",
177
+ "vocal": "complex_pro",
178
+ "texture": "texture",
179
+ "foley": "texture",
180
+ "fx": "complex",
181
+ "full_mix": "complex_pro",
182
+ }
183
+ return mode_map.get(profile.material_type, "complex")
184
+
185
+
186
+ def build_profile_from_filename(
187
+ file_path: str,
188
+ source: str = "filesystem",
189
+ duration_seconds: float = 0.0,
190
+ ) -> SampleProfile:
191
+ """Build a SampleProfile from filename metadata only (no spectral analysis).
192
+
193
+ This is the fallback when M4L bridge is unavailable.
194
+ """
195
+ name = os.path.splitext(os.path.basename(file_path))[0]
196
+ metadata = parse_filename_metadata(file_path)
197
+ material = classify_material_from_name(name)
198
+
199
+ profile = SampleProfile(
200
+ source=source,
201
+ file_path=file_path,
202
+ name=name,
203
+ key=metadata.get("key"),
204
+ key_confidence=0.5 if metadata.get("key") else 0.0,
205
+ bpm=metadata.get("bpm"),
206
+ bpm_confidence=0.5 if metadata.get("bpm") else 0.0,
207
+ material_type=material,
208
+ material_confidence=0.4, # filename-only is low confidence
209
+ duration_seconds=duration_seconds,
210
+ )
211
+
212
+ profile.suggested_mode = suggest_simpler_mode(profile)
213
+ profile.suggested_slice_by = suggest_slice_method(profile)
214
+ profile.suggested_warp_mode = suggest_warp_mode(profile)
215
+
216
+ return profile