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.
Files changed (33) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/README.md +6 -6
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/mcp_server/__init__.py +1 -1
  5. package/mcp_server/atlas/device_atlas.json +91219 -7161
  6. package/mcp_server/atlas/tools.py +30 -2
  7. package/mcp_server/runtime/live_version.py +4 -2
  8. package/mcp_server/runtime/remote_commands.py +5 -0
  9. package/mcp_server/sample_engine/tools.py +692 -60
  10. package/mcp_server/splice_client/client.py +511 -65
  11. package/mcp_server/splice_client/http_bridge.py +361 -0
  12. package/mcp_server/splice_client/models.py +266 -2
  13. package/mcp_server/splice_client/quota.py +229 -0
  14. package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
  15. package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
  16. package/mcp_server/tools/analyzer.py +666 -6
  17. package/mcp_server/tools/browser.py +164 -13
  18. package/mcp_server/tools/devices.py +56 -11
  19. package/mcp_server/tools/mixing.py +64 -15
  20. package/mcp_server/tools/scales.py +18 -6
  21. package/mcp_server/tools/tracks.py +92 -4
  22. package/package.json +2 -2
  23. package/remote_script/LivePilot/__init__.py +2 -1
  24. package/remote_script/LivePilot/_clip_helpers.py +86 -0
  25. package/remote_script/LivePilot/_drum_helpers.py +40 -0
  26. package/remote_script/LivePilot/_scale_helpers.py +87 -0
  27. package/remote_script/LivePilot/arrangement.py +44 -15
  28. package/remote_script/LivePilot/clips.py +182 -2
  29. package/remote_script/LivePilot/devices.py +82 -2
  30. package/remote_script/LivePilot/notes.py +17 -2
  31. package/remote_script/LivePilot/scales.py +31 -16
  32. package/remote_script/LivePilot/simpler_sample.py +186 -0
  33. 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
  }