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,540 @@
1
+ """Sample sources — discover samples from Ableton browser, Splice, and local filesystem.
2
+
3
+ Three sources:
4
+ - BrowserSource: searches Ableton's built-in browser (samples, drums, packs, user_library)
5
+ - SpliceSource: reads Splice's local sounds.db SQLite database for rich metadata
6
+ - FilesystemSource: scans user-configured local directories
7
+
8
+ All sources return SampleCandidate objects. Actual Ableton communication happens in tools.py.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import glob
14
+ import os
15
+ import sqlite3
16
+ from typing import Optional
17
+
18
+ from .models import SampleCandidate
19
+ from .analyzer import parse_filename_metadata, classify_material_from_name
20
+
21
+
22
+ _AUDIO_EXTENSIONS = frozenset({
23
+ ".wav", ".aif", ".aiff", ".mp3", ".flac", ".ogg",
24
+ })
25
+
26
+
27
+ # ── Browser Source ─────────────────────────────────────────────────
28
+
29
+
30
+ class BrowserSource:
31
+ """Search Ableton's browser for samples, drums, packs, and user library.
32
+
33
+ Parameter-building class — actual Ableton communication happens in tools.py
34
+ which calls build_search_params() and sends the command over TCP.
35
+ """
36
+
37
+ DEFAULT_CATEGORIES = ("samples", "drums", "packs", "user_library")
38
+
39
+ def build_search_params(
40
+ self, query: str, category: str = "samples", max_results: int = 20,
41
+ ) -> dict:
42
+ """Build a single search_browser command payload."""
43
+ return {
44
+ "path": category,
45
+ "name_filter": query,
46
+ "loadable_only": True,
47
+ "max_results": max_results,
48
+ }
49
+
50
+ def build_all_search_params(
51
+ self,
52
+ query: str,
53
+ categories: Optional[list[str]] = None,
54
+ max_results: int = 20,
55
+ ) -> list[dict]:
56
+ """Build search params for each category."""
57
+ cats = categories or list(self.DEFAULT_CATEGORIES)
58
+ return [self.build_search_params(query, cat, max_results) for cat in cats]
59
+
60
+ def parse_results(
61
+ self, raw_results: list[dict], category: str = "browser",
62
+ ) -> list[SampleCandidate]:
63
+ """Parse raw browser search results into SampleCandidates."""
64
+ candidates: list[SampleCandidate] = []
65
+ for item in raw_results:
66
+ name = item.get("name", "")
67
+ stem = os.path.splitext(name)[0] if name else ""
68
+ material = classify_material_from_name(stem) if stem else "unknown"
69
+ candidates.append(SampleCandidate(
70
+ source="browser",
71
+ name=stem or name,
72
+ uri=item.get("uri"),
73
+ metadata={
74
+ "category": category,
75
+ "uri": item.get("uri", ""),
76
+ "material_type": material,
77
+ },
78
+ ))
79
+ return candidates
80
+
81
+
82
+ # ── Splice Source ──────────────────────────────────────────────────
83
+
84
+
85
+ # Splice stores sounds.db under a user-specific subdirectory:
86
+ # ~/Library/Application Support/com.splice.Splice/users/default/<username>/sounds.db
87
+ _SPLICE_APP_SUPPORT = "~/Library/Application Support/com.splice.Splice"
88
+
89
+ # Map Splice sample_type to our material_type
90
+ _SPLICE_TYPE_MAP = {
91
+ "oneshot": "one_shot",
92
+ "one-shot": "one_shot",
93
+ "loop": "drum_loop", # default for loops, refined by tags
94
+ }
95
+
96
+ # Tag-based material refinement for loops
97
+ _SPLICE_LOOP_REFINEMENT = {
98
+ "vocal": "vocal",
99
+ "vox": "vocal",
100
+ "voice": "vocal",
101
+ "synth": "instrument_loop",
102
+ "keys": "instrument_loop",
103
+ "piano": "instrument_loop",
104
+ "guitar": "instrument_loop",
105
+ "bass": "instrument_loop",
106
+ "pad": "texture",
107
+ "ambient": "texture",
108
+ "texture": "texture",
109
+ "atmosphere": "texture",
110
+ "foley": "foley",
111
+ "fx": "fx",
112
+ "riser": "fx",
113
+ "sweep": "fx",
114
+ }
115
+
116
+
117
+ class SpliceSource:
118
+ """Read Splice's local sounds.db for rich sample metadata.
119
+
120
+ Splice stores downloaded samples in a SQLite database with columns:
121
+ id, local_path, audio_key, bpm, tags, sample_type, genre, filename,
122
+ provider_name, pack_uuid, duration, popularity, and more.
123
+
124
+ The database is opened read-only to avoid corrupting Splice's live data.
125
+ """
126
+
127
+ def __init__(self, db_path: Optional[str] = None):
128
+ self.db_path = db_path or self._find_db()
129
+ self.enabled = self.db_path is not None and os.path.isfile(self.db_path)
130
+
131
+ @staticmethod
132
+ def _find_db() -> Optional[str]:
133
+ """Auto-detect Splice sounds.db location.
134
+
135
+ Splice stores the DB at:
136
+ ~/Library/Application Support/com.splice.Splice/users/default/<username>/sounds.db
137
+ We glob for it since the username varies per account.
138
+ """
139
+ base = os.path.expanduser(_SPLICE_APP_SUPPORT)
140
+ # Search under users/default/*/sounds.db (most common)
141
+ pattern = os.path.join(base, "users", "default", "*", "sounds.db")
142
+ matches = glob.glob(pattern)
143
+ if matches:
144
+ return matches[0]
145
+ # Broader search: users/*/sounds.db
146
+ pattern = os.path.join(base, "users", "*", "sounds.db")
147
+ matches = glob.glob(pattern)
148
+ if matches:
149
+ return matches[0]
150
+ # Direct fallback (older Splice versions)
151
+ direct = os.path.join(base, "sounds.db")
152
+ if os.path.isfile(direct):
153
+ return direct
154
+ return None
155
+
156
+ def _connect(self) -> Optional[sqlite3.Connection]:
157
+ """Open read-only connection to Splice database."""
158
+ if not self.enabled:
159
+ return None
160
+ try:
161
+ uri = f"file:{self.db_path}?mode=ro"
162
+ conn = sqlite3.connect(uri, uri=True)
163
+ conn.row_factory = sqlite3.Row
164
+ return conn
165
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
166
+ return None
167
+
168
+ def search(
169
+ self,
170
+ query: str,
171
+ max_results: int = 20,
172
+ sample_type: Optional[str] = None,
173
+ key: Optional[str] = None,
174
+ bpm_min: Optional[float] = None,
175
+ bpm_max: Optional[float] = None,
176
+ genre: Optional[str] = None,
177
+ ) -> list[SampleCandidate]:
178
+ """Search Splice database by tags, filename, key, BPM, genre.
179
+
180
+ Only returns samples with local_path NOT NULL (actually downloaded).
181
+ Genre filtering JOINs with the packs table (genre lives there, not on samples).
182
+ Keys are stored lowercase in Splice (e.g., "c#") — we normalize for comparison.
183
+ Duration is stored as milliseconds — we convert to seconds.
184
+ """
185
+ conn = self._connect()
186
+ if conn is None:
187
+ return []
188
+
189
+ try:
190
+ conditions = ["s.local_path IS NOT NULL"]
191
+ params: list = []
192
+ use_packs_join = genre is not None
193
+
194
+ # Text search across tags and filename
195
+ if query:
196
+ words = query.lower().split()
197
+ for word in words:
198
+ conditions.append(
199
+ "(LOWER(s.tags) LIKE ? OR LOWER(s.filename) LIKE ?)"
200
+ )
201
+ params.extend([f"%{word}%", f"%{word}%"])
202
+
203
+ if sample_type:
204
+ conditions.append("s.sample_type = ?")
205
+ params.append(sample_type)
206
+
207
+ if key:
208
+ # Normalize: user might pass "Cm" or "C#", Splice stores "c" or "c#"
209
+ # Strip minor suffix for comparison — Splice uses chord_type column
210
+ key_normalized = key.lower().rstrip("m").rstrip("inor")
211
+ # But proper suffix removal:
212
+ k = key.lower()
213
+ for suffix in ("minor", "min"):
214
+ if k.endswith(suffix):
215
+ k = k[:-len(suffix)]
216
+ break
217
+ if k.endswith("m") and not k.endswith("bm") and len(k) > 1:
218
+ k = k[:-1]
219
+ conditions.append("LOWER(s.audio_key) = ?")
220
+ params.append(k)
221
+
222
+ if bpm_min is not None:
223
+ conditions.append("s.bpm >= ?")
224
+ params.append(bpm_min)
225
+
226
+ if bpm_max is not None:
227
+ conditions.append("s.bpm <= ?")
228
+ params.append(bpm_max)
229
+
230
+ if genre:
231
+ use_packs_join = True
232
+ conditions.append("LOWER(p.genre) LIKE ?")
233
+ params.append(f"%{genre.lower()}%")
234
+
235
+ where = " AND ".join(conditions)
236
+
237
+ if use_packs_join:
238
+ sql = f"""
239
+ SELECT s.id, s.local_path, s.audio_key, s.bpm, s.tags,
240
+ s.sample_type, p.genre AS pack_genre, s.filename,
241
+ s.provider_name, s.pack_uuid, s.duration,
242
+ s.popularity, s.chord_type,
243
+ p.name AS pack_name
244
+ FROM samples s
245
+ LEFT JOIN packs p ON s.pack_uuid = p.uuid
246
+ WHERE {where}
247
+ ORDER BY s.popularity DESC
248
+ LIMIT ?
249
+ """
250
+ else:
251
+ sql = f"""
252
+ SELECT s.id, s.local_path, s.audio_key, s.bpm, s.tags,
253
+ s.sample_type, NULL AS pack_genre, s.filename,
254
+ s.provider_name, s.pack_uuid, s.duration,
255
+ s.popularity, s.chord_type,
256
+ NULL AS pack_name
257
+ FROM samples s
258
+ WHERE {where}
259
+ ORDER BY s.popularity DESC
260
+ LIMIT ?
261
+ """
262
+ params.append(max_results)
263
+
264
+ cursor = conn.execute(sql, params)
265
+ rows = cursor.fetchall()
266
+ return [self._row_to_candidate(row) for row in rows]
267
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
268
+ return []
269
+ finally:
270
+ conn.close()
271
+
272
+ def get_sample_count(self) -> int:
273
+ """Return total number of downloaded samples in the Splice library."""
274
+ conn = self._connect()
275
+ if conn is None:
276
+ return 0
277
+ try:
278
+ cursor = conn.execute(
279
+ "SELECT COUNT(*) FROM samples WHERE local_path IS NOT NULL"
280
+ )
281
+ return cursor.fetchone()[0]
282
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
283
+ return 0
284
+ finally:
285
+ conn.close()
286
+
287
+ def get_available_keys(self) -> list[str]:
288
+ """Return all unique keys in the Splice library."""
289
+ conn = self._connect()
290
+ if conn is None:
291
+ return []
292
+ try:
293
+ cursor = conn.execute(
294
+ "SELECT DISTINCT audio_key FROM samples "
295
+ "WHERE audio_key IS NOT NULL AND local_path IS NOT NULL "
296
+ "ORDER BY audio_key"
297
+ )
298
+ return [row[0] for row in cursor.fetchall()]
299
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
300
+ return []
301
+ finally:
302
+ conn.close()
303
+
304
+ def get_available_genres(self) -> list[str]:
305
+ """Return all unique genres from packs that have downloaded samples."""
306
+ conn = self._connect()
307
+ if conn is None:
308
+ return []
309
+ try:
310
+ cursor = conn.execute(
311
+ "SELECT DISTINCT p.genre FROM packs p "
312
+ "INNER JOIN samples s ON s.pack_uuid = p.uuid "
313
+ "WHERE p.genre IS NOT NULL AND p.genre != '' "
314
+ "AND s.local_path IS NOT NULL "
315
+ "ORDER BY p.genre"
316
+ )
317
+ return [row[0] for row in cursor.fetchall()]
318
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
319
+ return []
320
+ finally:
321
+ conn.close()
322
+
323
+ def search_by_key_and_tempo(
324
+ self,
325
+ key: str,
326
+ bpm: float,
327
+ tolerance_bpm: float = 5.0,
328
+ max_results: int = 10,
329
+ ) -> list[SampleCandidate]:
330
+ """Find samples matching a specific key and tempo range.
331
+
332
+ This is the power query — find samples that musically fit your song.
333
+ """
334
+ return self.search(
335
+ query="",
336
+ key=key,
337
+ bpm_min=bpm - tolerance_bpm,
338
+ bpm_max=bpm + tolerance_bpm,
339
+ max_results=max_results,
340
+ )
341
+
342
+ def _row_to_candidate(self, row: sqlite3.Row) -> SampleCandidate:
343
+ """Convert a database row to SampleCandidate with rich metadata.
344
+
345
+ Handles Splice-specific quirks:
346
+ - audio_key is lowercase ("c#") → normalize to "C#"
347
+ - chord_type is separate ("major"/"minor") → combine with key
348
+ - duration is milliseconds → convert to seconds
349
+ - genre lives on packs table (pack_genre column from JOIN)
350
+ """
351
+ tags = str(row["tags"] or "")
352
+ splice_type = str(row["sample_type"] or "")
353
+ material = self._classify_splice_material(splice_type, tags)
354
+
355
+ # Normalize key: "c#" → "C#", combine with chord_type
356
+ raw_key = row["audio_key"]
357
+ chord_type = str(row["chord_type"] or "") if "chord_type" in row.keys() else ""
358
+ normalized_key = self._normalize_key(raw_key, chord_type)
359
+
360
+ # Duration: ms → seconds
361
+ raw_duration = row["duration"]
362
+ duration_sec = (raw_duration / 1000.0) if raw_duration else 0.0
363
+
364
+ # Genre from packs JOIN (pack_genre) or fallback
365
+ genre = None
366
+ try:
367
+ genre = row["pack_genre"]
368
+ except (IndexError, KeyError):
369
+ pass
370
+
371
+ # Pack name from JOIN
372
+ pack_name = None
373
+ try:
374
+ pack_name = row["pack_name"]
375
+ except (IndexError, KeyError):
376
+ pack_name = row["provider_name"]
377
+
378
+ return SampleCandidate(
379
+ source="splice",
380
+ name=str(row["filename"] or ""),
381
+ file_path=str(row["local_path"] or ""),
382
+ metadata={
383
+ "key": normalized_key,
384
+ "bpm": row["bpm"] if row["bpm"] else None,
385
+ "tags": tags,
386
+ "genre": genre,
387
+ "sample_type": splice_type,
388
+ "material_type": material,
389
+ "pack": pack_name or row["provider_name"],
390
+ "pack_uuid": row["pack_uuid"],
391
+ "duration": round(duration_sec, 2),
392
+ "popularity": row["popularity"],
393
+ "splice_id": row["id"],
394
+ "chord_type": chord_type,
395
+ },
396
+ )
397
+
398
+ @staticmethod
399
+ def _normalize_key(raw_key: Optional[str], chord_type: str = "") -> Optional[str]:
400
+ """Normalize Splice's lowercase key + chord_type to standard format.
401
+
402
+ "c#" + "major" → "C#"
403
+ "c#" + "minor" → "C#m"
404
+ "eb" + "" → "Eb"
405
+ """
406
+ if not raw_key:
407
+ return None
408
+ # Capitalize root note
409
+ key = raw_key[0].upper() + raw_key[1:] if raw_key else ""
410
+ # Replace 'b' after first char — it's a flat, keep as-is
411
+ # Replace '#' — keep as-is
412
+ # Add minor suffix
413
+ if chord_type.lower() in ("minor", "min"):
414
+ key += "m"
415
+ return key
416
+
417
+ @staticmethod
418
+ def _classify_splice_material(sample_type: str, tags: str) -> str:
419
+ """Classify material type from Splice's sample_type + tags.
420
+
421
+ Splice has "oneshot" and "loop". We refine loops by tag content.
422
+ """
423
+ base = _SPLICE_TYPE_MAP.get(sample_type.lower(), "unknown")
424
+
425
+ if base == "one_shot":
426
+ return "one_shot"
427
+
428
+ # Refine loops by tag keywords
429
+ tags_lower = tags.lower()
430
+ for keyword, material in _SPLICE_LOOP_REFINEMENT.items():
431
+ if keyword in tags_lower:
432
+ return material
433
+
434
+ # Default: if it's a loop, call it drum_loop (most common)
435
+ if base == "drum_loop":
436
+ return "drum_loop"
437
+
438
+ # Fall back to filename-based classification
439
+ return classify_material_from_name(tags_lower) or "unknown"
440
+
441
+
442
+ # ── Filesystem Source ───────────────────────────────────────────────
443
+
444
+
445
+ class FilesystemSource:
446
+ """Scan local directories for audio files with metadata extraction."""
447
+
448
+ def __init__(
449
+ self,
450
+ scan_paths: Optional[list[str]] = None,
451
+ max_depth: int = 6,
452
+ ):
453
+ self.scan_paths = scan_paths or []
454
+ self.max_depth = max_depth
455
+
456
+ def scan(self) -> list[SampleCandidate]:
457
+ """Scan all configured paths for audio files."""
458
+ candidates: list[SampleCandidate] = []
459
+ for base_path in self.scan_paths:
460
+ expanded = os.path.expanduser(base_path)
461
+ if not os.path.isdir(expanded):
462
+ continue
463
+ self._scan_dir(expanded, 0, candidates)
464
+ return candidates
465
+
466
+ def search(self, query: str, max_results: int = 20) -> list[SampleCandidate]:
467
+ """Search scanned files by query keywords."""
468
+ all_files = self.scan()
469
+ query_lower = query.lower()
470
+ query_words = set(query_lower.split())
471
+
472
+ scored: list[tuple[SampleCandidate, float]] = []
473
+ for candidate in all_files:
474
+ name_lower = candidate.name.lower()
475
+ score = sum(1 for w in query_words if w in name_lower)
476
+ if candidate.metadata.get("key") and query_lower in str(
477
+ candidate.metadata.get("key", "")
478
+ ).lower():
479
+ score += 0.5
480
+ if score > 0:
481
+ scored.append((candidate, score))
482
+
483
+ scored.sort(key=lambda x: -x[1])
484
+ return [c for c, _ in scored[:max_results]]
485
+
486
+ def _scan_dir(self, path: str, depth: int, out: list[SampleCandidate]):
487
+ if depth > self.max_depth:
488
+ return
489
+ try:
490
+ entries = os.scandir(path)
491
+ except PermissionError:
492
+ return
493
+
494
+ for entry in entries:
495
+ if entry.is_file():
496
+ ext = os.path.splitext(entry.name)[1].lower()
497
+ if ext in _AUDIO_EXTENSIONS:
498
+ stem = os.path.splitext(entry.name)[0]
499
+ metadata = parse_filename_metadata(entry.name)
500
+ metadata["material_type"] = classify_material_from_name(stem)
501
+ out.append(SampleCandidate(
502
+ source="filesystem",
503
+ name=stem,
504
+ file_path=entry.path,
505
+ metadata=metadata,
506
+ ))
507
+ elif entry.is_dir() and not entry.name.startswith("."):
508
+ self._scan_dir(entry.path, depth + 1, out)
509
+
510
+
511
+ # ── Search Query Builder ────────────────────────────────────────────
512
+
513
+
514
+ def build_search_queries(
515
+ user_query: str,
516
+ material_type: Optional[str] = None,
517
+ song_context: Optional[dict] = None,
518
+ ) -> list[str]:
519
+ """Build smart search queries from user request + song context."""
520
+ queries = [user_query]
521
+
522
+ if material_type:
523
+ synonyms = {
524
+ "vocal": ["vocal", "vox", "voice", "acapella"],
525
+ "drum_loop": ["drum loop", "breakbeat", "percussion loop", "break"],
526
+ "texture": ["ambient", "pad", "texture", "drone"],
527
+ "one_shot": ["one shot", "hit", "stab"],
528
+ "instrument_loop": ["synth", "keys", "guitar", "bass loop"],
529
+ "foley": ["foley", "field recording", "found sound"],
530
+ }
531
+ for syn in synonyms.get(material_type, []):
532
+ if syn.lower() not in user_query.lower():
533
+ queries.append(f"{user_query} {syn}")
534
+
535
+ if song_context:
536
+ key = song_context.get("key", "")
537
+ if key and key not in user_query:
538
+ queries.append(f"{user_query} {key}")
539
+
540
+ return queries[:5]