livepilot 1.27.2 → 1.27.3
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/CHANGELOG.md +66 -0
- package/README.md +10 -7
- package/bin/livepilot.js +97 -39
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +4 -3
- package/mcp_server/connection.py +67 -9
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +69 -7
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +51 -1
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +26 -24
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- package/server.json +2 -2
|
@@ -20,7 +20,7 @@ Plan-aware gating (see `project_splice_subscription_model.md`):
|
|
|
20
20
|
(100/day) rather than credits. `can_download_sample()` checks both
|
|
21
21
|
the daily quota AND the credit floor, choosing the right budget for
|
|
22
22
|
the user's actual plan.
|
|
23
|
-
- Free samples (`Sample.IsPremium == False`
|
|
23
|
+
- Free samples (`Sample.IsPremium == False`) bypass
|
|
24
24
|
gating entirely — they're free under any plan.
|
|
25
25
|
"""
|
|
26
26
|
|
|
@@ -156,6 +156,14 @@ class SpliceGRPCClient:
|
|
|
156
156
|
self._quota = quota_tracker or get_tracker()
|
|
157
157
|
# Cached plan state — refreshed on every explicit get_credits() call.
|
|
158
158
|
self._cached_credits: Optional[SpliceCredits] = None
|
|
159
|
+
# Connection-health state. `connected=True` only proves the local
|
|
160
|
+
# gRPC channel object constructed — it does NOT prove Splice
|
|
161
|
+
# desktop is reachable. Real failures surface on the first RPC.
|
|
162
|
+
# `degraded` records that transition so the next call knows to
|
|
163
|
+
# retry `connect()` once instead of trusting a stale `connected`
|
|
164
|
+
# flag forever (see `_mark_degraded` / `_ensure_connected`).
|
|
165
|
+
self.degraded = False
|
|
166
|
+
self.last_error: Optional[str] = None
|
|
159
167
|
|
|
160
168
|
@property
|
|
161
169
|
def available(self) -> bool:
|
|
@@ -191,11 +199,16 @@ class SpliceGRPCClient:
|
|
|
191
199
|
self.stub = self._pb2_grpc.AppStub(self.channel)
|
|
192
200
|
self._port = port
|
|
193
201
|
self.connected = True
|
|
202
|
+
# A fresh successful connect clears any prior degradation —
|
|
203
|
+
# this is the "one reconnect attempt" landing.
|
|
204
|
+
self.degraded = False
|
|
205
|
+
self.last_error = None
|
|
194
206
|
logger.info(f"Connected to Splice gRPC on port {port}")
|
|
195
207
|
return True
|
|
196
208
|
except Exception as exc:
|
|
197
209
|
logger.warning(f"Failed to connect to Splice: {exc}")
|
|
198
210
|
self.connected = False
|
|
211
|
+
self.last_error = str(exc)
|
|
199
212
|
return False
|
|
200
213
|
|
|
201
214
|
async def disconnect(self):
|
|
@@ -206,6 +219,38 @@ class SpliceGRPCClient:
|
|
|
206
219
|
self.stub = None
|
|
207
220
|
self.connected = False
|
|
208
221
|
|
|
222
|
+
def _mark_degraded(self, exc: Exception) -> None:
|
|
223
|
+
"""Record an RPC failure and drop the (falsely optimistic) `connected` flag.
|
|
224
|
+
|
|
225
|
+
`connect()` only proves the local channel object constructed, not
|
|
226
|
+
that Splice desktop is actually listening — that only surfaces on
|
|
227
|
+
the first real RPC. Without this, `connected` stayed True forever
|
|
228
|
+
after a Splice restart and every wrapper kept returning empty
|
|
229
|
+
defaults indistinguishable from genuine zero-result state for the
|
|
230
|
+
rest of the process lifetime. Callers can inspect `self.degraded`
|
|
231
|
+
/ `self.last_error` to tell "connection is unhealthy" apart from
|
|
232
|
+
"legitimately found nothing".
|
|
233
|
+
"""
|
|
234
|
+
self.degraded = True
|
|
235
|
+
self.last_error = str(exc)
|
|
236
|
+
self.connected = False
|
|
237
|
+
|
|
238
|
+
async def _ensure_connected(self) -> bool:
|
|
239
|
+
"""True if the client is (or becomes) usable for an RPC call.
|
|
240
|
+
|
|
241
|
+
Retries `connect()` exactly once after a prior degradation —
|
|
242
|
+
cheap, since `connect()` only re-reads port.conf/cert and
|
|
243
|
+
rebuilds the local channel object, it doesn't itself probe the
|
|
244
|
+
network. A client that was never connected (and never marked
|
|
245
|
+
degraded) returns False immediately, matching the historical
|
|
246
|
+
"not connected → return default" behavior.
|
|
247
|
+
"""
|
|
248
|
+
if self.connected:
|
|
249
|
+
return True
|
|
250
|
+
if not getattr(self, "degraded", False):
|
|
251
|
+
return False
|
|
252
|
+
return await self.connect()
|
|
253
|
+
|
|
209
254
|
# ── Search ──────────────────────────────────────────────────────
|
|
210
255
|
|
|
211
256
|
async def search_samples(
|
|
@@ -237,7 +282,7 @@ class SpliceGRPCClient:
|
|
|
237
282
|
composition where role-correctness matters more than text-match
|
|
238
283
|
on free-form query strings (BUG-FULL-MODE-9, 2026-05-01).
|
|
239
284
|
"""
|
|
240
|
-
if not self.
|
|
285
|
+
if not await self._ensure_connected():
|
|
241
286
|
return SpliceSearchResult()
|
|
242
287
|
|
|
243
288
|
pb2 = self._pb2
|
|
@@ -267,6 +312,7 @@ class SpliceGRPCClient:
|
|
|
267
312
|
return self._parse_search_response(response)
|
|
268
313
|
except Exception as exc:
|
|
269
314
|
logger.warning(f"Splice search failed: {exc}")
|
|
315
|
+
self._mark_degraded(exc)
|
|
270
316
|
return SpliceSearchResult()
|
|
271
317
|
|
|
272
318
|
def _parse_search_response(self, response) -> SpliceSearchResult:
|
|
@@ -317,7 +363,7 @@ class SpliceGRPCClient:
|
|
|
317
363
|
When not known we do NOT fetch the sample — that would waste
|
|
318
364
|
a SampleInfo round-trip. Unknown samples default to paid.
|
|
319
365
|
"""
|
|
320
|
-
if not self.
|
|
366
|
+
if not await self._ensure_connected():
|
|
321
367
|
return DownloadDecision(
|
|
322
368
|
allowed=False,
|
|
323
369
|
reason="Splice desktop app not reachable",
|
|
@@ -325,12 +371,17 @@ class SpliceGRPCClient:
|
|
|
325
371
|
gating_mode="blocked",
|
|
326
372
|
)
|
|
327
373
|
|
|
374
|
+
logger.debug(
|
|
375
|
+
"decide_download: file_hash=%s sample=%s",
|
|
376
|
+
file_hash, sample.filename if sample else "<unknown>",
|
|
377
|
+
)
|
|
378
|
+
|
|
328
379
|
# Fast path: free samples bypass every gate.
|
|
329
380
|
if sample is not None and sample.is_free:
|
|
330
381
|
return DownloadDecision(
|
|
331
382
|
allowed=True,
|
|
332
383
|
reason=(
|
|
333
|
-
"Sample is free (
|
|
384
|
+
"Sample is free (not premium) — no credit or "
|
|
334
385
|
"quota cost under any plan."
|
|
335
386
|
),
|
|
336
387
|
plan_kind=(
|
|
@@ -346,32 +397,46 @@ class SpliceGRPCClient:
|
|
|
346
397
|
plan = credits.plan_kind
|
|
347
398
|
|
|
348
399
|
if plan == PlanKind.ABLETON_LIVE:
|
|
349
|
-
|
|
350
|
-
|
|
400
|
+
# `check_budget` reads would_exceed/near_limit/at_limit from a
|
|
401
|
+
# SINGLE lock-protected snapshot (see quota.py) instead of the
|
|
402
|
+
# stale `summary()["at_limit"]` check, which never consulted
|
|
403
|
+
# the would_exceed/near_limit predicates that already existed
|
|
404
|
+
# for exactly this purpose. Note this is still a check-then-act
|
|
405
|
+
# against the eventual `record_download()` call — that gap is
|
|
406
|
+
# inherent (the actual download is a network round-trip away)
|
|
407
|
+
# and this is a client-side warning, not a hard server limit.
|
|
408
|
+
budget = self._quota.check_budget(additional=1)
|
|
409
|
+
if budget["would_exceed"]:
|
|
351
410
|
return DownloadDecision(
|
|
352
411
|
allowed=False,
|
|
353
412
|
reason=(
|
|
354
|
-
f"Daily quota hit ({
|
|
355
|
-
f"{
|
|
413
|
+
f"Daily quota hit ({budget['used_today']}/"
|
|
414
|
+
f"{budget['daily_limit']}). Resets at UTC midnight."
|
|
356
415
|
),
|
|
357
416
|
plan_kind=plan,
|
|
358
417
|
gating_mode="daily_quota",
|
|
359
418
|
credits_remaining=credits.credits,
|
|
360
|
-
quota_used=
|
|
361
|
-
quota_remaining=
|
|
419
|
+
quota_used=budget["used_today"],
|
|
420
|
+
quota_remaining=budget["remaining_today"],
|
|
421
|
+
)
|
|
422
|
+
reason = (
|
|
423
|
+
f"Ableton Live plan: {budget['remaining_today']} of "
|
|
424
|
+
f"{budget['daily_limit']} daily samples remain. Download "
|
|
425
|
+
"will NOT deplete your 80 Splice.com credits."
|
|
426
|
+
)
|
|
427
|
+
if budget["near_limit"]:
|
|
428
|
+
reason += (
|
|
429
|
+
" Approaching the daily cap — consider "
|
|
430
|
+
"splice_preview_sample for further auditions today."
|
|
362
431
|
)
|
|
363
432
|
return DownloadDecision(
|
|
364
433
|
allowed=True,
|
|
365
|
-
reason=
|
|
366
|
-
f"Ableton Live plan: {quota['remaining_today']} of "
|
|
367
|
-
f"{quota['daily_limit']} daily samples remain. Download "
|
|
368
|
-
"will NOT deplete your 80 Splice.com credits."
|
|
369
|
-
),
|
|
434
|
+
reason=reason,
|
|
370
435
|
plan_kind=plan,
|
|
371
436
|
gating_mode="daily_quota",
|
|
372
437
|
credits_remaining=credits.credits,
|
|
373
|
-
quota_used=
|
|
374
|
-
quota_remaining=
|
|
438
|
+
quota_used=budget["used_today"],
|
|
439
|
+
quota_remaining=budget["remaining_today"],
|
|
375
440
|
)
|
|
376
441
|
|
|
377
442
|
# Credit-metered plans (SOUNDS_PLUS, CREATOR, CREATOR_PLUS, UNKNOWN).
|
|
@@ -418,14 +483,15 @@ class SpliceGRPCClient:
|
|
|
418
483
|
imperative "go download it" path; the decision is repeated here
|
|
419
484
|
defensively because a future caller might forget to gate.
|
|
420
485
|
"""
|
|
421
|
-
if not self.
|
|
486
|
+
if not await self._ensure_connected():
|
|
422
487
|
return None
|
|
423
488
|
|
|
424
489
|
decision = await self.decide_download(file_hash, sample=sample)
|
|
425
490
|
if not decision.allowed:
|
|
426
491
|
logger.warning(
|
|
427
|
-
"Splice download refused: %s (plan=%s, mode=%s)",
|
|
428
|
-
decision.reason, decision.plan_kind.value,
|
|
492
|
+
"Splice download refused for %s: %s (plan=%s, mode=%s)",
|
|
493
|
+
file_hash, decision.reason, decision.plan_kind.value,
|
|
494
|
+
decision.gating_mode,
|
|
429
495
|
)
|
|
430
496
|
return None
|
|
431
497
|
|
|
@@ -440,6 +506,7 @@ class SpliceGRPCClient:
|
|
|
440
506
|
local_path = await self._wait_for_download(file_hash, timeout)
|
|
441
507
|
except Exception as exc:
|
|
442
508
|
logger.warning(f"Splice download failed for {file_hash}: {exc}")
|
|
509
|
+
self._mark_degraded(exc)
|
|
443
510
|
return None
|
|
444
511
|
|
|
445
512
|
if local_path is None:
|
|
@@ -484,7 +551,7 @@ class SpliceGRPCClient:
|
|
|
484
551
|
|
|
485
552
|
async def get_sample_info(self, file_hash: str) -> Optional[SpliceSample]:
|
|
486
553
|
"""Get metadata for a specific sample."""
|
|
487
|
-
if not self.
|
|
554
|
+
if not await self._ensure_connected():
|
|
488
555
|
return None
|
|
489
556
|
|
|
490
557
|
pb2 = self._pb2
|
|
@@ -496,13 +563,14 @@ class SpliceGRPCClient:
|
|
|
496
563
|
return self._parse_sample(response.Sample)
|
|
497
564
|
except Exception as exc:
|
|
498
565
|
logger.warning(f"SampleInfo failed: {exc}")
|
|
566
|
+
self._mark_degraded(exc)
|
|
499
567
|
return None
|
|
500
568
|
|
|
501
569
|
# ── Credits + Plan ──────────────────────────────────────────────
|
|
502
570
|
|
|
503
571
|
async def get_credits(self) -> SpliceCredits:
|
|
504
572
|
"""Get current credit balance, plan, and feature-flag map."""
|
|
505
|
-
if not self.
|
|
573
|
+
if not await self._ensure_connected():
|
|
506
574
|
return SpliceCredits()
|
|
507
575
|
|
|
508
576
|
pb2 = self._pb2
|
|
@@ -540,6 +608,7 @@ class SpliceGRPCClient:
|
|
|
540
608
|
return creds
|
|
541
609
|
except Exception as exc:
|
|
542
610
|
logger.warning(f"Credit check failed: {exc}")
|
|
611
|
+
self._mark_degraded(exc)
|
|
543
612
|
return SpliceCredits()
|
|
544
613
|
|
|
545
614
|
async def can_afford(self, credits_needed: int, budget: int) -> tuple[bool, int]:
|
|
@@ -563,7 +632,7 @@ class SpliceGRPCClient:
|
|
|
563
632
|
|
|
564
633
|
async def sync_sounds(self) -> bool:
|
|
565
634
|
"""Trigger a full Splice library sync."""
|
|
566
|
-
if not self.
|
|
635
|
+
if not await self._ensure_connected():
|
|
567
636
|
return False
|
|
568
637
|
pb2 = self._pb2
|
|
569
638
|
try:
|
|
@@ -574,6 +643,7 @@ class SpliceGRPCClient:
|
|
|
574
643
|
return True
|
|
575
644
|
except Exception as exc:
|
|
576
645
|
logger.debug("sync_sounds failed: %s", exc)
|
|
646
|
+
self._mark_degraded(exc)
|
|
577
647
|
return False
|
|
578
648
|
|
|
579
649
|
# ── Collections ─────────────────────────────────────────────────
|
|
@@ -609,7 +679,7 @@ class SpliceGRPCClient:
|
|
|
609
679
|
self, page: int = 1, per_page: int = 50,
|
|
610
680
|
) -> tuple[int, list[SpliceCollection]]:
|
|
611
681
|
"""List the user's collections. Returns (total_count, collections)."""
|
|
612
|
-
if not self.
|
|
682
|
+
if not await self._ensure_connected():
|
|
613
683
|
return 0, []
|
|
614
684
|
pb2 = self._pb2
|
|
615
685
|
try:
|
|
@@ -622,13 +692,14 @@ class SpliceGRPCClient:
|
|
|
622
692
|
return total, collections
|
|
623
693
|
except Exception as exc:
|
|
624
694
|
logger.warning(f"CollectionsList failed: {exc}")
|
|
695
|
+
self._mark_degraded(exc)
|
|
625
696
|
return 0, []
|
|
626
697
|
|
|
627
698
|
async def collection_samples(
|
|
628
699
|
self, uuid: str, page: int = 1, per_page: int = 50,
|
|
629
700
|
) -> tuple[int, list[SpliceSample]]:
|
|
630
701
|
"""List samples inside a collection. Returns (total_hits, samples)."""
|
|
631
|
-
if not self.
|
|
702
|
+
if not await self._ensure_connected():
|
|
632
703
|
return 0, []
|
|
633
704
|
pb2 = self._pb2
|
|
634
705
|
try:
|
|
@@ -643,11 +714,14 @@ class SpliceGRPCClient:
|
|
|
643
714
|
return total, samples
|
|
644
715
|
except Exception as exc:
|
|
645
716
|
logger.warning(f"CollectionListSamples failed: {exc}")
|
|
717
|
+
self._mark_degraded(exc)
|
|
646
718
|
return 0, []
|
|
647
719
|
|
|
648
720
|
async def add_to_collection(self, uuid: str, sample_hashes: list[str]) -> bool:
|
|
649
721
|
"""Add samples to a collection. Returns True on success."""
|
|
650
|
-
if not
|
|
722
|
+
if not sample_hashes:
|
|
723
|
+
return False
|
|
724
|
+
if not await self._ensure_connected():
|
|
651
725
|
return False
|
|
652
726
|
pb2 = self._pb2
|
|
653
727
|
try:
|
|
@@ -658,13 +732,16 @@ class SpliceGRPCClient:
|
|
|
658
732
|
return True
|
|
659
733
|
except Exception as exc:
|
|
660
734
|
logger.warning(f"CollectionAddItems failed: {exc}")
|
|
735
|
+
self._mark_degraded(exc)
|
|
661
736
|
return False
|
|
662
737
|
|
|
663
738
|
async def remove_from_collection(
|
|
664
739
|
self, uuid: str, sample_hashes: list[str],
|
|
665
740
|
) -> bool:
|
|
666
741
|
"""Remove samples from a collection."""
|
|
667
|
-
if not
|
|
742
|
+
if not sample_hashes:
|
|
743
|
+
return False
|
|
744
|
+
if not await self._ensure_connected():
|
|
668
745
|
return False
|
|
669
746
|
pb2 = self._pb2
|
|
670
747
|
try:
|
|
@@ -675,11 +752,12 @@ class SpliceGRPCClient:
|
|
|
675
752
|
return True
|
|
676
753
|
except Exception as exc:
|
|
677
754
|
logger.warning(f"CollectionDeleteItems failed: {exc}")
|
|
755
|
+
self._mark_degraded(exc)
|
|
678
756
|
return False
|
|
679
757
|
|
|
680
758
|
async def create_collection(self, name: str) -> Optional[SpliceCollection]:
|
|
681
759
|
"""Create a new user collection. Returns the new Collection or None."""
|
|
682
|
-
if not self.
|
|
760
|
+
if not await self._ensure_connected():
|
|
683
761
|
return None
|
|
684
762
|
pb2 = self._pb2
|
|
685
763
|
try:
|
|
@@ -690,6 +768,7 @@ class SpliceGRPCClient:
|
|
|
690
768
|
return self._parse_collection(response.Collection)
|
|
691
769
|
except Exception as exc:
|
|
692
770
|
logger.warning(f"CollectionAdd failed: {exc}")
|
|
771
|
+
self._mark_degraded(exc)
|
|
693
772
|
return None
|
|
694
773
|
|
|
695
774
|
# ── Packs ───────────────────────────────────────────────────────
|
|
@@ -712,7 +791,7 @@ class SpliceGRPCClient:
|
|
|
712
791
|
Returns (pack, error) — `error` is a user-readable string when the
|
|
713
792
|
lookup didn't find a match.
|
|
714
793
|
"""
|
|
715
|
-
if not self.
|
|
794
|
+
if not await self._ensure_connected():
|
|
716
795
|
return None, "Splice gRPC not connected"
|
|
717
796
|
pb2 = self._pb2
|
|
718
797
|
target = pack_uuid.strip()
|
|
@@ -751,6 +830,7 @@ class SpliceGRPCClient:
|
|
|
751
830
|
except Exception as exc:
|
|
752
831
|
msg = f"ListSamplePacks gRPC call failed: {type(exc).__name__}: {exc}"
|
|
753
832
|
logger.warning(msg)
|
|
833
|
+
self._mark_degraded(exc)
|
|
754
834
|
return None, msg
|
|
755
835
|
return None, (
|
|
756
836
|
f"Pack '{target}' not found in the user's library. "
|
|
@@ -787,7 +867,7 @@ class SpliceGRPCClient:
|
|
|
787
867
|
sort_order: str = "",
|
|
788
868
|
) -> tuple[int, list[SplicePreset]]:
|
|
789
869
|
"""List presets the user has purchased/owns."""
|
|
790
|
-
if not self.
|
|
870
|
+
if not await self._ensure_connected():
|
|
791
871
|
return 0, []
|
|
792
872
|
pb2 = self._pb2
|
|
793
873
|
try:
|
|
@@ -803,13 +883,14 @@ class SpliceGRPCClient:
|
|
|
803
883
|
return total, presets
|
|
804
884
|
except Exception as exc:
|
|
805
885
|
logger.warning(f"PresetsListPurchased failed: {exc}")
|
|
886
|
+
self._mark_degraded(exc)
|
|
806
887
|
return 0, []
|
|
807
888
|
|
|
808
889
|
async def get_preset_info(
|
|
809
890
|
self, uuid: str = "", file_hash: str = "", plugin_name: str = "",
|
|
810
891
|
) -> Optional[dict]:
|
|
811
892
|
"""Fetch metadata for a single preset."""
|
|
812
|
-
if not self.
|
|
893
|
+
if not await self._ensure_connected():
|
|
813
894
|
return None
|
|
814
895
|
pb2 = self._pb2
|
|
815
896
|
try:
|
|
@@ -826,11 +907,12 @@ class SpliceGRPCClient:
|
|
|
826
907
|
}
|
|
827
908
|
except Exception as exc:
|
|
828
909
|
logger.warning(f"PresetInfo failed: {exc}")
|
|
910
|
+
self._mark_degraded(exc)
|
|
829
911
|
return None
|
|
830
912
|
|
|
831
913
|
async def download_preset(self, uuid: str) -> bool:
|
|
832
914
|
"""Trigger a preset download (uses credits)."""
|
|
833
|
-
if not self.
|
|
915
|
+
if not await self._ensure_connected():
|
|
834
916
|
return False
|
|
835
917
|
pb2 = self._pb2
|
|
836
918
|
try:
|
|
@@ -841,13 +923,14 @@ class SpliceGRPCClient:
|
|
|
841
923
|
return True
|
|
842
924
|
except Exception as exc:
|
|
843
925
|
logger.warning(f"PresetDownload failed: {exc}")
|
|
926
|
+
self._mark_degraded(exc)
|
|
844
927
|
return False
|
|
845
928
|
|
|
846
929
|
# ── Convert to WAV ──────────────────────────────────────────────
|
|
847
930
|
|
|
848
931
|
async def convert_to_wav(self, path: str) -> Optional[dict]:
|
|
849
932
|
"""Convert an audio file to PCM WAV via Splice's converter."""
|
|
850
|
-
if not self.
|
|
933
|
+
if not await self._ensure_connected():
|
|
851
934
|
return None
|
|
852
935
|
pb2 = self._pb2
|
|
853
936
|
try:
|
|
@@ -864,6 +947,7 @@ class SpliceGRPCClient:
|
|
|
864
947
|
}
|
|
865
948
|
except Exception as exc:
|
|
866
949
|
logger.warning(f"ConvertToWav failed: {exc}")
|
|
950
|
+
self._mark_degraded(exc)
|
|
867
951
|
return None
|
|
868
952
|
|
|
869
953
|
# ── Connection Helpers ──────────────────────────────────────────
|
|
@@ -500,7 +500,8 @@ class SpliceHTTPBridge:
|
|
|
500
500
|
|
|
501
501
|
loop = asyncio.get_running_loop()
|
|
502
502
|
last_err = None
|
|
503
|
-
|
|
503
|
+
max_attempts = 1 + max(0, self.config.max_retries)
|
|
504
|
+
for attempt in range(max_attempts):
|
|
504
505
|
try:
|
|
505
506
|
return await loop.run_in_executor(
|
|
506
507
|
None,
|
|
@@ -509,10 +510,21 @@ class SpliceHTTPBridge:
|
|
|
509
510
|
)
|
|
510
511
|
except SpliceHTTPError as exc:
|
|
511
512
|
last_err = exc
|
|
512
|
-
# Retry only on 5xx / network. 4xx is terminal
|
|
513
|
+
# Retry only on 5xx / network. 4xx is terminal, and so is a
|
|
514
|
+
# response-decode failure (DECODE_ERROR) — a malformed body
|
|
515
|
+
# will fail to parse identically on every retry, so retrying
|
|
516
|
+
# just burns the backoff delay for no benefit. Both
|
|
517
|
+
# DECODE_ERROR and NETWORK_ERROR carry status_code=0, which
|
|
518
|
+
# would otherwise slip past the "< 500 is terminal" check
|
|
519
|
+
# below and retry forever on a deterministic parse failure.
|
|
520
|
+
if exc.code == "DECODE_ERROR":
|
|
521
|
+
raise
|
|
513
522
|
if exc.status_code and exc.status_code < 500:
|
|
514
523
|
raise
|
|
515
|
-
|
|
524
|
+
# Only sleep between attempts — not after the final one, which
|
|
525
|
+
# is about to raise `last_err` and return control to the caller.
|
|
526
|
+
if attempt < max_attempts - 1:
|
|
527
|
+
await asyncio.sleep(min(2 ** attempt, 5))
|
|
516
528
|
assert last_err is not None
|
|
517
529
|
raise last_err
|
|
518
530
|
|
|
@@ -146,6 +146,34 @@ class DailyQuotaTracker:
|
|
|
146
146
|
used, _ = self.current()
|
|
147
147
|
return used >= self.warn_threshold
|
|
148
148
|
|
|
149
|
+
def check_budget(self, additional: int = 1) -> dict:
|
|
150
|
+
"""Atomic snapshot combining would_exceed/near_limit/at_limit.
|
|
151
|
+
|
|
152
|
+
`would_exceed()` and `near_limit()` each independently call
|
|
153
|
+
`current()`, which reads the on-disk state under its own lock
|
|
154
|
+
acquisition. That's safe for either predicate alone, but a
|
|
155
|
+
caller (like `decide_download`) that wants BOTH answers for one
|
|
156
|
+
decision could otherwise observe two different moments if a
|
|
157
|
+
concurrent `record_download()` lands between the two reads. This
|
|
158
|
+
takes a single lock and derives every predicate from the same
|
|
159
|
+
`used` count.
|
|
160
|
+
|
|
161
|
+
Returns a dict compatible in spirit with `summary()` but with an
|
|
162
|
+
explicit `would_exceed` for the caller's `additional` count.
|
|
163
|
+
"""
|
|
164
|
+
with self._lock:
|
|
165
|
+
state = self._load()
|
|
166
|
+
used = state.counts.get(_today_utc(), 0)
|
|
167
|
+
remaining = max(0, self.daily_limit - used)
|
|
168
|
+
return {
|
|
169
|
+
"used_today": used,
|
|
170
|
+
"remaining_today": remaining,
|
|
171
|
+
"daily_limit": self.daily_limit,
|
|
172
|
+
"would_exceed": (used + additional) > self.daily_limit,
|
|
173
|
+
"near_limit": used >= self.warn_threshold,
|
|
174
|
+
"at_limit": used >= self.daily_limit,
|
|
175
|
+
}
|
|
176
|
+
|
|
149
177
|
# ── Mutations ─────────────────────────────────────────────────────
|
|
150
178
|
|
|
151
179
|
def record_download(self, file_hash: str, filename: str = "") -> dict:
|
|
@@ -190,7 +190,10 @@ def _state_signals_to_signal_list(state: dict) -> list[StucknessSignal]:
|
|
|
190
190
|
|
|
191
191
|
def _check_repeated_undos(history: list[dict]) -> Optional[StucknessSignal]:
|
|
192
192
|
"""Check for repeated undone moves (kept=False in ledger entries)."""
|
|
193
|
-
|
|
193
|
+
# `history` is NEWEST-FIRST (action ledger get_recent_moves), so the
|
|
194
|
+
# recency window is the FRONT of the list. `history[-N:]` would take the
|
|
195
|
+
# OLDEST N entries — analyzing stale history once the list exceeds N.
|
|
196
|
+
recent = history[:20]
|
|
194
197
|
undo_count = sum(1 for a in recent if a.get("kept") is False)
|
|
195
198
|
|
|
196
199
|
if undo_count >= 4:
|
|
@@ -204,9 +207,9 @@ def _check_repeated_undos(history: list[dict]) -> Optional[StucknessSignal]:
|
|
|
204
207
|
|
|
205
208
|
def _check_local_tweaking(history: list[dict]) -> Optional[StucknessSignal]:
|
|
206
209
|
"""Check for many small parameter changes in one local area."""
|
|
207
|
-
recent = history[
|
|
210
|
+
recent = history[:15] # newest-first: front = most recent
|
|
208
211
|
param_tools = {"set_device_parameter", "set_track_volume", "set_track_pan",
|
|
209
|
-
"
|
|
212
|
+
"set_track_send", "set_clip_loop", "batch_set_parameters"}
|
|
210
213
|
param_entries = []
|
|
211
214
|
for entry in recent:
|
|
212
215
|
tools_used = [a.get("tool", "") for a in entry.get("actions", [])]
|
|
@@ -232,7 +235,7 @@ def _check_loop_without_structure(
|
|
|
232
235
|
history: list[dict], section_count: int
|
|
233
236
|
) -> Optional[StucknessSignal]:
|
|
234
237
|
"""Check for long work without structural changes."""
|
|
235
|
-
recent = history[
|
|
238
|
+
recent = history[:30] # newest-first: front = most recent
|
|
236
239
|
structural_tools = {"create_clip", "delete_clip", "create_midi_track",
|
|
237
240
|
"create_audio_track", "delete_track", "duplicate_clip"}
|
|
238
241
|
structural = 0
|
|
@@ -260,7 +263,7 @@ def _check_loop_without_structure(
|
|
|
260
263
|
|
|
261
264
|
def _check_repeated_requests(history: list[dict]) -> Optional[StucknessSignal]:
|
|
262
265
|
"""Check for repeated similar intents without acceptance."""
|
|
263
|
-
recent = history[
|
|
266
|
+
recent = history[:10] # newest-first: front = most recent
|
|
264
267
|
intents = [a.get("intent", "").lower() for a in recent if a.get("intent")]
|
|
265
268
|
|
|
266
269
|
if len(intents) >= 3:
|
|
@@ -10,9 +10,12 @@ add tuning-table variants and LFO-routing variants.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import hashlib
|
|
13
|
+
import logging
|
|
13
14
|
from typing import Optional
|
|
14
15
|
|
|
15
16
|
from ...branches import BranchSeed, freeform_seed
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
16
19
|
from ..models import (
|
|
17
20
|
SynthProfile,
|
|
18
21
|
TimbralFingerprint,
|
|
@@ -110,6 +113,16 @@ class DriftAdapter:
|
|
|
110
113
|
try:
|
|
111
114
|
maybe = strategy_fn(profile, target, kernel, adapter=self)
|
|
112
115
|
except Exception:
|
|
116
|
+
# Never let one strategy's crash kill the rest, but make the
|
|
117
|
+
# swallowed failure observable instead of silently degrading
|
|
118
|
+
# the branch set.
|
|
119
|
+
logger.warning(
|
|
120
|
+
"Drift strategy %s crashed on track %s device %s; skipping",
|
|
121
|
+
getattr(strategy_fn, "__name__", strategy_fn),
|
|
122
|
+
profile.track_index,
|
|
123
|
+
profile.device_index,
|
|
124
|
+
exc_info=True,
|
|
125
|
+
)
|
|
113
126
|
continue
|
|
114
127
|
if maybe is not None:
|
|
115
128
|
results.append(maybe)
|
|
@@ -11,9 +11,12 @@ variants.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import hashlib
|
|
14
|
+
import logging
|
|
14
15
|
from typing import Optional
|
|
15
16
|
|
|
16
17
|
from ...branches import BranchSeed, freeform_seed
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
17
20
|
from ..models import (
|
|
18
21
|
SynthProfile,
|
|
19
22
|
TimbralFingerprint,
|
|
@@ -107,6 +110,16 @@ class MeldAdapter:
|
|
|
107
110
|
try:
|
|
108
111
|
maybe = strategy_fn(profile, target, kernel, adapter=self)
|
|
109
112
|
except Exception:
|
|
113
|
+
# Never let one strategy's crash kill the rest, but make the
|
|
114
|
+
# swallowed failure observable instead of silently degrading
|
|
115
|
+
# the branch set.
|
|
116
|
+
logger.warning(
|
|
117
|
+
"Meld strategy %s crashed on track %s device %s; skipping",
|
|
118
|
+
getattr(strategy_fn, "__name__", strategy_fn),
|
|
119
|
+
profile.track_index,
|
|
120
|
+
profile.device_index,
|
|
121
|
+
exc_info=True,
|
|
122
|
+
)
|
|
110
123
|
continue
|
|
111
124
|
if maybe is not None:
|
|
112
125
|
results.append(maybe)
|