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
package/mcp_server/server.py
CHANGED
|
@@ -40,13 +40,60 @@ def _identify_port_holder(port: int) -> str | None:
|
|
|
40
40
|
return None
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _master_has_livepilot_analyzer(ableton: AbletonConnection) -> bool:
|
|
44
|
+
"""Check whether the analyzer device is currently on the master track."""
|
|
45
|
+
try:
|
|
46
|
+
track = ableton.send_command("get_master_track")
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
devices = track.get("devices", []) if isinstance(track, dict) else []
|
|
51
|
+
for device in devices:
|
|
52
|
+
normalized = " ".join(
|
|
53
|
+
str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
|
|
54
|
+
)
|
|
55
|
+
if normalized == "livepilot analyzer":
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _warm_analyzer_bridge(
|
|
61
|
+
ableton: AbletonConnection,
|
|
62
|
+
spectral: SpectralCache,
|
|
63
|
+
timeout: float = 3.0,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Give the analyzer stream a short startup window before first use."""
|
|
66
|
+
if not _master_has_livepilot_analyzer(ableton):
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
loop = asyncio.get_running_loop()
|
|
70
|
+
deadline = loop.time() + max(timeout, 0.0)
|
|
71
|
+
while loop.time() < deadline:
|
|
72
|
+
if spectral.is_connected:
|
|
73
|
+
return
|
|
74
|
+
await asyncio.sleep(0.05)
|
|
75
|
+
|
|
76
|
+
|
|
43
77
|
@asynccontextmanager
|
|
44
78
|
async def lifespan(server):
|
|
45
|
-
"""Create and yield the shared AbletonConnection + M4L bridge."""
|
|
79
|
+
"""Create and yield the shared AbletonConnection + M4L bridge + registries."""
|
|
80
|
+
from .runtime.mcp_dispatch import build_mcp_dispatch_registry
|
|
81
|
+
from .splice_client.client import SpliceGRPCClient
|
|
82
|
+
|
|
46
83
|
ableton = AbletonConnection()
|
|
47
84
|
spectral = SpectralCache()
|
|
48
85
|
receiver = SpectralReceiver(spectral)
|
|
49
86
|
m4l = M4LBridge(spectral, receiver)
|
|
87
|
+
mcp_dispatch = build_mcp_dispatch_registry()
|
|
88
|
+
|
|
89
|
+
# Splice gRPC client — graceful degradation if Splice desktop isn't
|
|
90
|
+
# running or grpcio isn't installed. .connected will be False in that
|
|
91
|
+
# case and sample_resolver treats it as "no splice hits".
|
|
92
|
+
splice_client = SpliceGRPCClient()
|
|
93
|
+
try:
|
|
94
|
+
await splice_client.connect()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass # client remains in disconnected state
|
|
50
97
|
|
|
51
98
|
# Start UDP listener for incoming M4L spectral data (port 9880)
|
|
52
99
|
loop = asyncio.get_running_loop()
|
|
@@ -80,17 +127,25 @@ async def lifespan(server):
|
|
|
80
127
|
}
|
|
81
128
|
|
|
82
129
|
try:
|
|
130
|
+
if bridge_state["transport"] is not None:
|
|
131
|
+
await _warm_analyzer_bridge(ableton, spectral)
|
|
83
132
|
yield {
|
|
84
133
|
"ableton": ableton,
|
|
85
134
|
"spectral": spectral,
|
|
86
135
|
"m4l": m4l,
|
|
87
136
|
"_bridge_state": bridge_state,
|
|
137
|
+
"mcp_dispatch": mcp_dispatch,
|
|
138
|
+
"splice_client": splice_client,
|
|
88
139
|
}
|
|
89
140
|
finally:
|
|
90
141
|
if bridge_state["transport"]:
|
|
91
142
|
bridge_state["transport"].close()
|
|
92
143
|
m4l.close()
|
|
93
144
|
ableton.disconnect()
|
|
145
|
+
try:
|
|
146
|
+
await splice_client.disconnect()
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
94
149
|
|
|
95
150
|
|
|
96
151
|
mcp = FastMCP("LivePilot", lifespan=lifespan)
|
|
@@ -140,6 +195,10 @@ from .stuckness_detector import tools as stuckness_tools # noqa: F401, E40
|
|
|
140
195
|
from .wonder_mode import tools as wonder_mode_tools # noqa: F401, E402
|
|
141
196
|
from .session_continuity import tools as session_cont_tools # noqa: F401, E402
|
|
142
197
|
from .creative_constraints import tools as constraints_tools # noqa: F401, E402
|
|
198
|
+
from .device_forge import tools as device_forge_tools # noqa: F401, E402
|
|
199
|
+
from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
|
|
200
|
+
from .atlas import tools as atlas_tools # noqa: F401, E402
|
|
201
|
+
from .composer import tools as composer_tools # noqa: F401, E402
|
|
143
202
|
|
|
144
203
|
|
|
145
204
|
# ---------------------------------------------------------------------------
|
|
@@ -170,6 +229,14 @@ def _coerce_schema_property(prop: dict) -> None:
|
|
|
170
229
|
# Recurse into array items so list[int]/list[float] params also accept strings
|
|
171
230
|
if "items" in prop and isinstance(prop["items"], dict):
|
|
172
231
|
_coerce_schema_property(prop["items"])
|
|
232
|
+
if "properties" in prop and isinstance(prop["properties"], dict):
|
|
233
|
+
for nested in prop["properties"].values():
|
|
234
|
+
if isinstance(nested, dict):
|
|
235
|
+
_coerce_schema_property(nested)
|
|
236
|
+
if "$defs" in prop and isinstance(prop["$defs"], dict):
|
|
237
|
+
for nested in prop["$defs"].values():
|
|
238
|
+
if isinstance(nested, dict):
|
|
239
|
+
_coerce_schema_property(nested)
|
|
173
240
|
|
|
174
241
|
|
|
175
242
|
def _get_all_tools():
|
|
@@ -202,6 +269,9 @@ def _patch_tool_schemas() -> None:
|
|
|
202
269
|
if name == "ctx":
|
|
203
270
|
continue # skip the Context parameter
|
|
204
271
|
_coerce_schema_property(prop)
|
|
272
|
+
for definition in tool.parameters.get("$defs", {}).values():
|
|
273
|
+
if isinstance(definition, dict):
|
|
274
|
+
_coerce_schema_property(definition)
|
|
205
275
|
|
|
206
276
|
|
|
207
277
|
_patch_tool_schemas()
|
|
@@ -210,7 +210,10 @@ def rank_by_taste_and_identity(
|
|
|
210
210
|
identity_effect = candidate.get("identity_effect", "preserves")
|
|
211
211
|
|
|
212
212
|
# Taste score — how well does this fit cross-session preferences?
|
|
213
|
-
|
|
213
|
+
# Routed through the canonical accessor so dimension_weights.transition_boldness
|
|
214
|
+
# is honored. Previously read the top-level key directly and always got 0.5.
|
|
215
|
+
from ..memory.taste_accessors import get_dimension_pref
|
|
216
|
+
boldness_pref = get_dimension_pref(taste_graph, "transition_boldness", default=0.5)
|
|
214
217
|
taste_score = 1.0 - abs(novelty - boldness_pref) * 0.8
|
|
215
218
|
taste_score = round(max(0.0, min(1.0, taste_score)), 3)
|
|
216
219
|
|
|
@@ -281,17 +281,105 @@ def run_layer_overlap_critic(
|
|
|
281
281
|
return issues
|
|
282
282
|
|
|
283
283
|
|
|
284
|
+
# ── Corpus Intelligence Critic ──────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def run_corpus_critic(
|
|
288
|
+
patch: PatchModel,
|
|
289
|
+
goal: TimbralGoalVector,
|
|
290
|
+
) -> list[SoundDesignIssue]:
|
|
291
|
+
"""Use the device-knowledge corpus to flag missed opportunities.
|
|
292
|
+
|
|
293
|
+
Checks each device in the chain against the corpus for known
|
|
294
|
+
techniques, parameter sweet spots, and creative possibilities
|
|
295
|
+
that the current patch doesn't exploit.
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
from ..corpus import get_corpus
|
|
299
|
+
except ImportError:
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
corpus = get_corpus()
|
|
303
|
+
if not corpus.emotional_recipes and not corpus.device_knowledge:
|
|
304
|
+
return [] # Corpus not loaded
|
|
305
|
+
|
|
306
|
+
issues: list[SoundDesignIssue] = []
|
|
307
|
+
|
|
308
|
+
# Check if any device in the chain has corpus knowledge
|
|
309
|
+
for block in patch.blocks:
|
|
310
|
+
dk = corpus.get_device(block.device_name)
|
|
311
|
+
if dk and dk.techniques and block.block_type == "oscillator":
|
|
312
|
+
# Oscillator with known techniques — suggest if patch is simple
|
|
313
|
+
has_character_block = any(
|
|
314
|
+
b.block_type in ("saturation", "spectral")
|
|
315
|
+
for b in patch.blocks
|
|
316
|
+
)
|
|
317
|
+
if not has_character_block and len(dk.techniques) > 2:
|
|
318
|
+
issues.append(SoundDesignIssue(
|
|
319
|
+
issue_type="corpus_technique_available",
|
|
320
|
+
critic="corpus",
|
|
321
|
+
severity=0.25,
|
|
322
|
+
confidence=0.6,
|
|
323
|
+
affected_blocks=[block.device_name],
|
|
324
|
+
evidence=(
|
|
325
|
+
f"Corpus has {len(dk.techniques)} known techniques "
|
|
326
|
+
f"for {block.device_name} but chain lacks character "
|
|
327
|
+
f"processing (saturation/spectral). First technique: "
|
|
328
|
+
f"{dk.techniques[0][:80]}"
|
|
329
|
+
),
|
|
330
|
+
recommended_moves=["modulation_injection", "filter_contour"],
|
|
331
|
+
))
|
|
332
|
+
|
|
333
|
+
# Check if goal maps to a known emotional recipe
|
|
334
|
+
emotion_map = {
|
|
335
|
+
"warmth": ("warmth", goal.warmth),
|
|
336
|
+
"brightness": ("euphoria", goal.brightness),
|
|
337
|
+
"instability": ("tension", goal.instability),
|
|
338
|
+
"softness": ("melancholy", goal.softness),
|
|
339
|
+
}
|
|
340
|
+
for quality, (emotion_key, goal_value) in emotion_map.items():
|
|
341
|
+
if goal_value > 0.3:
|
|
342
|
+
recipe = corpus.suggest_for_emotion(emotion_key)
|
|
343
|
+
if recipe and recipe.techniques:
|
|
344
|
+
# Check if any corpus technique device is in the chain
|
|
345
|
+
chain_names_lower = {d.lower() for d in patch.device_chain}
|
|
346
|
+
recipe_devices = set()
|
|
347
|
+
for tech in recipe.techniques:
|
|
348
|
+
# Extract bold device names from technique strings
|
|
349
|
+
for match in re.finditer(r"\*\*(.+?)\*\*", tech):
|
|
350
|
+
recipe_devices.add(match.group(1).lower())
|
|
351
|
+
|
|
352
|
+
missing = recipe_devices - chain_names_lower
|
|
353
|
+
if missing and len(missing) <= 3:
|
|
354
|
+
issues.append(SoundDesignIssue(
|
|
355
|
+
issue_type="corpus_emotion_opportunity",
|
|
356
|
+
critic="corpus",
|
|
357
|
+
severity=0.2,
|
|
358
|
+
confidence=0.5,
|
|
359
|
+
affected_blocks=list(missing)[:3],
|
|
360
|
+
evidence=(
|
|
361
|
+
f"Goal wants {quality}={goal_value:.2f}. "
|
|
362
|
+
f"Corpus '{recipe.emotion}' recipe suggests "
|
|
363
|
+
f"devices not in chain: {', '.join(list(missing)[:3])}"
|
|
364
|
+
),
|
|
365
|
+
recommended_moves=["filter_contour", "modulation_injection"],
|
|
366
|
+
))
|
|
367
|
+
|
|
368
|
+
return issues
|
|
369
|
+
|
|
370
|
+
|
|
284
371
|
# ── Run all critics ──────────────────────────────────────────────────
|
|
285
372
|
|
|
286
373
|
|
|
287
374
|
def run_all_sound_design_critics(
|
|
288
375
|
state: SoundDesignState,
|
|
289
376
|
) -> list[SoundDesignIssue]:
|
|
290
|
-
"""Run all
|
|
377
|
+
"""Run all six critics and aggregate issues."""
|
|
291
378
|
issues: list[SoundDesignIssue] = []
|
|
292
379
|
issues.extend(run_static_timbre_critic(state.patch, state.goal))
|
|
293
380
|
issues.extend(run_weak_identity_critic(state.patch))
|
|
294
381
|
issues.extend(run_masking_role_critic(state.patch, state.layers))
|
|
295
382
|
issues.extend(run_modulation_flatness_critic(state.patch))
|
|
296
383
|
issues.extend(run_layer_overlap_critic(state.layers))
|
|
384
|
+
issues.extend(run_corpus_critic(state.patch, state.goal))
|
|
297
385
|
return issues
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Splice gRPC client — connect to Splice desktop's local API for sample search and download."""
|
|
@@ -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."""
|