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
@@ -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."""