livepilot 1.7.6 → 1.8.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/CHANGELOG.md +38 -0
- package/README.md +5 -5
- package/bin/livepilot.js +135 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +117 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/m4l_bridge.py +81 -0
- package/mcp_server/server.py +1 -0
- package/mcp_server/tools/_perception_engine.py +459 -0
- package/mcp_server/tools/_theory_engine.py +17 -2
- package/mcp_server/tools/analyzer.py +186 -2
- package/mcp_server/tools/perception.py +214 -0
- package/package.json +2 -2
- package/plugin/plugin.json +2 -2
- package/plugin/skills/livepilot-core/SKILL.md +6 -6
- package/plugin/skills/livepilot-core/references/overview.md +3 -3
- package/remote_script/LivePilot/__init__.py +2 -2
- package/requirements.txt +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.1 — Patch (March 2026)
|
|
4
|
+
|
|
5
|
+
- Fix: `parse_key()` now accepts shorthand key notation ("Am", "C#m", "Bbm") in addition to "A minor" / "C# major"
|
|
6
|
+
- Fix: re-freeze LivePilot_Analyzer.amxd with v1.8.0 bridge + patch openinpresentation
|
|
7
|
+
- Fix: address audit findings from fresh v1.8 code review
|
|
8
|
+
- Fix: update bridge version string
|
|
9
|
+
|
|
10
|
+
## 1.8.0 — Perception Layer (March 2026)
|
|
11
|
+
|
|
12
|
+
**13 new tools (155 → 168), 1 new domain (perception), FluCoMa real-time DSP, offline audio analysis, audio capture.**
|
|
13
|
+
|
|
14
|
+
### Perception Domain (4 tools)
|
|
15
|
+
- `analyze_loudness` — LUFS, sample peak, RMS, crest factor, LRA, streaming compliance
|
|
16
|
+
- `analyze_spectrum_offline` — spectral centroid, rolloff, flatness, bandwidth, 5-band balance
|
|
17
|
+
- `compare_to_reference` — mix vs reference: loudness/spectral/stereo deltas + suggestions
|
|
18
|
+
- `read_audio_metadata` — format, duration, sample rate, tags, artwork detection
|
|
19
|
+
|
|
20
|
+
### Analyzer — Capture (2 tools)
|
|
21
|
+
- `capture_audio` — record master output to WAV via M4L buffer~/record~
|
|
22
|
+
- `capture_stop` — cancel in-progress capture
|
|
23
|
+
|
|
24
|
+
### Analyzer — FluCoMa Real-Time (7 tools)
|
|
25
|
+
- `get_spectral_shape` — 7 descriptors (centroid, spread, skewness, kurtosis, rolloff, flatness, crest)
|
|
26
|
+
- `get_mel_spectrum` — 40-band mel spectrum (5x resolution of get_master_spectrum)
|
|
27
|
+
- `get_chroma` — 12 pitch class energies for chord detection
|
|
28
|
+
- `get_onsets` — real-time onset/transient detection
|
|
29
|
+
- `get_novelty` — spectral novelty for section boundary detection
|
|
30
|
+
- `get_momentary_loudness` — EBU R128 momentary LUFS + peak
|
|
31
|
+
- `check_flucoma` — verify FluCoMa installation status
|
|
32
|
+
|
|
33
|
+
### Architecture
|
|
34
|
+
- New `_perception_engine.py` — pure scipy/pyloudnorm/soundfile/mutagen analysis (no MCP deps)
|
|
35
|
+
- New `perception.py` — 4 MCP tool wrappers with format validation
|
|
36
|
+
- 6 FluCoMa OSC handlers in SpectralReceiver (`/spectral_shape`, `/mel_bands`, `/chroma`, `/onset`, `/novelty`, `/loudness`)
|
|
37
|
+
- Dedicated `/capture_complete` channel with `_capture_future` (separate from bridge responses)
|
|
38
|
+
- `--setup-flucoma` CLI command — auto-downloads and installs FluCoMa Max package
|
|
39
|
+
- New dependencies: pyloudnorm, soundfile, scipy, mutagen
|
|
40
|
+
|
|
3
41
|
## 1.7.0 — Creative Engine (March 2026)
|
|
4
42
|
|
|
5
43
|
**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
|
-
|
|
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
|
-
│ │
|
|
42
|
-
│ │
|
|
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
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -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();
|
|
Binary file
|
|
@@ -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
|
|
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.
|
|
86
|
+
send_response({"ok": true, "version": "1.8.1"});
|
|
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) {
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.8.1"
|
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -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)
|
package/mcp_server/server.py
CHANGED
|
@@ -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
|
# ---------------------------------------------------------------------------
|