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.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +73 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +56 -19
- 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-core/SKILL.md +21 -4
- 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 +16 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +5 -5
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +104 -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 +15 -0
- package/livepilot.mcpb +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/manifest.json +2 -2
- 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 +452 -0
- package/mcp_server/composer/layer_planner.py +427 -0
- package/mcp_server/composer/prompt_parser.py +329 -0
- package/mcp_server/composer/tools.py +201 -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/preview_studio/tools.py +4 -4
- package/mcp_server/runtime/capability_probe.py +21 -2
- package/mcp_server/runtime/execution_router.py +4 -0
- package/mcp_server/runtime/live_version.py +102 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- package/mcp_server/runtime/tools.py +18 -4
- 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/sources.py +540 -0
- package/mcp_server/sample_engine/techniques.py +908 -0
- package/mcp_server/sample_engine/tools.py +442 -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/sample_compilers.py +372 -0
- package/mcp_server/server.py +51 -0
- 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/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/wonder_mode/diagnosis.py +5 -0
- package/mcp_server/wonder_mode/engine.py +85 -1
- package/package.json +12 -2
- 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 +236 -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,347 @@
|
|
|
1
|
+
"""SpliceGRPCClient — connect to Splice desktop's local gRPC API.
|
|
2
|
+
|
|
3
|
+
Splice runs a gRPC server (Go binary) on localhost with TLS.
|
|
4
|
+
Port is dynamic (read from port.conf). Certs are self-signed.
|
|
5
|
+
|
|
6
|
+
This client provides: search, download, sample info, credit check.
|
|
7
|
+
All methods are async. Graceful degradation when Splice is not running.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import glob
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .models import SpliceCredits, SpliceSample, SpliceSearchResult
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Splice app support directory
|
|
23
|
+
_SPLICE_APP_SUPPORT = os.path.expanduser(
|
|
24
|
+
"~/Library/Application Support/com.splice.Splice"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Credit safety floor — never drain below this
|
|
28
|
+
CREDIT_HARD_FLOOR = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _try_import_grpc():
|
|
32
|
+
"""Import grpcio lazily — graceful degradation if not installed."""
|
|
33
|
+
try:
|
|
34
|
+
import grpc
|
|
35
|
+
return grpc
|
|
36
|
+
except ImportError:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _try_import_protos():
|
|
41
|
+
"""Import generated protobuf stubs lazily."""
|
|
42
|
+
try:
|
|
43
|
+
from .protos import app_pb2, app_pb2_grpc
|
|
44
|
+
return app_pb2, app_pb2_grpc
|
|
45
|
+
except ImportError:
|
|
46
|
+
return None, None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SpliceGRPCClient:
|
|
50
|
+
"""Async gRPC client for Splice desktop's App service."""
|
|
51
|
+
|
|
52
|
+
def __init__(self):
|
|
53
|
+
self.channel = None
|
|
54
|
+
self.stub = None
|
|
55
|
+
self.connected = False
|
|
56
|
+
self._port: Optional[int] = None
|
|
57
|
+
self._grpc = _try_import_grpc()
|
|
58
|
+
self._pb2, self._pb2_grpc = _try_import_protos()
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def available(self) -> bool:
|
|
62
|
+
"""True if grpcio is installed and Splice app support exists."""
|
|
63
|
+
return (
|
|
64
|
+
self._grpc is not None
|
|
65
|
+
and self._pb2 is not None
|
|
66
|
+
and os.path.isdir(_SPLICE_APP_SUPPORT)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def connect(self) -> bool:
|
|
70
|
+
"""Connect to Splice's local gRPC server. Returns True on success."""
|
|
71
|
+
if not self.available:
|
|
72
|
+
logger.info("Splice gRPC not available (grpcio missing or Splice not installed)")
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
port = self._read_port()
|
|
76
|
+
if not port:
|
|
77
|
+
logger.info("Cannot read Splice port from port.conf")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
cert_pem = self._read_cert()
|
|
81
|
+
if not cert_pem:
|
|
82
|
+
logger.info("Cannot read Splice TLS certificate")
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
grpc = self._grpc
|
|
87
|
+
credentials = grpc.ssl_channel_credentials(root_certificates=cert_pem)
|
|
88
|
+
self.channel = grpc.aio.secure_channel(
|
|
89
|
+
f"127.0.0.1:{port}", credentials
|
|
90
|
+
)
|
|
91
|
+
self.stub = self._pb2_grpc.AppStub(self.channel)
|
|
92
|
+
self._port = port
|
|
93
|
+
self.connected = True
|
|
94
|
+
logger.info(f"Connected to Splice gRPC on port {port}")
|
|
95
|
+
return True
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
logger.warning(f"Failed to connect to Splice: {exc}")
|
|
98
|
+
self.connected = False
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
async def disconnect(self):
|
|
102
|
+
"""Close the gRPC channel."""
|
|
103
|
+
if self.channel:
|
|
104
|
+
await self.channel.close()
|
|
105
|
+
self.channel = None
|
|
106
|
+
self.stub = None
|
|
107
|
+
self.connected = False
|
|
108
|
+
|
|
109
|
+
# ── Search ──────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
async def search_samples(
|
|
112
|
+
self,
|
|
113
|
+
query: str = "",
|
|
114
|
+
key: str = "",
|
|
115
|
+
chord_type: str = "",
|
|
116
|
+
bpm_min: int = 0,
|
|
117
|
+
bpm_max: int = 0,
|
|
118
|
+
tags: Optional[list[str]] = None,
|
|
119
|
+
genre: str = "",
|
|
120
|
+
sample_type: str = "",
|
|
121
|
+
sort: str = "",
|
|
122
|
+
per_page: int = 20,
|
|
123
|
+
page: int = 1,
|
|
124
|
+
purchased_only: bool = False,
|
|
125
|
+
) -> SpliceSearchResult:
|
|
126
|
+
"""Search Splice catalog. Returns ranked results with full metadata."""
|
|
127
|
+
if not self.connected:
|
|
128
|
+
return SpliceSearchResult()
|
|
129
|
+
|
|
130
|
+
pb2 = self._pb2
|
|
131
|
+
try:
|
|
132
|
+
# Build search request
|
|
133
|
+
purchased = 0 # All
|
|
134
|
+
if purchased_only:
|
|
135
|
+
purchased = 1 # OnlyPurchased
|
|
136
|
+
|
|
137
|
+
request = pb2.SearchSampleRequest(
|
|
138
|
+
SearchTerm=query,
|
|
139
|
+
Key=key.lower() if key else "",
|
|
140
|
+
ChordType=chord_type,
|
|
141
|
+
BPMMin=bpm_min,
|
|
142
|
+
BPMMax=bpm_max,
|
|
143
|
+
Tags=tags or [],
|
|
144
|
+
Genre=genre,
|
|
145
|
+
SampleType=sample_type,
|
|
146
|
+
SortFn=sort,
|
|
147
|
+
PerPage=per_page,
|
|
148
|
+
Page=page,
|
|
149
|
+
Purchased=purchased,
|
|
150
|
+
)
|
|
151
|
+
response = await self.stub.SearchSamples(request)
|
|
152
|
+
return self._parse_search_response(response)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
logger.warning(f"Splice search failed: {exc}")
|
|
155
|
+
return SpliceSearchResult()
|
|
156
|
+
|
|
157
|
+
def _parse_search_response(self, response) -> SpliceSearchResult:
|
|
158
|
+
"""Convert protobuf SearchSampleResponse to our models."""
|
|
159
|
+
samples = []
|
|
160
|
+
for s in response.Samples:
|
|
161
|
+
samples.append(SpliceSample(
|
|
162
|
+
file_hash=s.FileHash,
|
|
163
|
+
filename=s.Filename,
|
|
164
|
+
local_path=s.LocalPath,
|
|
165
|
+
audio_key=s.AudioKey,
|
|
166
|
+
chord_type=s.ChordType,
|
|
167
|
+
bpm=s.BPM,
|
|
168
|
+
duration_ms=s.Duration,
|
|
169
|
+
genre=s.Genre,
|
|
170
|
+
sample_type=s.SampleType,
|
|
171
|
+
tags=list(s.Tags),
|
|
172
|
+
provider_name=s.ProviderName,
|
|
173
|
+
pack_uuid=s.PackUUID,
|
|
174
|
+
popularity=s.Popularity,
|
|
175
|
+
is_premium=s.IsPremium,
|
|
176
|
+
preview_url=s.PreviewURL,
|
|
177
|
+
waveform_url=s.WaveformURL,
|
|
178
|
+
is_downloaded=bool(s.LocalPath),
|
|
179
|
+
))
|
|
180
|
+
return SpliceSearchResult(
|
|
181
|
+
total_hits=response.TotalHits,
|
|
182
|
+
samples=samples,
|
|
183
|
+
matching_tags=dict(response.MatchingTags),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# ── Download ────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
async def download_sample(
|
|
189
|
+
self, file_hash: str, timeout: float = 30.0,
|
|
190
|
+
) -> Optional[str]:
|
|
191
|
+
"""Download a sample by file_hash. Returns local path when complete.
|
|
192
|
+
|
|
193
|
+
Costs 1 credit. Checks credit floor before downloading.
|
|
194
|
+
Returns None on failure.
|
|
195
|
+
"""
|
|
196
|
+
if not self.connected:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
pb2 = self._pb2
|
|
200
|
+
try:
|
|
201
|
+
# Trigger download
|
|
202
|
+
await self.stub.DownloadSample(
|
|
203
|
+
pb2.DownloadSampleRequest(FileHash=file_hash)
|
|
204
|
+
)
|
|
205
|
+
# Wait for file to appear on disk
|
|
206
|
+
return await self._wait_for_download(file_hash, timeout)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
logger.warning(f"Splice download failed for {file_hash}: {exc}")
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
async def _wait_for_download(
|
|
212
|
+
self, file_hash: str, timeout: float,
|
|
213
|
+
) -> Optional[str]:
|
|
214
|
+
"""Poll SampleInfo until LocalPath is populated."""
|
|
215
|
+
pb2 = self._pb2
|
|
216
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
217
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
218
|
+
try:
|
|
219
|
+
response = await self.stub.SampleInfo(
|
|
220
|
+
pb2.SampleInfoRequest(FileHash=file_hash)
|
|
221
|
+
)
|
|
222
|
+
if response.Sample.LocalPath:
|
|
223
|
+
return response.Sample.LocalPath
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
await asyncio.sleep(0.5)
|
|
227
|
+
logger.warning(f"Download timed out for {file_hash}")
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
# ── Sample Info ─────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
async def get_sample_info(self, file_hash: str) -> Optional[SpliceSample]:
|
|
233
|
+
"""Get metadata for a specific sample."""
|
|
234
|
+
if not self.connected:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
pb2 = self._pb2
|
|
238
|
+
try:
|
|
239
|
+
response = await self.stub.SampleInfo(
|
|
240
|
+
pb2.SampleInfoRequest(FileHash=file_hash)
|
|
241
|
+
)
|
|
242
|
+
s = response.Sample
|
|
243
|
+
return SpliceSample(
|
|
244
|
+
file_hash=s.FileHash,
|
|
245
|
+
filename=s.Filename,
|
|
246
|
+
local_path=s.LocalPath,
|
|
247
|
+
audio_key=s.AudioKey,
|
|
248
|
+
chord_type=s.ChordType,
|
|
249
|
+
bpm=s.BPM,
|
|
250
|
+
duration_ms=s.Duration,
|
|
251
|
+
genre=s.Genre,
|
|
252
|
+
sample_type=s.SampleType,
|
|
253
|
+
tags=list(s.Tags),
|
|
254
|
+
provider_name=s.ProviderName,
|
|
255
|
+
pack_uuid=s.PackUUID,
|
|
256
|
+
is_downloaded=bool(s.LocalPath),
|
|
257
|
+
)
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
logger.warning(f"SampleInfo failed: {exc}")
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
# ── Credits ─────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
async def get_credits(self) -> SpliceCredits:
|
|
265
|
+
"""Get current credit balance and user info."""
|
|
266
|
+
if not self.connected:
|
|
267
|
+
return SpliceCredits()
|
|
268
|
+
|
|
269
|
+
pb2 = self._pb2
|
|
270
|
+
try:
|
|
271
|
+
response = await self.stub.ValidateLogin(
|
|
272
|
+
pb2.ValidateLoginRequest()
|
|
273
|
+
)
|
|
274
|
+
return SpliceCredits(
|
|
275
|
+
credits=response.User.Credits,
|
|
276
|
+
username=response.User.Username,
|
|
277
|
+
plan=response.User.SoundsStatus,
|
|
278
|
+
)
|
|
279
|
+
except Exception as exc:
|
|
280
|
+
logger.warning(f"Credit check failed: {exc}")
|
|
281
|
+
return SpliceCredits()
|
|
282
|
+
|
|
283
|
+
async def can_afford(self, credits_needed: int, budget: int) -> tuple[bool, int]:
|
|
284
|
+
"""Check if we can afford credits_needed within budget.
|
|
285
|
+
|
|
286
|
+
Returns (can_afford, credits_remaining).
|
|
287
|
+
"""
|
|
288
|
+
info = await self.get_credits()
|
|
289
|
+
remaining = info.credits
|
|
290
|
+
can = (
|
|
291
|
+
remaining > CREDIT_HARD_FLOOR
|
|
292
|
+
and credits_needed <= budget
|
|
293
|
+
and credits_needed <= (remaining - CREDIT_HARD_FLOOR)
|
|
294
|
+
)
|
|
295
|
+
return can, remaining
|
|
296
|
+
|
|
297
|
+
# ── Sync ────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
async def sync_sounds(self) -> bool:
|
|
300
|
+
"""Trigger a full Splice library sync."""
|
|
301
|
+
if not self.connected:
|
|
302
|
+
return False
|
|
303
|
+
pb2 = self._pb2
|
|
304
|
+
try:
|
|
305
|
+
await self.stub.SyncSounds(pb2.SyncSoundsRequest())
|
|
306
|
+
return True
|
|
307
|
+
except Exception:
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
# ── Connection Helpers ──────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
def _read_port(self) -> Optional[int]:
|
|
313
|
+
"""Read Splice's current gRPC port from port.conf."""
|
|
314
|
+
port_file = os.path.join(_SPLICE_APP_SUPPORT, "port.conf")
|
|
315
|
+
if not os.path.isfile(port_file):
|
|
316
|
+
return None
|
|
317
|
+
try:
|
|
318
|
+
with open(port_file) as f:
|
|
319
|
+
content = f.read().strip()
|
|
320
|
+
# Format: "127.0.0.1:56765" or just "56765"
|
|
321
|
+
if ":" in content:
|
|
322
|
+
return int(content.split(":")[-1])
|
|
323
|
+
return int(content)
|
|
324
|
+
except (ValueError, OSError):
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
def _read_cert(self) -> Optional[bytes]:
|
|
328
|
+
"""Read Splice's self-signed TLS certificate."""
|
|
329
|
+
# Search in user-specific directories
|
|
330
|
+
patterns = [
|
|
331
|
+
os.path.join(_SPLICE_APP_SUPPORT, ".certs", "cert.pem"),
|
|
332
|
+
os.path.join(_SPLICE_APP_SUPPORT, "certs", "cert.pem"),
|
|
333
|
+
]
|
|
334
|
+
# Also try user-specific paths
|
|
335
|
+
user_patterns = glob.glob(
|
|
336
|
+
os.path.join(_SPLICE_APP_SUPPORT, "users", "*", ".certs", "cert.pem")
|
|
337
|
+
)
|
|
338
|
+
patterns.extend(user_patterns)
|
|
339
|
+
|
|
340
|
+
for path in patterns:
|
|
341
|
+
if os.path.isfile(path):
|
|
342
|
+
try:
|
|
343
|
+
with open(path, "rb") as f:
|
|
344
|
+
return f.read()
|
|
345
|
+
except OSError:
|
|
346
|
+
continue
|
|
347
|
+
return None
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Splice client data models — Python representations of Splice gRPC messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SpliceSample:
|
|
11
|
+
"""A sample from the Splice catalog or local library."""
|
|
12
|
+
|
|
13
|
+
file_hash: str = ""
|
|
14
|
+
filename: str = ""
|
|
15
|
+
local_path: str = "" # empty if not downloaded
|
|
16
|
+
audio_key: str = "" # lowercase: "c#", "a", "eb"
|
|
17
|
+
chord_type: str = "" # "major", "minor", ""
|
|
18
|
+
bpm: int = 0
|
|
19
|
+
duration_ms: int = 0
|
|
20
|
+
genre: str = ""
|
|
21
|
+
sample_type: str = "" # "loop" or "oneshot"
|
|
22
|
+
tags: list[str] = field(default_factory=list)
|
|
23
|
+
provider_name: str = ""
|
|
24
|
+
pack_uuid: str = ""
|
|
25
|
+
popularity: int = 0
|
|
26
|
+
is_premium: bool = False
|
|
27
|
+
preview_url: str = ""
|
|
28
|
+
waveform_url: str = ""
|
|
29
|
+
is_downloaded: bool = False
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def key_display(self) -> str:
|
|
33
|
+
"""Normalized key: 'c#' + 'minor' → 'C#m'."""
|
|
34
|
+
if not self.audio_key:
|
|
35
|
+
return ""
|
|
36
|
+
key = self.audio_key[0].upper() + self.audio_key[1:]
|
|
37
|
+
if self.chord_type.lower() in ("minor", "min"):
|
|
38
|
+
key += "m"
|
|
39
|
+
return key
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def duration_seconds(self) -> float:
|
|
43
|
+
return self.duration_ms / 1000.0 if self.duration_ms else 0.0
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict:
|
|
46
|
+
return {
|
|
47
|
+
"file_hash": self.file_hash,
|
|
48
|
+
"filename": self.filename,
|
|
49
|
+
"local_path": self.local_path,
|
|
50
|
+
"key": self.key_display,
|
|
51
|
+
"audio_key_raw": self.audio_key,
|
|
52
|
+
"chord_type": self.chord_type,
|
|
53
|
+
"bpm": self.bpm,
|
|
54
|
+
"duration": self.duration_seconds,
|
|
55
|
+
"genre": self.genre,
|
|
56
|
+
"sample_type": self.sample_type,
|
|
57
|
+
"tags": self.tags,
|
|
58
|
+
"provider": self.provider_name,
|
|
59
|
+
"pack_uuid": self.pack_uuid,
|
|
60
|
+
"popularity": self.popularity,
|
|
61
|
+
"is_downloaded": self.is_downloaded,
|
|
62
|
+
"is_premium": self.is_premium,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class SpliceSearchResult:
|
|
68
|
+
"""Result from a Splice catalog search."""
|
|
69
|
+
|
|
70
|
+
total_hits: int = 0
|
|
71
|
+
samples: list[SpliceSample] = field(default_factory=list)
|
|
72
|
+
matching_tags: dict[str, int] = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict:
|
|
75
|
+
return {
|
|
76
|
+
"total_hits": self.total_hits,
|
|
77
|
+
"sample_count": len(self.samples),
|
|
78
|
+
"samples": [s.to_dict() for s in self.samples],
|
|
79
|
+
"matching_tags": self.matching_tags,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class SpliceCredits:
|
|
85
|
+
"""User credit status."""
|
|
86
|
+
|
|
87
|
+
credits: int = 0
|
|
88
|
+
username: str = ""
|
|
89
|
+
plan: str = ""
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict:
|
|
92
|
+
return {
|
|
93
|
+
"credits": self.credits,
|
|
94
|
+
"username": self.username,
|
|
95
|
+
"plan": self.plan,
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generated protobuf stubs for Splice gRPC API."""
|