livepilot 1.7.5 → 1.8.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 CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0 — Perception Layer (March 2026)
4
+
5
+ **13 new tools (155 → 168), 1 new domain (perception), FluCoMa real-time DSP, offline audio analysis, audio capture.**
6
+
7
+ ### Perception Domain (4 tools)
8
+ - `analyze_loudness` — LUFS, sample peak, RMS, crest factor, LRA, streaming compliance
9
+ - `analyze_spectrum_offline` — spectral centroid, rolloff, flatness, bandwidth, 5-band balance
10
+ - `compare_to_reference` — mix vs reference: loudness/spectral/stereo deltas + suggestions
11
+ - `read_audio_metadata` — format, duration, sample rate, tags, artwork detection
12
+
13
+ ### Analyzer — Capture (2 tools)
14
+ - `capture_audio` — record master output to WAV via M4L buffer~/record~
15
+ - `capture_stop` — cancel in-progress capture
16
+
17
+ ### Analyzer — FluCoMa Real-Time (7 tools)
18
+ - `get_spectral_shape` — 7 descriptors (centroid, spread, skewness, kurtosis, rolloff, flatness, crest)
19
+ - `get_mel_spectrum` — 40-band mel spectrum (5x resolution of get_master_spectrum)
20
+ - `get_chroma` — 12 pitch class energies for chord detection
21
+ - `get_onsets` — real-time onset/transient detection
22
+ - `get_novelty` — spectral novelty for section boundary detection
23
+ - `get_momentary_loudness` — EBU R128 momentary LUFS + peak
24
+ - `check_flucoma` — verify FluCoMa installation status
25
+
26
+ ### Architecture
27
+ - New `_perception_engine.py` — pure scipy/pyloudnorm/soundfile/mutagen analysis (no MCP deps)
28
+ - New `perception.py` — 4 MCP tool wrappers with format validation
29
+ - 6 FluCoMa OSC handlers in SpectralReceiver (`/spectral_shape`, `/mel_bands`, `/chroma`, `/onset`, `/novelty`, `/loudness`)
30
+ - Dedicated `/capture_complete` channel with `_capture_future` (separate from bridge responses)
31
+ - `--setup-flucoma` CLI command — auto-downloads and installs FluCoMa Max package
32
+ - New dependencies: pyloudnorm, soundfile, scipy, mutagen
33
+
3
34
  ## 1.7.0 — Creative Engine (March 2026)
4
35
 
5
36
  **13 new tools (142 → 155), 3 new domains, MIDI file I/O, neo-Riemannian harmony, generative algorithms.**
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  An agentic production system for Ableton Live 12.
11
11
 
12
- 155 tools. Device atlas. Spectral perception. Technique memory.
12
+ 168 tools. Device atlas. Spectral perception. Technique memory.
13
13
  Neo-Riemannian harmony. Euclidean rhythm. Species counterpoint.
14
14
 
15
15
  It doesn't assist — it produces.
@@ -38,8 +38,8 @@ It doesn't assist — it produces.
38
38
  │ └───────────────────┼───────────────────┘ │
39
39
  │ ▼ │
40
40
  │ ┌─────────────────┐ │
41
- │ │ 155 MCP Tools │ │
42
- │ │ 16 domains │ │
41
+ │ │ 168 MCP Tools │ │
42
+ │ │ 17 domains │ │
43
43
  │ └────────┬────────┘ │
44
44
  │ │ │
45
45
  │ Remote Script ──┤── TCP 9878 │
@@ -60,7 +60,7 @@ via a Max for Live device.
60
60
  The memory gives it history — a searchable library of production decisions
61
61
  that persists across sessions.
62
62
 
63
- All three feed into 155 deterministic tools that execute on Ableton's main thread
63
+ All three feed into 168 deterministic tools that execute on Ableton's main thread
64
64
  through the official Live Object Model API. Everything is reversible with undo.
65
65
 
66
66
  <br>
@@ -477,7 +477,7 @@ Check memory before creative decisions. Verify every mutation.
477
477
 
478
478
  ## Full Tool List
479
479
 
480
- 155 tools across 16 domains.
480
+ 168 tools across 17 domains.
481
481
 
482
482
  <br>
483
483
 
package/bin/livepilot.js CHANGED
@@ -273,6 +273,134 @@ async function doctor() {
273
273
  return ok;
274
274
  }
275
275
 
276
+ // ---------------------------------------------------------------------------
277
+ // FluCoMa installer
278
+ // ---------------------------------------------------------------------------
279
+
280
+ async function setupFlucoma() {
281
+ const os = require("os");
282
+ const https = require("https");
283
+
284
+ const home = os.homedir();
285
+ const packagesDir = process.platform === "darwin"
286
+ ? path.join(home, "Documents", "Max 8", "Packages")
287
+ : path.join(process.env.USERPROFILE || home, "Documents", "Max 8", "Packages");
288
+
289
+ const flucomaDir = path.join(packagesDir, "FluidCorpusManipulation");
290
+
291
+ if (fs.existsSync(flucomaDir)) {
292
+ // Check version
293
+ const pkgInfo = path.join(flucomaDir, "package-info.json");
294
+ if (fs.existsSync(pkgInfo)) {
295
+ try {
296
+ const info = JSON.parse(fs.readFileSync(pkgInfo, "utf-8"));
297
+ console.log("FluCoMa already installed: v%s", info.version || "unknown");
298
+ console.log("Location: %s", flucomaDir);
299
+ return;
300
+ } catch {}
301
+ }
302
+ console.log("FluCoMa already installed at %s", flucomaDir);
303
+ return;
304
+ }
305
+
306
+ console.log("FluCoMa not found. Downloading from GitHub...");
307
+
308
+ // Fetch latest release info
309
+ const releaseInfo = await new Promise((resolve, reject) => {
310
+ https.get("https://api.github.com/repos/flucoma/flucoma-max/releases/latest", {
311
+ headers: { "User-Agent": "LivePilot" }
312
+ }, (res) => {
313
+ if (res.statusCode === 302 || res.statusCode === 301) {
314
+ https.get(res.headers.location, {
315
+ headers: { "User-Agent": "LivePilot" }
316
+ }, (res2) => {
317
+ let data = "";
318
+ res2.on("data", (c) => data += c);
319
+ res2.on("end", () => resolve(JSON.parse(data)));
320
+ }).on("error", reject);
321
+ return;
322
+ }
323
+ let data = "";
324
+ res.on("data", (c) => data += c);
325
+ res.on("end", () => resolve(JSON.parse(data)));
326
+ }).on("error", reject);
327
+ });
328
+
329
+ const zipAsset = releaseInfo.assets.find(a => a.name.endsWith(".zip"));
330
+ if (!zipAsset) {
331
+ console.error("Error: no zip asset found in FluCoMa release");
332
+ process.exit(1);
333
+ }
334
+
335
+ console.log("Downloading %s (%sMB)...", zipAsset.name,
336
+ Math.round(zipAsset.size / 1024 / 1024));
337
+
338
+ // Download to temp
339
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "flucoma-"));
340
+ const zipPath = path.join(tmpDir, zipAsset.name);
341
+
342
+ await new Promise((resolve, reject) => {
343
+ const downloadUrl = zipAsset.browser_download_url;
344
+ const download = (url, depth) => {
345
+ if (depth > 5) { reject(new Error("Too many redirects")); return; }
346
+ if (!url.startsWith("https://")) { reject(new Error("Refusing non-HTTPS redirect")); return; }
347
+ https.get(url, { headers: { "User-Agent": "LivePilot" } }, (res) => {
348
+ if (res.statusCode === 302 || res.statusCode === 301) {
349
+ download(res.headers.location, depth + 1);
350
+ return;
351
+ }
352
+ const file = fs.createWriteStream(zipPath);
353
+ res.pipe(file);
354
+ file.on("finish", () => { file.close(); resolve(); });
355
+ }).on("error", reject);
356
+ };
357
+ download(downloadUrl, 0);
358
+ });
359
+
360
+ console.log("Extracting to %s...", packagesDir);
361
+ fs.mkdirSync(packagesDir, { recursive: true });
362
+
363
+ if (process.platform === "win32") {
364
+ // Escape single quotes for PowerShell: ' → ''
365
+ const psZip = zipPath.replace(/'/g, "''");
366
+ const psDest = packagesDir.replace(/'/g, "''");
367
+ execFileSync("powershell", [
368
+ "-Command",
369
+ `Expand-Archive -Path '${psZip}' -DestinationPath '${psDest}' -Force`
370
+ ], { stdio: "inherit", timeout: 120000 });
371
+ } else {
372
+ execFileSync("unzip", ["-o", "-q", zipPath, "-d", packagesDir], {
373
+ stdio: "inherit",
374
+ timeout: 120000,
375
+ });
376
+ }
377
+
378
+ // macOS: strip quarantine
379
+ if (process.platform === "darwin" && fs.existsSync(flucomaDir)) {
380
+ try {
381
+ execFileSync("xattr", ["-d", "-r", "com.apple.quarantine", flucomaDir], {
382
+ stdio: "pipe",
383
+ timeout: 30000,
384
+ });
385
+ } catch {
386
+ // xattr may fail if no quarantine attribute — that's fine
387
+ }
388
+ }
389
+
390
+ // Clean up temp
391
+ try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
392
+
393
+ if (fs.existsSync(flucomaDir)) {
394
+ console.log("");
395
+ console.log("FluCoMa installed successfully!");
396
+ console.log("Restart Ableton Live for real-time DSP tools.");
397
+ } else {
398
+ console.error("Error: FluCoMa directory not found after extraction.");
399
+ console.error("The zip may have a different structure. Check %s manually.", packagesDir);
400
+ process.exit(1);
401
+ }
402
+ }
403
+
276
404
  // ---------------------------------------------------------------------------
277
405
  // Main
278
406
  // ---------------------------------------------------------------------------
@@ -289,7 +417,7 @@ async function main() {
289
417
 
290
418
  // --help / -h
291
419
  if (flag === "--help" || flag === "-h") {
292
- console.log("livepilot v%s — AI copilot for Ableton Live 12", PKG.version);
420
+ console.log("livepilot v%s — agentic production system for Ableton Live 12", PKG.version);
293
421
  console.log("");
294
422
  console.log("Usage: npx livepilot [command]");
295
423
  console.log("");
@@ -300,6 +428,7 @@ async function main() {
300
428
  console.log(" --status Check if Ableton Live is reachable");
301
429
  console.log(" --doctor Run diagnostics (Python, deps, connection)");
302
430
  console.log(" --version Show version");
431
+ console.log(" --setup-flucoma Install FluCoMa package for real-time DSP");
303
432
  console.log(" --help Show this help");
304
433
  console.log("");
305
434
  console.log("Environment:");
@@ -328,6 +457,12 @@ async function main() {
328
457
  process.exit(reachable ? 0 : 1);
329
458
  }
330
459
 
460
+ // --setup-flucoma
461
+ if (flag === "--setup-flucoma") {
462
+ await setupFlucoma();
463
+ return;
464
+ }
465
+
331
466
  // --doctor
332
467
  if (flag === "--doctor") {
333
468
  const passed = await doctor();
@@ -17,8 +17,8 @@
17
17
  */
18
18
 
19
19
  autowatch = 1;
20
- inlets = 1;
21
- outlets = 2; // 0: to udpsend (responses), 1: to status UI
20
+ inlets = 2; // 0: OSC commands, 1: dspstate~ (sample rate)
21
+ outlets = 2; // 0: to udpsend (responses), 1: to buffer~/status
22
22
 
23
23
  // ── State ──────────────────────────────────────────────────────────────────
24
24
 
@@ -30,6 +30,12 @@ var MAX_PITCH_HISTORY = 128;
30
30
  var detected_key = "";
31
31
  var detected_scale = "";
32
32
 
33
+ // Capture state
34
+ var capture_active = false;
35
+ var capture_timer = null;
36
+ var capture_filename = "";
37
+ var current_sample_rate = 44100; // Updated by dspstate~ via inlet 1
38
+
33
39
  // Base64 encoding table
34
40
  var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
35
41
 
@@ -46,6 +52,15 @@ function bang() {
46
52
  }
47
53
  }
48
54
 
55
+ // ── DSP State (inlet 1: dspstate~ sample rate) ─────────────────────────────
56
+
57
+ function msg_int(v) {
58
+ // dspstate~ sends the sample rate as an int on inlet 1
59
+ if (inlet === 1) {
60
+ current_sample_rate = v > 0 ? v : 44100;
61
+ }
62
+ }
63
+
49
64
  // ── Incoming OSC Message Dispatch ──────────────────────────────────────────
50
65
 
51
66
  function anything() {
@@ -68,7 +83,7 @@ function anything() {
68
83
  function dispatch(cmd, args) {
69
84
  switch(cmd) {
70
85
  case "ping":
71
- send_response({"ok": true, "version": "1.7.0"});
86
+ send_response({"ok": true, "version": "1.8.0"});
72
87
  break;
73
88
  case "get_params":
74
89
  cmd_get_params(args);
@@ -126,6 +141,16 @@ function dispatch(cmd, args) {
126
141
  case "remove_warp_marker":
127
142
  cmd_remove_warp_marker(args);
128
143
  break;
144
+ // ── Phase 3: Capture ──
145
+ case "capture_audio":
146
+ cmd_capture_audio(args);
147
+ break;
148
+ case "capture_stop":
149
+ cmd_capture_stop();
150
+ break;
151
+ case "check_flucoma":
152
+ send_response({"flucoma_available": true, "version": "1.0.9"});
153
+ break;
129
154
  // ── Phase 2: Clip & Display ──
130
155
  case "scrub_clip":
131
156
  cmd_scrub_clip(args);
@@ -921,6 +946,95 @@ function cmd_get_display_values(args) {
921
946
  read_batch();
922
947
  }
923
948
 
949
+ // ── Phase 3: Audio Capture ────────────────────────────────────────────
950
+
951
+ function cmd_capture_audio(args) {
952
+ // args: [duration_ms, filename]
953
+ // duration_ms is the requested record length in milliseconds.
954
+ // filename is the desired output name (empty = auto-generate).
955
+ if (capture_active) {
956
+ send_response({"error": "Capture already in progress. Call capture_stop first."});
957
+ return;
958
+ }
959
+
960
+ var duration_ms = parseInt(args[0]) || 10000;
961
+ var requested_name = args[1] ? args[1].toString().trim() : "";
962
+
963
+ // Generate a timestamped filename if none provided
964
+ var d = new Date();
965
+ var ts = d.getFullYear() + "_"
966
+ + pad2(d.getMonth() + 1) + "_"
967
+ + pad2(d.getDate()) + "_"
968
+ + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
969
+ capture_filename = requested_name.length > 0 ? requested_name : ("capture_" + ts + ".wav");
970
+
971
+ // Calculate sample count from duration and current sample rate
972
+ var num_samples = Math.ceil((duration_ms / 1000.0) * current_sample_rate);
973
+
974
+ capture_active = true;
975
+
976
+ // Tell the Max patch to start recording into buffer~ via outlet 1.
977
+ // The patch is expected to connect outlet 1 to a buffer~ / record~ rig.
978
+ // Message: "capture_start <filename> <num_samples>"
979
+ outlet(1, "capture_start", capture_filename, num_samples);
980
+
981
+ // Set a timer to call cmd_capture_write_done after duration_ms.
982
+ // If the buffer~ fires its bang first (via a connected message), that
983
+ // call will also land here — the guard flag prevents double-response.
984
+ capture_timer = new Task(function() {
985
+ cmd_capture_write_done();
986
+ });
987
+ capture_timer.schedule(duration_ms);
988
+ }
989
+
990
+ function cmd_capture_write_done() {
991
+ // Called when buffer~ finishes writing (bang from record~), or by the
992
+ // timer. Guards against double invocation.
993
+ if (!capture_active) return;
994
+ capture_active = false;
995
+ if (capture_timer) {
996
+ capture_timer.cancel();
997
+ capture_timer = null;
998
+ }
999
+
1000
+ var written = capture_filename;
1001
+ capture_filename = "";
1002
+
1003
+ // Send /capture_complete back to the MCP server via outlet 0.
1004
+ var encoded = base64_encode(JSON.stringify({
1005
+ "ok": true,
1006
+ "file": written,
1007
+ "sample_rate": current_sample_rate
1008
+ }));
1009
+ outlet(0, "/capture_complete", encoded);
1010
+ }
1011
+
1012
+ function cmd_capture_stop() {
1013
+ if (!capture_active) {
1014
+ send_response({"ok": true, "stopped": false, "message": "No capture was active"});
1015
+ return;
1016
+ }
1017
+
1018
+ // Cancel the countdown timer so cmd_capture_write_done isn't called twice
1019
+ if (capture_timer) {
1020
+ capture_timer.cancel();
1021
+ capture_timer = null;
1022
+ }
1023
+
1024
+ // Signal the Max patch to stop recording early
1025
+ outlet(1, "capture_stop");
1026
+
1027
+ capture_active = false;
1028
+ var written = capture_filename;
1029
+ capture_filename = "";
1030
+
1031
+ send_response({"ok": true, "stopped": true, "file": written});
1032
+ }
1033
+
1034
+ function pad2(n) {
1035
+ return n < 10 ? "0" + n : "" + n;
1036
+ }
1037
+
924
1038
  // ── Helpers ────────────────────────────────────────────────────────────────
925
1039
 
926
1040
  function build_track_path(track_idx) {
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.7.0"
2
+ __version__ = "1.8.0"
@@ -98,6 +98,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
98
98
  self.cache = cache
99
99
  self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
100
100
  self._response_callback: Optional[asyncio.Future] = None
101
+ self._capture_future: Optional[asyncio.Future] = None
101
102
 
102
103
  def connection_made(self, transport: asyncio.DatagramTransport) -> None:
103
104
  self.transport = transport
@@ -176,6 +177,39 @@ class SpectralReceiver(asyncio.DatagramProtocol):
176
177
  "scale": str(args[1]),
177
178
  })
178
179
 
180
+ elif address == "/spectral_shape" and len(args) >= 7:
181
+ names = ["centroid", "spread", "skewness", "kurtosis", "rolloff", "flatness", "crest"]
182
+ self.cache.update("spectral_shape", {
183
+ n: round(float(args[i]), 4) for i, n in enumerate(names)
184
+ })
185
+
186
+ elif address == "/mel_bands" and len(args) >= 1:
187
+ self.cache.update("mel_bands", [round(float(a), 6) for a in args])
188
+
189
+ elif address == "/chroma" and len(args) >= 12:
190
+ self.cache.update("chroma", [round(float(a), 4) for a in args[:12]])
191
+
192
+ elif address == "/onset" and len(args) >= 2:
193
+ self.cache.update("onset", {
194
+ "detected": float(args[0]) > 0.5,
195
+ "strength": round(float(args[1]), 4),
196
+ })
197
+
198
+ elif address == "/novelty" and len(args) >= 2:
199
+ self.cache.update("novelty", {
200
+ "score": round(float(args[0]), 4),
201
+ "boundary": float(args[1]) > 0.5,
202
+ })
203
+
204
+ elif address == "/loudness" and len(args) >= 2:
205
+ self.cache.update("loudness", {
206
+ "momentary_lufs": round(float(args[0]), 1),
207
+ "true_peak_dbtp": round(float(args[1]), 1),
208
+ })
209
+
210
+ elif address == "/capture_complete" and len(args) >= 1:
211
+ self._handle_capture_complete(str(args[0]))
212
+
179
213
  elif address == "/response" and len(args) >= 1:
180
214
  self._handle_response(str(args[0]))
181
215
 
@@ -210,10 +244,25 @@ class SpectralReceiver(asyncio.DatagramProtocol):
210
244
  del self._chunks[key]
211
245
  self._handle_response(full)
212
246
 
247
+ def _handle_capture_complete(self, encoded: str) -> None:
248
+ """Decode a /capture_complete OSC message and resolve _capture_future."""
249
+ try:
250
+ padded = encoded + "=" * (-len(encoded) % 4)
251
+ decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
252
+ result = json.loads(decoded)
253
+ if self._capture_future and not self._capture_future.done():
254
+ self._capture_future.set_result(result)
255
+ except Exception:
256
+ pass
257
+
213
258
  def set_response_future(self, future: asyncio.Future) -> None:
214
259
  """Set a future to be resolved with the next response."""
215
260
  self._response_callback = future
216
261
 
262
+ def set_capture_future(self, future: asyncio.Future) -> None:
263
+ """Set a future to be resolved when a capture_complete OSC arrives."""
264
+ self._capture_future = future
265
+
217
266
 
218
267
  class M4LBridge:
219
268
  """Sends commands to the M4L device and waits for responses.
@@ -251,6 +300,38 @@ class M4LBridge:
251
300
  except asyncio.TimeoutError:
252
301
  return {"error": "M4L bridge timeout — device may be busy or removed"}
253
302
 
303
+ async def send_capture(self, command: str, *args: Any, timeout: float = 35.0) -> dict:
304
+ """Send a capture command to the M4L device and wait for /capture_complete."""
305
+ if not self.cache.is_connected:
306
+ return {"error": "LivePilot Analyzer not connected. Drop it on the master track."}
307
+
308
+ # Cancel any stale capture future before creating a new one
309
+ if self.receiver and self.receiver._capture_future and not self.receiver._capture_future.done():
310
+ self.receiver._capture_future.cancel()
311
+
312
+ loop = asyncio.get_running_loop()
313
+ future = loop.create_future()
314
+ if self.receiver:
315
+ self.receiver.set_capture_future(future)
316
+
317
+ osc_data = self._build_osc(command, args)
318
+ self._sock.sendto(osc_data, self._m4l_addr)
319
+
320
+ try:
321
+ result = await asyncio.wait_for(future, timeout=timeout)
322
+ return result
323
+ except asyncio.TimeoutError:
324
+ # Clean up the dangling future
325
+ if self.receiver:
326
+ self.receiver._capture_future = None
327
+ return {"error": "M4L capture timeout — device may be busy or removed"}
328
+
329
+ def cancel_capture_future(self) -> None:
330
+ """Cancel any in-progress capture future (called by capture_stop)."""
331
+ if self.receiver and self.receiver._capture_future and not self.receiver._capture_future.done():
332
+ self.receiver._capture_future.cancel()
333
+ self.receiver._capture_future = None
334
+
254
335
  def _build_osc(self, address: str, args: tuple) -> bytes:
255
336
  """Build a minimal OSC message."""
256
337
  # Address string (null-terminated, padded to 4 bytes)
@@ -60,6 +60,7 @@ from .tools import theory # noqa: F401, E402
60
60
  from .tools import generative # noqa: F401, E402
61
61
  from .tools import harmony # noqa: F401, E402
62
62
  from .tools import midi_io # noqa: F401, E402
63
+ from .tools import perception # noqa: F401, E402
63
64
 
64
65
 
65
66
  # ---------------------------------------------------------------------------