livepilot 1.14.1 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +176 -1
- package/README.md +6 -6
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/device_atlas.json +91219 -7161
- package/mcp_server/atlas/tools.py +30 -2
- package/mcp_server/runtime/live_version.py +4 -2
- package/mcp_server/runtime/remote_commands.py +5 -0
- package/mcp_server/sample_engine/tools.py +692 -60
- package/mcp_server/splice_client/client.py +511 -65
- package/mcp_server/splice_client/http_bridge.py +361 -0
- package/mcp_server/splice_client/models.py +266 -2
- package/mcp_server/splice_client/quota.py +229 -0
- package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
- package/mcp_server/tools/analyzer.py +666 -6
- package/mcp_server/tools/browser.py +164 -13
- package/mcp_server/tools/devices.py +56 -11
- package/mcp_server/tools/mixing.py +64 -15
- package/mcp_server/tools/scales.py +18 -6
- package/mcp_server/tools/tracks.py +92 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +2 -1
- package/remote_script/LivePilot/_clip_helpers.py +86 -0
- package/remote_script/LivePilot/_drum_helpers.py +40 -0
- package/remote_script/LivePilot/_scale_helpers.py +87 -0
- package/remote_script/LivePilot/arrangement.py +44 -15
- package/remote_script/LivePilot/clips.py +182 -2
- package/remote_script/LivePilot/devices.py +82 -2
- package/remote_script/LivePilot/notes.py +17 -2
- package/remote_script/LivePilot/scales.py +31 -16
- package/remote_script/LivePilot/simpler_sample.py +186 -0
- package/server.json +3 -3
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Daily download quota tracker for the Splice x Ableton Live plan.
|
|
2
|
+
|
|
3
|
+
The plan grants 100 samples/day unmetered via drag-drop inside Ableton.
|
|
4
|
+
Samples downloaded through our `splice_download_sample` MCP tool count
|
|
5
|
+
against the SAME daily quota server-side — Splice doesn't distinguish
|
|
6
|
+
between in-Ableton drags and in-plugin drag/downloads, they all hit the
|
|
7
|
+
same `DownloadSample` RPC.
|
|
8
|
+
|
|
9
|
+
This tracker lets us:
|
|
10
|
+
1. Warn the user before they approach the ceiling (default 90/100).
|
|
11
|
+
2. Refuse downloads when the ceiling would be exceeded, turning a
|
|
12
|
+
confusing server error into a clear "quota hit, resets at UTC
|
|
13
|
+
midnight" message.
|
|
14
|
+
3. Give the agent a running count so it can choose "audition via
|
|
15
|
+
PreviewURL" instead of "download" when budget is tight.
|
|
16
|
+
|
|
17
|
+
State lives at ~/.livepilot/splice_quota.json. It's a small JSON file
|
|
18
|
+
because simplicity beats a DB for a single-digit-KB ledger.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import threading
|
|
27
|
+
from dataclasses import dataclass, asdict, field
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Splice quota boundaries
|
|
34
|
+
DEFAULT_DAILY_LIMIT = 100
|
|
35
|
+
DEFAULT_WARN_THRESHOLD = 90
|
|
36
|
+
|
|
37
|
+
# Quota file location — one ledger per user, stored under ~/.livepilot.
|
|
38
|
+
_DEFAULT_QUOTA_PATH = os.path.expanduser("~/.livepilot/splice_quota.json")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _today_utc() -> str:
|
|
42
|
+
"""ISO date string in UTC — matches Splice's server-side reset boundary.
|
|
43
|
+
|
|
44
|
+
Splice documentation doesn't publish the reset timezone, but the
|
|
45
|
+
desktop app's telemetry timestamps are UTC. Using UTC matches the
|
|
46
|
+
server to avoid "quota reset at 11pm local" surprises. If we later
|
|
47
|
+
discover Splice resets at local midnight we can swap the function.
|
|
48
|
+
"""
|
|
49
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class QuotaState:
|
|
54
|
+
"""On-disk record of sample downloads per UTC day.
|
|
55
|
+
|
|
56
|
+
`counts` maps YYYY-MM-DD → number of samples downloaded that day.
|
|
57
|
+
`downloads` is a bounded log of recent file_hashes (last 200) for
|
|
58
|
+
debugging — never the source of truth, just a trail.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
version: int = 1
|
|
62
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
63
|
+
downloads: list[dict] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
def to_json(self) -> str:
|
|
66
|
+
return json.dumps(asdict(self), indent=2, sort_keys=True)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_json(cls, data: str) -> "QuotaState":
|
|
70
|
+
try:
|
|
71
|
+
raw = json.loads(data)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
return cls()
|
|
74
|
+
if not isinstance(raw, dict):
|
|
75
|
+
return cls()
|
|
76
|
+
return cls(
|
|
77
|
+
version=int(raw.get("version", 1)),
|
|
78
|
+
counts={str(k): int(v) for k, v in (raw.get("counts") or {}).items()},
|
|
79
|
+
downloads=list(raw.get("downloads") or [])[-200:],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DailyQuotaTracker:
|
|
84
|
+
"""Thread-safe persistent counter for Splice daily downloads.
|
|
85
|
+
|
|
86
|
+
Thread-safety: the gRPC client is async but the quota file lives on
|
|
87
|
+
a single process. A `threading.Lock` is enough — async callers can
|
|
88
|
+
grab it synchronously because the critical section is I/O-free
|
|
89
|
+
(read/modify/write a small JSON blob).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
path: Optional[str] = None,
|
|
95
|
+
daily_limit: int = DEFAULT_DAILY_LIMIT,
|
|
96
|
+
warn_threshold: int = DEFAULT_WARN_THRESHOLD,
|
|
97
|
+
):
|
|
98
|
+
self.path = path or _DEFAULT_QUOTA_PATH
|
|
99
|
+
self.daily_limit = daily_limit
|
|
100
|
+
self.warn_threshold = warn_threshold
|
|
101
|
+
self._lock = threading.Lock()
|
|
102
|
+
self._ensure_dir()
|
|
103
|
+
|
|
104
|
+
def _ensure_dir(self):
|
|
105
|
+
try:
|
|
106
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
logger.warning("Could not create quota dir %s: %s", self.path, exc)
|
|
109
|
+
|
|
110
|
+
def _load(self) -> QuotaState:
|
|
111
|
+
if not os.path.isfile(self.path):
|
|
112
|
+
return QuotaState()
|
|
113
|
+
try:
|
|
114
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
|
115
|
+
return QuotaState.from_json(f.read())
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
logger.warning("Could not read quota file %s: %s", self.path, exc)
|
|
118
|
+
return QuotaState()
|
|
119
|
+
|
|
120
|
+
def _save(self, state: QuotaState):
|
|
121
|
+
try:
|
|
122
|
+
tmp = self.path + ".tmp"
|
|
123
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
124
|
+
f.write(state.to_json())
|
|
125
|
+
os.replace(tmp, self.path)
|
|
126
|
+
except OSError as exc:
|
|
127
|
+
logger.warning("Could not write quota file %s: %s", self.path, exc)
|
|
128
|
+
|
|
129
|
+
# ── Queries ──────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def current(self) -> tuple[int, int]:
|
|
132
|
+
"""Return (used_today, remaining_today)."""
|
|
133
|
+
with self._lock:
|
|
134
|
+
state = self._load()
|
|
135
|
+
used = state.counts.get(_today_utc(), 0)
|
|
136
|
+
remaining = max(0, self.daily_limit - used)
|
|
137
|
+
return used, remaining
|
|
138
|
+
|
|
139
|
+
def would_exceed(self, additional: int = 1) -> bool:
|
|
140
|
+
"""True iff `additional` more downloads would breach the daily limit."""
|
|
141
|
+
used, _ = self.current()
|
|
142
|
+
return (used + additional) > self.daily_limit
|
|
143
|
+
|
|
144
|
+
def near_limit(self) -> bool:
|
|
145
|
+
"""True iff we're at or above the warn threshold."""
|
|
146
|
+
used, _ = self.current()
|
|
147
|
+
return used >= self.warn_threshold
|
|
148
|
+
|
|
149
|
+
# ── Mutations ─────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def record_download(self, file_hash: str, filename: str = "") -> dict:
|
|
152
|
+
"""Increment today's counter and append to the log.
|
|
153
|
+
|
|
154
|
+
Returns a summary dict: {used_today, remaining_today, warning}.
|
|
155
|
+
Safe to call concurrently — `threading.Lock` serializes I/O.
|
|
156
|
+
"""
|
|
157
|
+
today = _today_utc()
|
|
158
|
+
warning = None
|
|
159
|
+
with self._lock:
|
|
160
|
+
state = self._load()
|
|
161
|
+
state.counts[today] = state.counts.get(today, 0) + 1
|
|
162
|
+
state.downloads.append({
|
|
163
|
+
"file_hash": file_hash,
|
|
164
|
+
"filename": filename,
|
|
165
|
+
"at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
166
|
+
"day": today,
|
|
167
|
+
})
|
|
168
|
+
# Trim log to last 200 entries
|
|
169
|
+
if len(state.downloads) > 200:
|
|
170
|
+
state.downloads = state.downloads[-200:]
|
|
171
|
+
# Prune old days (keep last 30) so the counts dict doesn't grow
|
|
172
|
+
# unbounded. Splice's daily limit resets at UTC midnight so
|
|
173
|
+
# anything older than a week is no longer useful.
|
|
174
|
+
if len(state.counts) > 30:
|
|
175
|
+
sorted_days = sorted(state.counts.keys())
|
|
176
|
+
for day in sorted_days[:-30]:
|
|
177
|
+
state.counts.pop(day, None)
|
|
178
|
+
self._save(state)
|
|
179
|
+
used = state.counts[today]
|
|
180
|
+
remaining = max(0, self.daily_limit - used)
|
|
181
|
+
if used >= self.daily_limit:
|
|
182
|
+
warning = (
|
|
183
|
+
f"Daily quota of {self.daily_limit} samples reached. "
|
|
184
|
+
"Resets at UTC midnight. Further downloads will be "
|
|
185
|
+
"refused server-side."
|
|
186
|
+
)
|
|
187
|
+
elif used >= self.warn_threshold:
|
|
188
|
+
warning = (
|
|
189
|
+
f"Approaching daily quota ({used}/{self.daily_limit}). "
|
|
190
|
+
"Consider previewing samples (splice_preview_sample) "
|
|
191
|
+
"before committing to more downloads today."
|
|
192
|
+
)
|
|
193
|
+
return {
|
|
194
|
+
"used_today": used,
|
|
195
|
+
"remaining_today": remaining,
|
|
196
|
+
"daily_limit": self.daily_limit,
|
|
197
|
+
"warning": warning,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
def summary(self) -> dict:
|
|
201
|
+
"""Read-only snapshot — used by get_splice_credits for reporting."""
|
|
202
|
+
used, remaining = self.current()
|
|
203
|
+
return {
|
|
204
|
+
"used_today": used,
|
|
205
|
+
"remaining_today": remaining,
|
|
206
|
+
"daily_limit": self.daily_limit,
|
|
207
|
+
"warn_threshold": self.warn_threshold,
|
|
208
|
+
"near_limit": used >= self.warn_threshold,
|
|
209
|
+
"at_limit": used >= self.daily_limit,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# Process-wide singleton. The MCP server has exactly one event loop and
|
|
214
|
+
# one Splice gRPC client; sharing a tracker means all download sites
|
|
215
|
+
# observe the same counter.
|
|
216
|
+
_singleton: Optional[DailyQuotaTracker] = None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_tracker() -> DailyQuotaTracker:
|
|
220
|
+
global _singleton
|
|
221
|
+
if _singleton is None:
|
|
222
|
+
_singleton = DailyQuotaTracker()
|
|
223
|
+
return _singleton
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def reset_singleton_for_tests():
|
|
227
|
+
"""Reset the module singleton — test-only helper."""
|
|
228
|
+
global _singleton
|
|
229
|
+
_singleton = None
|
|
@@ -20,6 +20,8 @@ continue to resolve via the thin analyzer module.
|
|
|
20
20
|
from .context import _get_spectral, _get_m4l, _require_analyzer
|
|
21
21
|
from .sample import (
|
|
22
22
|
_BPM_IN_FILENAME_RE,
|
|
23
|
+
_DRUM_ROOT_MAP,
|
|
24
|
+
_detect_drum_root_note,
|
|
23
25
|
_filename_stem,
|
|
24
26
|
_is_warped_loop,
|
|
25
27
|
_simpler_post_load_hygiene,
|
|
@@ -31,6 +33,8 @@ __all__ = [
|
|
|
31
33
|
"_get_m4l",
|
|
32
34
|
"_require_analyzer",
|
|
33
35
|
"_BPM_IN_FILENAME_RE",
|
|
36
|
+
"_DRUM_ROOT_MAP",
|
|
37
|
+
"_detect_drum_root_note",
|
|
34
38
|
"_filename_stem",
|
|
35
39
|
"_is_warped_loop",
|
|
36
40
|
"_simpler_post_load_hygiene",
|
|
@@ -27,6 +27,45 @@ logger = logging.getLogger(__name__)
|
|
|
27
27
|
_BPM_IN_FILENAME_RE = re.compile(r"(\d{2,3})\s*bpm", re.IGNORECASE)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
# Drum material keywords → MIDI root pitch (Live Drum Rack convention).
|
|
31
|
+
# BUG-2026-04-22#18: loading a kick and triggering at note 36 previously
|
|
32
|
+
# played 24 semitones down because the Simpler root defaulted to C3 (60).
|
|
33
|
+
# Auto-detecting the intended trigger note from the filename fixes that
|
|
34
|
+
# without forcing the caller to know the magic number.
|
|
35
|
+
_DRUM_ROOT_MAP = {
|
|
36
|
+
# Order matters — most specific first so "hi_hat" beats "hat".
|
|
37
|
+
"kick": 36, # C1
|
|
38
|
+
"bd": 36,
|
|
39
|
+
"808": 36,
|
|
40
|
+
"snare": 38, # D1
|
|
41
|
+
"sd": 38,
|
|
42
|
+
"clap": 39, # D#1
|
|
43
|
+
"rim": 37, # C#1
|
|
44
|
+
"tom_low": 41, # F1
|
|
45
|
+
"tom": 45, # A1
|
|
46
|
+
"closed_hat": 42, # F#1
|
|
47
|
+
"closed_hh": 42,
|
|
48
|
+
"hihat_closed": 42,
|
|
49
|
+
"hh_closed": 42,
|
|
50
|
+
"hihat_open": 46, # common naming pattern: prefix-then-modifier
|
|
51
|
+
"hh_open": 46,
|
|
52
|
+
"open_hat": 46, # A#1
|
|
53
|
+
"open_hh": 46,
|
|
54
|
+
"hi_hat": 42,
|
|
55
|
+
"hihat": 42,
|
|
56
|
+
"hat_closed": 42,
|
|
57
|
+
"hat": 42, # default closed
|
|
58
|
+
"hh": 42,
|
|
59
|
+
"ride": 51, # D#2
|
|
60
|
+
"crash": 49, # C#2
|
|
61
|
+
"cymbal": 49,
|
|
62
|
+
"perc": 60, # C3 — neutral for generic percussion
|
|
63
|
+
"shaker": 70,
|
|
64
|
+
"tamb": 54,
|
|
65
|
+
"cowbell": 56,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
30
69
|
def _is_warped_loop(file_path: str) -> bool:
|
|
31
70
|
"""Return True if the filename contains a BPM marker (likely a tempo-locked loop)."""
|
|
32
71
|
stem = os.path.splitext(os.path.basename(file_path))[0]
|
|
@@ -37,6 +76,28 @@ def _filename_stem(file_path: str) -> str:
|
|
|
37
76
|
return os.path.splitext(os.path.basename(file_path))[0]
|
|
38
77
|
|
|
39
78
|
|
|
79
|
+
def _detect_drum_root_note(file_path: str) -> int | None:
|
|
80
|
+
"""Guess the intended MIDI trigger pitch for a sample by filename.
|
|
81
|
+
|
|
82
|
+
Returns a MIDI note (0-127) when the filename contains a drum-material
|
|
83
|
+
hint (kick, snare, hat, ride, etc.), else None.
|
|
84
|
+
|
|
85
|
+
Why: Live's default Simpler root is C3 (60). A kick triggered from a
|
|
86
|
+
Drum Rack pad at C1 (36) plays 24 semitones down — 4× slower, sounds
|
|
87
|
+
like a broken airhorn. Setting the sample's root note to match the
|
|
88
|
+
trigger pad (36 for a kick) fixes playback without any pitch-matching
|
|
89
|
+
math. BUG-2026-04-22#18.
|
|
90
|
+
"""
|
|
91
|
+
stem = _filename_stem(file_path).lower()
|
|
92
|
+
# Normalize common separators so "Kick-Hard" and "kick_hard" both match.
|
|
93
|
+
normalized = stem.replace("-", "_").replace(" ", "_")
|
|
94
|
+
# Sort keys by length so "closed_hat" matches before "hat".
|
|
95
|
+
for key in sorted(_DRUM_ROOT_MAP.keys(), key=len, reverse=True):
|
|
96
|
+
if key in normalized:
|
|
97
|
+
return _DRUM_ROOT_MAP[key]
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
40
101
|
async def _simpler_post_load_hygiene(
|
|
41
102
|
bridge,
|
|
42
103
|
ableton,
|
|
@@ -102,6 +163,17 @@ async def _simpler_post_load_hygiene(
|
|
|
102
163
|
{"name_or_index": "S Loop On", "value": 1},
|
|
103
164
|
])
|
|
104
165
|
|
|
166
|
+
# Step 4: auto-detect drum root note from filename (BUG-2026-04-22#18).
|
|
167
|
+
# Only applied for one-shots — warped loops keep Live's default root
|
|
168
|
+
# because their root note is irrelevant at loop playback speeds.
|
|
169
|
+
drum_root = None
|
|
170
|
+
if not _is_warped_loop(file_path):
|
|
171
|
+
drum_root = _detect_drum_root_note(file_path)
|
|
172
|
+
if drum_root is not None:
|
|
173
|
+
hygiene_params.append(
|
|
174
|
+
{"name_or_index": "Sample Pitch Coarse", "value": drum_root}
|
|
175
|
+
)
|
|
176
|
+
|
|
105
177
|
try:
|
|
106
178
|
ableton.send_command("batch_set_parameters", {
|
|
107
179
|
"track_index": track_index,
|
|
@@ -119,4 +191,5 @@ async def _simpler_post_load_hygiene(
|
|
|
119
191
|
"track_index": track_index,
|
|
120
192
|
"device_index": device_index,
|
|
121
193
|
"warped_loop_defaults_applied": _is_warped_loop(file_path),
|
|
194
|
+
"auto_root_note": drum_root,
|
|
122
195
|
}
|