livepilot 1.9.24 → 1.10.0

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +73 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +56 -19
  6. package/bin/livepilot.js +87 -0
  7. package/installer/codex.js +147 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/skills/livepilot-core/SKILL.md +21 -4
  11. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
  12. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
  13. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
  14. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
  15. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
  16. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
  17. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
  18. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
  19. package/livepilot/skills/livepilot-core/references/overview.md +13 -9
  20. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
  21. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
  22. package/livepilot/skills/livepilot-devices/SKILL.md +16 -2
  23. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  24. package/livepilot/skills/livepilot-release/SKILL.md +5 -5
  25. package/livepilot/skills/livepilot-sample-engine/SKILL.md +104 -0
  26. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
  27. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
  28. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
  29. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
  30. package/livepilot/skills/livepilot-wonder/SKILL.md +15 -0
  31. package/livepilot.mcpb +0 -0
  32. package/m4l_device/livepilot_bridge.js +1 -1
  33. package/manifest.json +2 -2
  34. package/mcp_server/__init__.py +1 -1
  35. package/mcp_server/atlas/__init__.py +357 -0
  36. package/mcp_server/atlas/device_atlas.json +44067 -0
  37. package/mcp_server/atlas/enrichments/__init__.py +111 -0
  38. package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
  39. package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
  40. package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
  41. package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
  42. package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
  43. package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
  44. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
  45. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
  46. package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
  47. package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
  48. package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
  49. package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
  50. package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
  51. package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
  52. package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
  53. package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
  54. package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
  55. package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
  56. package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
  57. package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
  58. package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
  59. package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
  60. package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
  61. package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
  62. package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
  63. package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
  64. package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
  65. package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
  66. package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
  67. package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
  68. package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
  69. package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
  70. package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
  71. package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
  72. package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
  73. package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
  74. package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
  75. package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
  76. package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
  77. package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
  78. package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
  79. package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
  80. package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
  81. package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
  82. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
  83. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
  84. package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
  85. package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
  86. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
  87. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
  88. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
  89. package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
  90. package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
  91. package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
  92. package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
  93. package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
  94. package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
  95. package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
  96. package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
  97. package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
  98. package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
  99. package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
  100. package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
  101. package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
  102. package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
  103. package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
  104. package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
  105. package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
  106. package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
  107. package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
  108. package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
  109. package/mcp_server/atlas/scanner.py +236 -0
  110. package/mcp_server/atlas/tools.py +224 -0
  111. package/mcp_server/composer/__init__.py +1 -0
  112. package/mcp_server/composer/engine.py +452 -0
  113. package/mcp_server/composer/layer_planner.py +427 -0
  114. package/mcp_server/composer/prompt_parser.py +329 -0
  115. package/mcp_server/composer/tools.py +201 -0
  116. package/mcp_server/connection.py +53 -8
  117. package/mcp_server/corpus/__init__.py +377 -0
  118. package/mcp_server/device_forge/__init__.py +1 -0
  119. package/mcp_server/device_forge/builder.py +377 -0
  120. package/mcp_server/device_forge/models.py +142 -0
  121. package/mcp_server/device_forge/templates.py +483 -0
  122. package/mcp_server/device_forge/tools.py +162 -0
  123. package/mcp_server/m4l_bridge.py +1 -0
  124. package/mcp_server/preview_studio/tools.py +4 -4
  125. package/mcp_server/runtime/capability_probe.py +21 -2
  126. package/mcp_server/runtime/execution_router.py +4 -0
  127. package/mcp_server/runtime/live_version.py +102 -0
  128. package/mcp_server/runtime/remote_commands.py +9 -4
  129. package/mcp_server/runtime/tools.py +18 -4
  130. package/mcp_server/sample_engine/__init__.py +1 -0
  131. package/mcp_server/sample_engine/analyzer.py +216 -0
  132. package/mcp_server/sample_engine/critics.py +390 -0
  133. package/mcp_server/sample_engine/models.py +193 -0
  134. package/mcp_server/sample_engine/moves.py +127 -0
  135. package/mcp_server/sample_engine/planner.py +186 -0
  136. package/mcp_server/sample_engine/sources.py +540 -0
  137. package/mcp_server/sample_engine/techniques.py +908 -0
  138. package/mcp_server/sample_engine/tools.py +442 -0
  139. package/mcp_server/semantic_moves/__init__.py +3 -0
  140. package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
  141. package/mcp_server/semantic_moves/sample_compilers.py +372 -0
  142. package/mcp_server/server.py +51 -0
  143. package/mcp_server/sound_design/critics.py +89 -1
  144. package/mcp_server/splice_client/__init__.py +1 -0
  145. package/mcp_server/splice_client/client.py +347 -0
  146. package/mcp_server/splice_client/models.py +96 -0
  147. package/mcp_server/splice_client/protos/__init__.py +1 -0
  148. package/mcp_server/splice_client/protos/app_pb2.py +319 -0
  149. package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
  150. package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
  151. package/mcp_server/tools/arrangement.py +69 -0
  152. package/mcp_server/tools/automation.py +15 -2
  153. package/mcp_server/tools/devices.py +117 -6
  154. package/mcp_server/tools/notes.py +37 -4
  155. package/mcp_server/wonder_mode/diagnosis.py +5 -0
  156. package/mcp_server/wonder_mode/engine.py +85 -1
  157. package/package.json +12 -2
  158. package/remote_script/LivePilot/__init__.py +8 -1
  159. package/remote_script/LivePilot/arrangement.py +114 -0
  160. package/remote_script/LivePilot/browser.py +56 -1
  161. package/remote_script/LivePilot/devices.py +236 -6
  162. package/remote_script/LivePilot/mixing.py +8 -3
  163. package/remote_script/LivePilot/server.py +5 -1
  164. package/remote_script/LivePilot/transport.py +3 -0
  165. package/remote_script/LivePilot/version_detect.py +78 -0
@@ -34,6 +34,23 @@ def probe_capabilities(
34
34
  "detail": "TCP 9878 connection active" if ableton_ok else "Not connected",
35
35
  }
36
36
 
37
+ # 1b. Live version capabilities
38
+ live_version_str = "12.0.0"
39
+ if ableton_ok:
40
+ try:
41
+ info = ableton.send_command("get_session_info")
42
+ live_version_str = info.get("live_version", "12.0.0")
43
+ except Exception:
44
+ pass
45
+ from .live_version import LiveVersionCapabilities
46
+ version_caps = LiveVersionCapabilities.from_version_string(live_version_str)
47
+ report["live_version"] = {
48
+ "status": "ok",
49
+ "version": live_version_str,
50
+ "capability_tier": version_caps.capability_tier,
51
+ "features": version_caps.to_dict(),
52
+ }
53
+
37
54
  # 2. Remote Script parity
38
55
  from .remote_commands import REMOTE_COMMANDS
39
56
  report["remote_script"] = {
@@ -45,8 +62,10 @@ def probe_capabilities(
45
62
  # 3. M4L bridge
46
63
  bridge_ok = False
47
64
  if ctx is not None:
48
- bridge = getattr(ctx, "lifespan_context", {}).get("m4l_bridge") if hasattr(ctx, "lifespan_context") else None
49
- bridge_ok = bridge is not None
65
+ lifespan_context = getattr(ctx, "lifespan_context", {}) if hasattr(ctx, "lifespan_context") else {}
66
+ bridge = lifespan_context.get("m4l")
67
+ spectral = lifespan_context.get("spectral")
68
+ bridge_ok = bridge is not None and spectral is not None and getattr(spectral, "is_connected", False)
50
69
  report["m4l_bridge"] = {
51
70
  "status": "ok" if bridge_ok else "unavailable",
52
71
  "detail": "UDP 9880 / OSC 9881 active" if bridge_ok else "Not connected — 30 analyzer tools unavailable",
@@ -29,6 +29,10 @@ MCP_TOOLS: frozenset[str] = frozenset({
29
29
  "get_emotional_arc",
30
30
  "capture_audio",
31
31
  "get_motif_graph",
32
+ # Device Forge tools (MCP-only, no TCP handler)
33
+ "generate_m4l_effect",
34
+ "install_m4l_device",
35
+ "list_genexpr_templates",
32
36
  })
33
37
 
34
38
 
@@ -0,0 +1,102 @@
1
+ """MCP-side Live version capabilities model.
2
+
3
+ Pure data model — no I/O. Parses version info from get_session_info
4
+ responses and exposes feature flags for tool routing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass
13
+ class LiveVersionCapabilities:
14
+ """Feature availability based on detected Live version."""
15
+
16
+ major: int = 12
17
+ minor: int = 0
18
+ patch: int = 0
19
+
20
+ @classmethod
21
+ def from_version_string(cls, version_str: str) -> LiveVersionCapabilities:
22
+ """Parse '12.3.6' into a capabilities instance."""
23
+ parts = version_str.split(".")
24
+ major = int(parts[0]) if len(parts) > 0 else 12
25
+ minor = int(parts[1]) if len(parts) > 1 else 0
26
+ patch = int(parts[2]) if len(parts) > 2 else 0
27
+ return cls(major=major, minor=minor, patch=patch)
28
+
29
+ @classmethod
30
+ def from_session_info(cls, session_info: dict) -> LiveVersionCapabilities:
31
+ """Extract version from get_session_info response.
32
+
33
+ Looks for 'live_version' field. Falls back to 12.0.0 if absent
34
+ (pre-upgrade Remote Script).
35
+ """
36
+ version_str = session_info.get("live_version", "12.0.0")
37
+ return cls.from_version_string(version_str)
38
+
39
+ @property
40
+ def _version_tuple(self) -> tuple[int, int, int]:
41
+ return (self.major, self.minor, self.patch)
42
+
43
+ # ── Feature flags ──────────────────────────────────────────────
44
+
45
+ @property
46
+ def has_native_arrangement_clips(self) -> bool:
47
+ """Track.create_midi_clip(start, length) — 12.1.10+"""
48
+ return self._version_tuple >= (12, 1, 10)
49
+
50
+ @property
51
+ def has_display_value(self) -> bool:
52
+ """DeviceParameter.display_value — 12.2+"""
53
+ return self._version_tuple >= (12, 2, 0)
54
+
55
+ @property
56
+ def has_insert_device(self) -> bool:
57
+ """Track.insert_device(name, index?) — 12.3+"""
58
+ return self._version_tuple >= (12, 3, 0)
59
+
60
+ @property
61
+ def has_drum_rack_construction(self) -> bool:
62
+ """insert_chain + DrumChain.in_note — 12.3+"""
63
+ return self._version_tuple >= (12, 3, 0)
64
+
65
+ @property
66
+ def has_take_lanes(self) -> bool:
67
+ """Take Lanes API — 12.2+"""
68
+ return self._version_tuple >= (12, 2, 0)
69
+
70
+ @property
71
+ def has_stem_separation(self) -> bool:
72
+ """Stem separation via MFL — 12.3+"""
73
+ return self._version_tuple >= (12, 3, 0)
74
+
75
+ @property
76
+ def has_replace_sample_native(self) -> bool:
77
+ """SimplerDevice.replace_sample(path) — 12.4+"""
78
+ return self._version_tuple >= (12, 4, 0)
79
+
80
+ @property
81
+ def capability_tier(self) -> str:
82
+ """Human-readable tier: core | enhanced_arrangement | full_intelligence."""
83
+ if self._version_tuple >= (12, 3, 0):
84
+ return "full_intelligence"
85
+ elif self._version_tuple >= (12, 1, 10):
86
+ return "enhanced_arrangement"
87
+ else:
88
+ return "core"
89
+
90
+ def to_dict(self) -> dict:
91
+ """Serialize for API responses and capability probes."""
92
+ return {
93
+ "version": f"{self.major}.{self.minor}.{self.patch}",
94
+ "capability_tier": self.capability_tier,
95
+ "native_arrangement_clips": self.has_native_arrangement_clips,
96
+ "display_value": self.has_display_value,
97
+ "insert_device": self.has_insert_device,
98
+ "drum_rack_construction": self.has_drum_rack_construction,
99
+ "take_lanes": self.has_take_lanes,
100
+ "stem_separation": self.has_stem_separation,
101
+ "replace_sample_native": self.has_replace_sample_native,
102
+ }
@@ -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)
@@ -94,14 +94,19 @@ 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
 
@@ -112,10 +117,19 @@ def get_session_kernel(
112
117
  session_mem = []
113
118
 
114
119
  try:
115
- from .action_ledger import ActionLedger
116
- ledger = ActionLedger.instance()
120
+ from .action_ledger import SessionLedger
121
+ ledger = ctx.lifespan_context.get("action_ledger")
122
+ if ledger is None:
123
+ ledger = SessionLedger()
124
+ ctx.lifespan_context["action_ledger"] = ledger
117
125
  if ledger:
118
- ledger_summary = ledger.summary()
126
+ recent = ledger.get_recent_moves(limit=10)
127
+ ledger_summary = {
128
+ "total_moves": len(ledger._entries),
129
+ "memory_candidate_count": len(ledger.get_memory_candidates()),
130
+ "last_move": ledger.get_last_move().to_dict() if ledger.get_last_move() else None,
131
+ "recent_moves": [entry.to_dict() for entry in recent],
132
+ }
119
133
  except Exception:
120
134
  pass
121
135
 
@@ -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