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.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +223 -0
- package/CONTRIBUTING.md +2 -2
- package/LICENSE +62 -21
- package/README.md +291 -276
- package/bin/livepilot.js +87 -0
- package/installer/codex.js +147 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-arrangement/SKILL.md +18 -1
- package/livepilot/skills/livepilot-core/SKILL.md +22 -5
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
- package/livepilot/skills/livepilot-core/references/overview.md +13 -9
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
- package/livepilot/skills/livepilot-devices/SKILL.md +39 -4
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +23 -19
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +105 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +17 -0
- package/livepilot.mcpb +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/manifest.json +4 -4
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +357 -0
- package/mcp_server/atlas/device_atlas.json +44067 -0
- package/mcp_server/atlas/enrichments/__init__.py +111 -0
- package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
- package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
- package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
- package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
- package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
- package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
- package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
- package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
- package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
- package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
- package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
- package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
- package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
- package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
- package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
- package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
- package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
- package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
- package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
- package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
- package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
- package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
- package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
- package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
- package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
- package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
- package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
- package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
- package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
- package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
- package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
- package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
- package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
- package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
- package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
- package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
- package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
- package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
- package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
- package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
- package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
- package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
- package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
- package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
- package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
- package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
- package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
- package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
- package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
- package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
- package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
- package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
- package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
- package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
- package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
- package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
- package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
- package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
- package/mcp_server/atlas/scanner.py +236 -0
- package/mcp_server/atlas/tools.py +224 -0
- package/mcp_server/composer/__init__.py +1 -0
- package/mcp_server/composer/engine.py +532 -0
- package/mcp_server/composer/layer_planner.py +427 -0
- package/mcp_server/composer/prompt_parser.py +329 -0
- package/mcp_server/composer/sample_resolver.py +153 -0
- package/mcp_server/composer/tools.py +211 -0
- package/mcp_server/connection.py +53 -8
- package/mcp_server/corpus/__init__.py +377 -0
- package/mcp_server/device_forge/__init__.py +1 -0
- package/mcp_server/device_forge/builder.py +377 -0
- package/mcp_server/device_forge/models.py +142 -0
- package/mcp_server/device_forge/templates.py +483 -0
- package/mcp_server/device_forge/tools.py +162 -0
- package/mcp_server/m4l_bridge.py +1 -0
- package/mcp_server/memory/taste_accessors.py +47 -0
- package/mcp_server/preview_studio/engine.py +9 -2
- package/mcp_server/preview_studio/tools.py +78 -35
- package/mcp_server/project_brain/tools.py +34 -0
- package/mcp_server/runtime/capability_probe.py +21 -2
- package/mcp_server/runtime/execution_router.py +184 -38
- package/mcp_server/runtime/live_version.py +102 -0
- package/mcp_server/runtime/mcp_dispatch.py +46 -0
- package/mcp_server/runtime/remote_commands.py +13 -5
- package/mcp_server/runtime/tools.py +66 -29
- package/mcp_server/sample_engine/__init__.py +1 -0
- package/mcp_server/sample_engine/analyzer.py +216 -0
- package/mcp_server/sample_engine/critics.py +390 -0
- package/mcp_server/sample_engine/models.py +193 -0
- package/mcp_server/sample_engine/moves.py +127 -0
- package/mcp_server/sample_engine/planner.py +186 -0
- package/mcp_server/sample_engine/slice_workflow.py +190 -0
- package/mcp_server/sample_engine/sources.py +540 -0
- package/mcp_server/sample_engine/techniques.py +908 -0
- package/mcp_server/sample_engine/tools.py +545 -0
- package/mcp_server/semantic_moves/__init__.py +3 -0
- package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
- package/mcp_server/semantic_moves/mix_moves.py +8 -8
- package/mcp_server/semantic_moves/models.py +7 -7
- package/mcp_server/semantic_moves/performance_moves.py +4 -4
- package/mcp_server/semantic_moves/sample_compilers.py +377 -0
- package/mcp_server/semantic_moves/sound_design_moves.py +4 -4
- package/mcp_server/semantic_moves/tools.py +63 -10
- package/mcp_server/semantic_moves/transition_moves.py +4 -4
- package/mcp_server/server.py +71 -1
- package/mcp_server/session_continuity/tracker.py +4 -1
- package/mcp_server/sound_design/critics.py +89 -1
- package/mcp_server/splice_client/__init__.py +1 -0
- package/mcp_server/splice_client/client.py +347 -0
- package/mcp_server/splice_client/models.py +96 -0
- package/mcp_server/splice_client/protos/__init__.py +1 -0
- package/mcp_server/splice_client/protos/app_pb2.py +319 -0
- package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
- package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
- package/mcp_server/tools/_conductor.py +16 -0
- package/mcp_server/tools/_planner_engine.py +24 -0
- package/mcp_server/tools/analyzer.py +2 -0
- package/mcp_server/tools/arrangement.py +69 -0
- package/mcp_server/tools/automation.py +15 -2
- package/mcp_server/tools/devices.py +117 -6
- package/mcp_server/tools/notes.py +37 -4
- package/mcp_server/tools/planner.py +3 -0
- package/mcp_server/wonder_mode/diagnosis.py +5 -0
- package/mcp_server/wonder_mode/engine.py +144 -14
- package/mcp_server/wonder_mode/tools.py +33 -1
- package/package.json +14 -4
- package/remote_script/LivePilot/__init__.py +8 -1
- package/remote_script/LivePilot/arrangement.py +114 -0
- package/remote_script/LivePilot/browser.py +56 -1
- package/remote_script/LivePilot/devices.py +246 -6
- package/remote_script/LivePilot/mixing.py +8 -3
- package/remote_script/LivePilot/server.py +5 -1
- package/remote_script/LivePilot/transport.py +3 -0
- 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]
|