livepilot 1.9.11 → 1.9.12
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/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +47 -0
- package/README.md +11 -10
- package/bin/livepilot.js +1 -1
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +6 -6
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +4 -4
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/capture_2026_04_07_192216.wav +0 -0
- package/m4l_device/livepilot_bridge.js +77 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +5 -1
- package/mcp_server/curves.py +5 -1
- package/mcp_server/m4l_bridge.py +37 -20
- package/mcp_server/server.py +10 -2
- package/mcp_server/tools/analyzer.py +12 -1
- package/mcp_server/tools/arrangement.py +20 -5
- package/mcp_server/tools/automation.py +52 -0
- package/mcp_server/tools/devices.py +2 -3
- package/mcp_server/tools/generative.py +4 -0
- package/mcp_server/tools/midi_io.py +22 -4
- package/mcp_server/tools/theory.py +8 -1
- package/mcp_server/tools/transport.py +16 -6
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +12 -18
- package/remote_script/LivePilot/browser.py +19 -8
- package/remote_script/LivePilot/clip_automation.py +5 -4
- package/remote_script/LivePilot/clips.py +14 -6
- package/remote_script/LivePilot/devices.py +6 -3
- package/remote_script/LivePilot/server.py +18 -6
- package/remote_script/LivePilot/tracks.py +7 -2
- package/remote_script/LivePilot/transport.py +3 -3
- package/requirements.txt +3 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"name": "livepilot",
|
|
12
12
|
"description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
13
|
-
"version": "1.9.
|
|
13
|
+
"version": "1.9.12",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Pilot Studio"
|
|
16
16
|
},
|
package/AGENTS.md
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.12 — Deep Audit: 21 Fixes Across 15 Files (April 2026)
|
|
4
|
+
|
|
5
|
+
**Full codebase audit — 5 critical, 10 important, 6 doc/test fixes.**
|
|
6
|
+
|
|
7
|
+
### Critical Fixes
|
|
8
|
+
- Fix(P1): `capture_stop` no longer deadlocks — `cancel_capture_future` removed lock acquisition that blocked behind `send_capture`
|
|
9
|
+
- Fix(P1): `import_midi_to_clip` now distinguishes empty-slot NOT_FOUND from INDEX_ERROR/TIMEOUT instead of swallowing all AbletonConnectionErrors
|
|
10
|
+
- Fix(P1): capture audio files now write to `~/Documents/LivePilot/captures/` (stable path) instead of beside the .amxd preset
|
|
11
|
+
- Fix(P1): `check_flucoma` now uses `Folder.end` to detect FluCoMa — `typelist` check was always true
|
|
12
|
+
- Fix(P1): CI workflow updated to `actions/checkout@v4` + `actions/setup-python@v5` (v6 doesn't exist)
|
|
13
|
+
|
|
14
|
+
### Safety & Validation
|
|
15
|
+
- Fix(P2): 5 automation tools now validate `track_index >= 0` and `clip_index >= 0` (matching all peer modules)
|
|
16
|
+
- Fix(P2): `cmd_stop_scrub` now checks `cursor_a.id === 0` for empty clip slots (matching all peer bridge functions)
|
|
17
|
+
- Fix(P2): `cmd_get_selected` now resolves return tracks (negative indices) and master track (-1000)
|
|
18
|
+
- Fix(P2): `duplicate_track` uses count-before/after delta for correct group track duplication index
|
|
19
|
+
- Fix(P2): `create_arrangement_clip` locates first clip by `start_time` instead of stale index after trim pass
|
|
20
|
+
- Fix(P2): `get_session_info` reuses already-built lists instead of re-iterating `song.tracks`/`song.scenes`
|
|
21
|
+
- Fix(P2): client disconnect race — socket now closes before clearing `_client_connected` flag
|
|
22
|
+
|
|
23
|
+
### Tests
|
|
24
|
+
- Fix: transport validation tests now import production `_validate_tempo`/`_validate_time_signature` instead of testing local copies
|
|
25
|
+
- Fix: added `load_sample_to_simpler` to analyzer tool contract (was 28/29)
|
|
26
|
+
- Fix: removed duplicate `test_release_quick_verify_checks_both_plugin_manifests`
|
|
27
|
+
- New: 5 automation negative tests (index validation, parameter_type validation)
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
- Fix: `docs/manual/index.md` domain map — Tracks 14→17, Devices 12→15, Scenes 8→12
|
|
31
|
+
- Fix: README perception split — 145+33 → 149+29 (actual analyzer tool count is 29)
|
|
32
|
+
- Fix: M4L_BRIDGE.md command count — 22→28 (6 commands undocumented)
|
|
33
|
+
- Fix: tool-reference.md MIDI docs — `export_clip_midi` and `import_midi_to_clip` parameter tables matched to actual signatures
|
|
34
|
+
|
|
35
|
+
### Deferred (documented, low-impact)
|
|
36
|
+
- Timed-out commands still execute on main thread (needs cancellation token redesign)
|
|
37
|
+
- Chunked UDP reassembly fragile on packet loss (loopback mitigates)
|
|
38
|
+
- Diatonic transpose octave correction edge case (needs musical test suite)
|
|
39
|
+
- `cmd_map_plugin_param` reports false success (LiveAPI lacks Configure mapping API)
|
|
40
|
+
|
|
41
|
+
Verification: 145 tests passing (non-fastmcp), 178 tools confirmed, 15 files changed
|
|
42
|
+
|
|
3
43
|
## 1.9.11 — Session Diagnostics + Client Conflict Clarity (March 2026)
|
|
4
44
|
|
|
5
45
|
**Live-tested against the open Ableton set after reloading the updated Remote Script.**
|
|
@@ -11,6 +51,13 @@
|
|
|
11
51
|
- Fix(P2): `--status` and TCP timeout paths now explain when another LivePilot client appears to be connected instead of only reporting a generic timeout
|
|
12
52
|
- Docs: beat/sounddesign/core skill guidance now includes device-health checks, sample-dependent plugin cautions, and pitch-audit discipline from the live stress-test sessions
|
|
13
53
|
- Verification: `292 passed`, `npm pack --dry-run` passed, live set diagnostics succeeded, analyzer bridge streamed on the master track, and conflict reproduction now reports the competing client PID
|
|
54
|
+
- Fix(P1): brownian automation curve reflection loop now has 100-iteration guard with hard clamp fallback — high volatility values could previously hang the server
|
|
55
|
+
- Fix(P1): schema coercion now recurses into array `items` so `list[float]` params benefit from string-to-number widening for MCP clients that serialize as strings
|
|
56
|
+
- Fix(P1): `apply_automation_shape` and `apply_automation_recipe` now validate `parameter_type` and required companion params before sending to Ableton
|
|
57
|
+
- Fix(P2): Remote Script `AssertionError` fallbacks now return STATE_ERROR instead of running LOM calls on the TCP thread during ControlSurface disconnection
|
|
58
|
+
- Fix(P2): M4L bridge ping version corrected to 1.9.11; `check_flucoma` now probes disk for FluCoMa package instead of returning hardcoded `true`
|
|
59
|
+
- Verification: deep audit across 45+ files (3 parallel agents), 292 unit tests + 15 live integration tests against Ableton session, all passing
|
|
60
|
+
|
|
14
61
|
## 1.9.10 — Analyzer Capture Finalization + Release Sync (March 2026)
|
|
15
62
|
|
|
16
63
|
**Live-tested in Ableton after a full analyzer rebuild and master-track validation.**
|
package/README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
```
|
|
2
|
+
██╗ ██╗██╗ ██╗███████╗██████╗ ██╗██╗ ██████╗ ████████╗
|
|
3
|
+
██║ ██║██║ ██║██╔════╝██╔══██╗██║██║ ██╔═══██╗╚══██╔══╝
|
|
4
|
+
██║ ██║██║ ██║█████╗ ██████╔╝██║██║ ██║ ██║ ██║
|
|
5
|
+
██║ ██║╚██╗ ██╔╝██╔══╝ ██╔═══╝ ██║██║ ██║ ██║ ██║
|
|
6
|
+
███████╗██║ ╚████╔╝ ███████╗██║ ██║███████╗╚██████╔╝ ██║
|
|
7
|
+
╚══════╝╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝
|
|
8
|
+
```
|
|
8
9
|
|
|
9
10
|
<p align="center">
|
|
10
11
|
<a href="https://github.com/dreamrec/LivePilot/actions"><img src="https://img.shields.io/github/actions/workflow/status/dreamrec/LivePilot/ci.yml?style=flat-square&label=CI" alt="CI"></a>
|
|
@@ -90,7 +91,7 @@ All three feed into 178 deterministic tools that execute on Ableton's main threa
|
|
|
90
91
|
| Tracks | 17 | create MIDI/audio/return, delete, duplicate, arm, mute, solo, color, freeze, flatten |
|
|
91
92
|
| Clips | 11 | create, delete, duplicate, fire, stop, loop, launch mode, warp mode, quantize |
|
|
92
93
|
| Notes | 8 | add/get/remove/modify MIDI notes, transpose, duplicate, per-note probability |
|
|
93
|
-
| Devices |
|
|
94
|
+
| Devices | 15 | load by name or URI, get/set parameters, batch edit, racks, chains, presets, plugin deep control |
|
|
94
95
|
| Scenes | 12 | create, delete, duplicate, fire, name, color, tempo, scene matrix |
|
|
95
96
|
| Browser | 4 | search library, browse tree, load items, filter by category |
|
|
96
97
|
| Mixing | 11 | volume, pan, sends, routing, meters, return tracks, master, full mix snapshot |
|
|
@@ -98,13 +99,13 @@ All three feed into 178 deterministic tools that execute on Ableton's main threa
|
|
|
98
99
|
|
|
99
100
|
<br>
|
|
100
101
|
|
|
101
|
-
### Perception —
|
|
102
|
+
### Perception — 29 tools `[M4L]`
|
|
102
103
|
|
|
103
104
|
The M4L Analyzer sits on the master track. UDP 9880 carries spectral data
|
|
104
105
|
from Max to the server. OSC 9881 sends commands back.
|
|
105
106
|
|
|
106
107
|
> [!TIP]
|
|
107
|
-
> All
|
|
108
|
+
> All 149 core tools work without the analyzer — it adds 29 more and closes the feedback loop.
|
|
108
109
|
|
|
109
110
|
```
|
|
110
111
|
SPECTRAL ─────── 8-band frequency decomposition (sub → air)
|
package/bin/livepilot.js
CHANGED
|
@@ -95,7 +95,7 @@ function ensureVenv(systemPython, prefixArgs) {
|
|
|
95
95
|
// Check if venv already exists and has our deps
|
|
96
96
|
if (fs.existsSync(venvPy)) {
|
|
97
97
|
try {
|
|
98
|
-
execFileSync(venvPy, ["-c", "import fastmcp; import midiutil; import pretty_midi; import numpy; import pyloudnorm; import soundfile; import scipy"], {
|
|
98
|
+
execFileSync(venvPy, ["-c", "import fastmcp; import midiutil; import pretty_midi; import numpy; import pyloudnorm; import soundfile; import scipy; import mutagen"], {
|
|
99
99
|
encoding: "utf-8",
|
|
100
100
|
timeout: 10000,
|
|
101
101
|
stdio: "pipe",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.12",
|
|
4
4
|
"description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Pilot Studio"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.12",
|
|
4
4
|
"description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Pilot Studio"
|
|
@@ -138,8 +138,8 @@ Never skip levels. The user's question determines the entry point, but always st
|
|
|
138
138
|
### Transport (12)
|
|
139
139
|
`get_session_info` · `set_tempo` · `set_time_signature` · `start_playback` · `stop_playback` · `continue_playback` · `toggle_metronome` · `set_session_loop` · `undo` · `redo` · `get_recent_actions` · `get_session_diagnostics`
|
|
140
140
|
|
|
141
|
-
### Tracks (
|
|
142
|
-
`get_track_info` · `create_midi_track` · `create_audio_track` · `create_return_track` · `delete_track` · `duplicate_track` · `set_track_name` · `set_track_color` · `set_track_mute` · `set_track_solo` · `set_track_arm` · `stop_track_clips` · `set_group_fold` · `set_track_input_monitoring`
|
|
141
|
+
### Tracks (17)
|
|
142
|
+
`get_track_info` · `create_midi_track` · `create_audio_track` · `create_return_track` · `delete_track` · `duplicate_track` · `set_track_name` · `set_track_color` · `set_track_mute` · `set_track_solo` · `set_track_arm` · `stop_track_clips` · `set_group_fold` · `set_track_input_monitoring` · `freeze_track` · `flatten_track` · `get_freeze_status`
|
|
143
143
|
|
|
144
144
|
### Clips (11)
|
|
145
145
|
`get_clip_info` · `create_clip` · `delete_clip` · `duplicate_clip` · `fire_clip` · `stop_clip` · `set_clip_name` · `set_clip_color` · `set_clip_loop` · `set_clip_launch` · `set_clip_warp_mode`
|
|
@@ -147,11 +147,11 @@ Never skip levels. The user's question determines the entry point, but always st
|
|
|
147
147
|
### Notes (8)
|
|
148
148
|
`add_notes` · `get_notes` · `remove_notes` · `remove_notes_by_id` · `modify_notes` · `duplicate_notes` · `transpose_notes` · `quantize_clip`
|
|
149
149
|
|
|
150
|
-
### Devices (
|
|
151
|
-
`get_device_info` · `get_device_parameters` · `set_device_parameter` · `batch_set_parameters` · `toggle_device` · `delete_device` · `load_device_by_uri` · `find_and_load_device` · `set_simpler_playback_mode` · `get_rack_chains` · `set_chain_volume` · `get_device_presets`
|
|
150
|
+
### Devices (15)
|
|
151
|
+
`get_device_info` · `get_device_parameters` · `set_device_parameter` · `batch_set_parameters` · `toggle_device` · `delete_device` · `load_device_by_uri` · `find_and_load_device` · `set_simpler_playback_mode` · `get_rack_chains` · `set_chain_volume` · `get_device_presets` · `get_plugin_parameters` · `map_plugin_parameter` · `get_plugin_presets`
|
|
152
152
|
|
|
153
|
-
### Scenes (
|
|
154
|
-
`get_scenes_info` · `create_scene` · `delete_scene` · `duplicate_scene` · `fire_scene` · `set_scene_name` · `set_scene_color` · `set_scene_tempo`
|
|
153
|
+
### Scenes (12)
|
|
154
|
+
`get_scenes_info` · `create_scene` · `delete_scene` · `duplicate_scene` · `fire_scene` · `set_scene_name` · `set_scene_color` · `set_scene_tempo` · `get_scene_matrix` · `fire_scene_clips` · `stop_all_clips` · `get_playing_clips`
|
|
155
155
|
|
|
156
156
|
### Mixing (11)
|
|
157
157
|
`set_track_volume` · `set_track_pan` · `set_track_send` · `get_return_tracks` · `get_master_track` · `set_master_volume` · `get_track_routing` · `set_track_routing` · `get_track_meters` · `get_master_meters` · `get_mix_snapshot`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# LivePilot v1.9.
|
|
1
|
+
# LivePilot v1.9.12 — Architecture & Tool Reference
|
|
2
2
|
|
|
3
3
|
Agentic production system for Ableton Live 12. 178 tools across 17 domains. Device atlas (280+ devices), spectral perception (M4L analyzer), technique memory, automation intelligence (16 curve types, 15 recipes), music theory (Krumhansl-Schmuckler, species counterpoint), generative algorithms (Euclidean rhythm, tintinnabuli, phase shift, additive process), neo-Riemannian harmony (PRL transforms, Tonnetz), MIDI file I/O.
|
|
4
4
|
|
|
@@ -29,12 +29,12 @@ Run this checklist EVERY time the user says "update everything", "push", "releas
|
|
|
29
29
|
## 2. Tool Count (must ALL match)
|
|
30
30
|
|
|
31
31
|
Current: **178 tools across 17 domains**.
|
|
32
|
-
Core (no M4L): **
|
|
32
|
+
Core (no M4L): **149**. Analyzer (M4L): **29**. Perception (offline): **4**.
|
|
33
33
|
|
|
34
34
|
Verify: `grep -rc "@mcp.tool" mcp_server/tools/ | grep -v ":0" | awk -F: '{sum+=$2} END{print sum}'`
|
|
35
35
|
|
|
36
36
|
Files that reference tool count:
|
|
37
|
-
- [ ] `README.md` — header, PERCEPTION section ("
|
|
37
|
+
- [ ] `README.md` — header, PERCEPTION section ("149 core...29 analyzer"), Analyzer table header "(29)", Perception table header "(4)"
|
|
38
38
|
- [ ] `package.json` → `"description"` (178 tools, 17 domains)
|
|
39
39
|
- [ ] `server.json` → `"description"`
|
|
40
40
|
- [ ] `livepilot/.Codex-plugin/plugin.json` → `"description"` (primary Codex manifest)
|
|
@@ -44,10 +44,10 @@ Files that reference tool count:
|
|
|
44
44
|
- [ ] `livepilot/skills/livepilot-core/SKILL.md` — "178 tools across 17 domains", Analyzer (29), Perception (4)
|
|
45
45
|
- [ ] `livepilot/skills/livepilot-core/references/overview.md` — "178 tools across 17 domains"
|
|
46
46
|
- [ ] `docs/manual/index.md` — domain table: Analyzer (29), Perception (4)
|
|
47
|
-
- [ ] `docs/manual/getting-started.md` — "
|
|
47
|
+
- [ ] `docs/manual/getting-started.md` — "149 core tools...29 analyzer"
|
|
48
48
|
- [ ] `docs/manual/tool-reference.md` — all domains present with correct counts
|
|
49
49
|
- [ ] `docs/TOOL_REFERENCE.md` — all domains present
|
|
50
|
-
- [ ] `docs/M4L_BRIDGE.md` — "
|
|
50
|
+
- [ ] `docs/M4L_BRIDGE.md` — "149 core tools...29 analyzer"
|
|
51
51
|
- [ ] `docs/social-banner.html`
|
|
52
52
|
- [ ] `mcp_server/tools/analyzer.py` → module docstring
|
|
53
53
|
- [ ] `tests/test_tools_contract.py` → expected total count
|
|
Binary file
|
|
Binary file
|
|
@@ -84,7 +84,7 @@ function anything() {
|
|
|
84
84
|
function dispatch(cmd, args) {
|
|
85
85
|
switch(cmd) {
|
|
86
86
|
case "ping":
|
|
87
|
-
send_response({"ok": true, "version": "1.9.
|
|
87
|
+
send_response({"ok": true, "version": "1.9.12"});
|
|
88
88
|
break;
|
|
89
89
|
case "get_params":
|
|
90
90
|
cmd_get_params(args);
|
|
@@ -150,7 +150,7 @@ function dispatch(cmd, args) {
|
|
|
150
150
|
cmd_capture_stop();
|
|
151
151
|
break;
|
|
152
152
|
case "check_flucoma":
|
|
153
|
-
|
|
153
|
+
cmd_check_flucoma();
|
|
154
154
|
break;
|
|
155
155
|
// ── Phase 2: Clip & Display ──
|
|
156
156
|
case "scrub_clip":
|
|
@@ -458,6 +458,22 @@ function cmd_get_chains_deep(args) {
|
|
|
458
458
|
}
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
+
function cmd_check_flucoma() {
|
|
462
|
+
// Check if FluCoMa externals are installed.
|
|
463
|
+
// Max JS cannot reliably probe the object search path at runtime,
|
|
464
|
+
// so we check if the FluCoMa package folder exists on disk.
|
|
465
|
+
try {
|
|
466
|
+
var pkg_path = max.appsupportpath + "/Packages/FluidCorpusManipulation";
|
|
467
|
+
var f = new Folder(pkg_path);
|
|
468
|
+
var available = !f.end; // end === true means folder not found
|
|
469
|
+
f.close();
|
|
470
|
+
send_response({"flucoma_available": available});
|
|
471
|
+
} catch (e) {
|
|
472
|
+
// Can't probe — report unknown rather than lying
|
|
473
|
+
send_response({"flucoma_available": false, "probe_error": String(e)});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
461
477
|
function cmd_get_track_cpu(args) {
|
|
462
478
|
try {
|
|
463
479
|
var results = [];
|
|
@@ -511,6 +527,25 @@ function cmd_get_selected() {
|
|
|
511
527
|
break;
|
|
512
528
|
}
|
|
513
529
|
}
|
|
530
|
+
// Check return tracks if not found in main tracks
|
|
531
|
+
if (result.selected_track === -1) {
|
|
532
|
+
cursor_a.goto("live_set");
|
|
533
|
+
var rtc = cursor_a.getcount("return_tracks");
|
|
534
|
+
for (var j = 0; j < rtc; j++) {
|
|
535
|
+
cursor_a.goto("live_set return_tracks " + j);
|
|
536
|
+
if (cursor_a.get("name").toString() === result.selected_track_name) {
|
|
537
|
+
result.selected_track = -(j + 1); // -1, -2, ... convention
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Check master track if still not found
|
|
543
|
+
if (result.selected_track === -1) {
|
|
544
|
+
cursor_a.goto("live_set master_track");
|
|
545
|
+
if (cursor_a.get("name").toString() === result.selected_track_name) {
|
|
546
|
+
result.selected_track = -1000; // master convention
|
|
547
|
+
}
|
|
548
|
+
}
|
|
514
549
|
} catch(e) {}
|
|
515
550
|
|
|
516
551
|
// Selected scene
|
|
@@ -1067,6 +1102,10 @@ function cmd_stop_scrub(args) {
|
|
|
1067
1102
|
var path = build_track_path(track_idx) + " clip_slots " + clip_idx + " clip";
|
|
1068
1103
|
|
|
1069
1104
|
cursor_a.goto(path);
|
|
1105
|
+
if (cursor_a.id === 0) {
|
|
1106
|
+
send_response({"error": "No clip at track " + track_idx + " slot " + clip_idx});
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1070
1109
|
try {
|
|
1071
1110
|
cursor_a.call("stop_scrub");
|
|
1072
1111
|
send_response({"ok": true});
|
|
@@ -1165,6 +1204,15 @@ function cmd_capture_audio(args) {
|
|
|
1165
1204
|
var duration_ms = parseInt(args[0]) || 10000;
|
|
1166
1205
|
var requested_name = args[1] ? args[1].toString().trim() : "";
|
|
1167
1206
|
|
|
1207
|
+
// Sanitize filename — strip any directory components (defense-in-depth)
|
|
1208
|
+
if (requested_name.length > 0) {
|
|
1209
|
+
requested_name = _safe_filename(requested_name);
|
|
1210
|
+
if (!requested_name || requested_name.length === 0) {
|
|
1211
|
+
send_response({"error": "Invalid capture filename (path traversal blocked)"});
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1168
1216
|
// Generate a timestamped filename if none provided
|
|
1169
1217
|
var d = new Date();
|
|
1170
1218
|
var ts = d.getFullYear() + "_"
|
|
@@ -1172,7 +1220,7 @@ function cmd_capture_audio(args) {
|
|
|
1172
1220
|
+ pad2(d.getDate()) + "_"
|
|
1173
1221
|
+ pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
|
|
1174
1222
|
capture_filename = requested_name.length > 0 ? requested_name : ("capture_" + ts + ".wav");
|
|
1175
|
-
capture_file_path = _join_path(
|
|
1223
|
+
capture_file_path = _join_path(_get_captures_dir(), capture_filename);
|
|
1176
1224
|
|
|
1177
1225
|
// Calculate sample count from duration and current sample rate
|
|
1178
1226
|
var num_samples = Math.ceil((duration_ms / 1000.0) * current_sample_rate);
|
|
@@ -1438,6 +1486,22 @@ function build_device_path(track_idx, device_idx) {
|
|
|
1438
1486
|
}
|
|
1439
1487
|
}
|
|
1440
1488
|
|
|
1489
|
+
function _get_captures_dir() {
|
|
1490
|
+
// Stable captures directory: ~/Documents/LivePilot/captures/
|
|
1491
|
+
// max.appsupportpath = "/Users/<name>/Library/Application Support/Cycling '74"
|
|
1492
|
+
// Walk up to get home directory
|
|
1493
|
+
try {
|
|
1494
|
+
var support = max.appsupportpath;
|
|
1495
|
+
var parts = support.split("/");
|
|
1496
|
+
// /Users/<name>/Library/... → first 3 parts = /Users/<name>
|
|
1497
|
+
var home = parts.slice(0, 3).join("/");
|
|
1498
|
+
return home + "/Documents/LivePilot/captures/";
|
|
1499
|
+
} catch (e) {
|
|
1500
|
+
// Fallback to patcher directory if home detection fails
|
|
1501
|
+
return _get_patcher_dir();
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1441
1505
|
function _get_patcher_dir() {
|
|
1442
1506
|
try {
|
|
1443
1507
|
var filepath = this.patcher && this.patcher.filepath ? this.patcher.filepath.toString() : "";
|
|
@@ -1450,6 +1514,16 @@ function _get_patcher_dir() {
|
|
|
1450
1514
|
}
|
|
1451
1515
|
}
|
|
1452
1516
|
|
|
1517
|
+
function _safe_filename(name) {
|
|
1518
|
+
// Strip directory components and reject traversal attempts.
|
|
1519
|
+
// This is defense-in-depth — Python should sanitize first.
|
|
1520
|
+
if (!name || name.length === 0) return name;
|
|
1521
|
+
var slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
|
|
1522
|
+
if (slash >= 0) name = name.substring(slash + 1);
|
|
1523
|
+
if (name === "." || name === ".." || name.length === 0) return "";
|
|
1524
|
+
return name;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1453
1527
|
function _join_path(dir, file) {
|
|
1454
1528
|
if (!dir) return file;
|
|
1455
1529
|
var last = dir.charAt(dir.length - 1);
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.9.
|
|
2
|
+
__version__ = "1.9.12"
|
package/mcp_server/connection.py
CHANGED
|
@@ -213,7 +213,7 @@ class AbletonConnection:
|
|
|
213
213
|
raise AbletonConnectionError(f"Failed to send command: {exc}") from exc
|
|
214
214
|
|
|
215
215
|
# Read until newline, preserving any trailing bytes in _recv_buf
|
|
216
|
-
buf =
|
|
216
|
+
buf = self._recv_buf
|
|
217
217
|
try:
|
|
218
218
|
while b"\n" not in buf:
|
|
219
219
|
chunk = self._socket.recv(4096)
|
|
@@ -222,6 +222,10 @@ class AbletonConnection:
|
|
|
222
222
|
self.disconnect()
|
|
223
223
|
raise AbletonConnectionError("Connection closed by Ableton")
|
|
224
224
|
buf += chunk
|
|
225
|
+
if len(buf) > 10 * 1024 * 1024: # 10 MB
|
|
226
|
+
self._recv_buf = b""
|
|
227
|
+
self.disconnect()
|
|
228
|
+
raise AbletonConnectionError("Response too large (>10 MB)")
|
|
225
229
|
except socket.timeout as exc:
|
|
226
230
|
self._recv_buf = buf
|
|
227
231
|
self.disconnect()
|
package/mcp_server/curves.py
CHANGED
|
@@ -366,11 +366,15 @@ def _brownian(duration: float, density: int, start: float = 0.5,
|
|
|
366
366
|
step = drift / density + volatility * _det_random(i, seed)
|
|
367
367
|
value += step
|
|
368
368
|
# Soft boundary reflection (bounce off 0/1 until within range)
|
|
369
|
-
|
|
369
|
+
for _ in range(100):
|
|
370
|
+
if 0.0 <= value <= 1.0:
|
|
371
|
+
break
|
|
370
372
|
if value > 1.0:
|
|
371
373
|
value = 2.0 - value
|
|
372
374
|
if value < 0.0:
|
|
373
375
|
value = -value
|
|
376
|
+
else:
|
|
377
|
+
value = max(0.0, min(1.0, value))
|
|
374
378
|
return points
|
|
375
379
|
|
|
376
380
|
|
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -141,6 +141,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
141
141
|
def __init__(self, cache: SpectralCache):
|
|
142
142
|
self.cache = cache
|
|
143
143
|
self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
|
|
144
|
+
self._chunk_times: dict[str, float] = {} # Monotonic timestamp per chunk sequence
|
|
144
145
|
self._chunk_id = 0
|
|
145
146
|
self._response_callback: Optional[asyncio.Future] = None
|
|
146
147
|
self._capture_future: Optional[asyncio.Future] = None
|
|
@@ -284,6 +285,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
284
285
|
key = str(self._chunk_id)
|
|
285
286
|
if key not in self._chunks:
|
|
286
287
|
self._chunks[key] = {"parts": {}, "total": total}
|
|
288
|
+
self._chunk_times[key] = time.monotonic()
|
|
287
289
|
|
|
288
290
|
self._chunks[key]["parts"][index] = encoded
|
|
289
291
|
|
|
@@ -293,8 +295,16 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
293
295
|
for i in range(total):
|
|
294
296
|
full += self._chunks[key]["parts"][i]
|
|
295
297
|
del self._chunks[key]
|
|
298
|
+
self._chunk_times.pop(key, None)
|
|
296
299
|
self._handle_response(full)
|
|
297
300
|
|
|
301
|
+
# Evict incomplete chunk sequences older than 30 seconds
|
|
302
|
+
now = time.monotonic()
|
|
303
|
+
stale = [k for k, t in self._chunk_times.items() if now - t > 30.0]
|
|
304
|
+
for k in stale:
|
|
305
|
+
self._chunks.pop(k, None)
|
|
306
|
+
self._chunk_times.pop(k, None)
|
|
307
|
+
|
|
298
308
|
def _handle_capture_complete(self, encoded: str) -> None:
|
|
299
309
|
"""Decode a /capture_complete OSC message and resolve _capture_future."""
|
|
300
310
|
try:
|
|
@@ -359,30 +369,37 @@ class M4LBridge:
|
|
|
359
369
|
if not self.cache.is_connected:
|
|
360
370
|
return {"error": "LivePilot Analyzer not connected. Drop it on the master track."}
|
|
361
371
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
self.receiver._capture_future.
|
|
372
|
+
async with self._cmd_lock:
|
|
373
|
+
# Cancel any stale capture future before creating a new one
|
|
374
|
+
if self.receiver and self.receiver._capture_future and not self.receiver._capture_future.done():
|
|
375
|
+
self.receiver._capture_future.cancel()
|
|
365
376
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
377
|
+
loop = asyncio.get_running_loop()
|
|
378
|
+
future = loop.create_future()
|
|
379
|
+
if self.receiver:
|
|
380
|
+
self.receiver.set_capture_future(future)
|
|
370
381
|
|
|
371
|
-
|
|
372
|
-
|
|
382
|
+
osc_data = self._build_osc(command, args)
|
|
383
|
+
self._sock.sendto(osc_data, self._m4l_addr)
|
|
373
384
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
385
|
+
try:
|
|
386
|
+
result = await asyncio.wait_for(future, timeout=timeout)
|
|
387
|
+
return result
|
|
388
|
+
except asyncio.TimeoutError:
|
|
389
|
+
# Clean up the dangling future
|
|
390
|
+
if self.receiver:
|
|
391
|
+
self.receiver._capture_future = None
|
|
392
|
+
return {"error": "M4L capture timeout — device may be busy or removed"}
|
|
382
393
|
|
|
383
|
-
def cancel_capture_future(self) -> None:
|
|
384
|
-
"""Cancel any in-progress capture future (called by capture_stop).
|
|
385
|
-
|
|
394
|
+
async def cancel_capture_future(self) -> None:
|
|
395
|
+
"""Cancel any in-progress capture future (called by capture_stop).
|
|
396
|
+
|
|
397
|
+
Does NOT acquire _cmd_lock — send_capture holds it during recording.
|
|
398
|
+
Cancelling the future causes send_capture's wait_for to raise
|
|
399
|
+
CancelledError, which releases the lock naturally.
|
|
400
|
+
"""
|
|
401
|
+
if self.receiver and self.receiver._capture_future \
|
|
402
|
+
and not self.receiver._capture_future.done():
|
|
386
403
|
self.receiver._capture_future.cancel()
|
|
387
404
|
self.receiver._capture_future = None
|
|
388
405
|
|
package/mcp_server/server.py
CHANGED
|
@@ -119,12 +119,20 @@ from .tools import perception # noqa: F401, E402
|
|
|
119
119
|
|
|
120
120
|
def _coerce_schema_property(prop: dict) -> None:
|
|
121
121
|
"""Widen a single JSON Schema property to also accept strings."""
|
|
122
|
-
if prop.get("type") in ("integer", "number"):
|
|
122
|
+
if prop.get("type") in ("integer", "number") and "anyOf" not in prop:
|
|
123
123
|
original_type = prop.pop("type")
|
|
124
124
|
prop["anyOf"] = [{"type": original_type}, {"type": "string"}]
|
|
125
125
|
elif "anyOf" in prop:
|
|
126
|
+
# Skip if this anyOf was already coerced (contains both a numeric and string type)
|
|
127
|
+
variant_types = {v.get("type") for v in prop["anyOf"] if isinstance(v, dict)}
|
|
128
|
+
if "string" in variant_types and variant_types & {"integer", "number"}:
|
|
129
|
+
return
|
|
126
130
|
for variant in prop["anyOf"]:
|
|
127
|
-
|
|
131
|
+
if isinstance(variant, dict):
|
|
132
|
+
_coerce_schema_property(variant)
|
|
133
|
+
# Recurse into array items so list[int]/list[float] params also accept strings
|
|
134
|
+
if "items" in prop and isinstance(prop["items"], dict):
|
|
135
|
+
_coerce_schema_property(prop["items"])
|
|
128
136
|
|
|
129
137
|
|
|
130
138
|
def _get_all_tools():
|
|
@@ -590,7 +590,18 @@ async def capture_audio(
|
|
|
590
590
|
if source not in ("master",):
|
|
591
591
|
raise ValueError(f"Unsupported source '{source}'. Valid: 'master'")
|
|
592
592
|
|
|
593
|
+
# Sanitize filename — strip directory components to prevent path traversal
|
|
594
|
+
if filename:
|
|
595
|
+
safe_name = os.path.basename(filename)
|
|
596
|
+
if not safe_name or safe_name != filename:
|
|
597
|
+
raise ValueError(
|
|
598
|
+
f"Filename must not contain path separators or '..' segments: {filename!r}"
|
|
599
|
+
)
|
|
600
|
+
filename = safe_name
|
|
601
|
+
|
|
593
602
|
bridge = _get_m4l(ctx)
|
|
603
|
+
# Ensure captures directory exists before sending to bridge
|
|
604
|
+
os.makedirs(CAPTURE_DIR, exist_ok=True)
|
|
594
605
|
duration_ms = duration_seconds * 1000
|
|
595
606
|
result = await bridge.send_capture(
|
|
596
607
|
"capture_audio",
|
|
@@ -613,7 +624,7 @@ async def capture_stop(ctx: Context) -> dict:
|
|
|
613
624
|
_require_analyzer(cache)
|
|
614
625
|
bridge = _get_m4l(ctx)
|
|
615
626
|
# Cancel the capture future so send_capture doesn't hang forever
|
|
616
|
-
bridge.cancel_capture_future()
|
|
627
|
+
await bridge.cancel_capture_future()
|
|
617
628
|
return await bridge.send_command("capture_stop")
|
|
618
629
|
|
|
619
630
|
|
|
@@ -162,7 +162,10 @@ def add_arrangement_notes(
|
|
|
162
162
|
_validate_track_index(track_index)
|
|
163
163
|
_validate_clip_index(clip_index)
|
|
164
164
|
if isinstance(notes, str):
|
|
165
|
-
|
|
165
|
+
try:
|
|
166
|
+
notes = json.loads(notes)
|
|
167
|
+
except json.JSONDecodeError as exc:
|
|
168
|
+
raise ValueError(f"Invalid JSON in notes parameter: {exc}") from exc
|
|
166
169
|
for note in notes:
|
|
167
170
|
_validate_note(note)
|
|
168
171
|
return _get_ableton(ctx).send_command("add_arrangement_notes", {
|
|
@@ -206,7 +209,10 @@ def set_arrangement_automation(
|
|
|
206
209
|
if parameter_type == "send" and send_index is None:
|
|
207
210
|
raise ValueError("send_index required for parameter_type='send'")
|
|
208
211
|
if isinstance(points, str):
|
|
209
|
-
|
|
212
|
+
try:
|
|
213
|
+
points = json.loads(points)
|
|
214
|
+
except json.JSONDecodeError as exc:
|
|
215
|
+
raise ValueError(f"Invalid JSON in points parameter: {exc}") from exc
|
|
210
216
|
if not points:
|
|
211
217
|
raise ValueError("points list cannot be empty")
|
|
212
218
|
params: dict = {
|
|
@@ -352,7 +358,10 @@ def remove_arrangement_notes_by_id(
|
|
|
352
358
|
_validate_track_index(track_index)
|
|
353
359
|
_validate_clip_index(clip_index)
|
|
354
360
|
if isinstance(note_ids, str):
|
|
355
|
-
|
|
361
|
+
try:
|
|
362
|
+
note_ids = json.loads(note_ids)
|
|
363
|
+
except json.JSONDecodeError as exc:
|
|
364
|
+
raise ValueError(f"Invalid JSON in note_ids parameter: {exc}") from exc
|
|
356
365
|
if not note_ids:
|
|
357
366
|
raise ValueError("note_ids list cannot be empty")
|
|
358
367
|
return _get_ableton(ctx).send_command("remove_arrangement_notes_by_id", {
|
|
@@ -374,7 +383,10 @@ def modify_arrangement_notes(
|
|
|
374
383
|
_validate_track_index(track_index)
|
|
375
384
|
_validate_clip_index(clip_index)
|
|
376
385
|
if isinstance(modifications, str):
|
|
377
|
-
|
|
386
|
+
try:
|
|
387
|
+
modifications = json.loads(modifications)
|
|
388
|
+
except json.JSONDecodeError as exc:
|
|
389
|
+
raise ValueError(f"Invalid JSON in modifications parameter: {exc}") from exc
|
|
378
390
|
if not modifications:
|
|
379
391
|
raise ValueError("modifications list cannot be empty")
|
|
380
392
|
for mod in modifications:
|
|
@@ -407,7 +419,10 @@ def duplicate_arrangement_notes(
|
|
|
407
419
|
_validate_track_index(track_index)
|
|
408
420
|
_validate_clip_index(clip_index)
|
|
409
421
|
if isinstance(note_ids, str):
|
|
410
|
-
|
|
422
|
+
try:
|
|
423
|
+
note_ids = json.loads(note_ids)
|
|
424
|
+
except json.JSONDecodeError as exc:
|
|
425
|
+
raise ValueError(f"Invalid JSON in note_ids parameter: {exc}") from exc
|
|
411
426
|
if not note_ids:
|
|
412
427
|
raise ValueError("note_ids list cannot be empty")
|
|
413
428
|
return _get_ableton(ctx).send_command("duplicate_arrangement_notes", {
|
|
@@ -43,6 +43,10 @@ def get_clip_automation(
|
|
|
43
43
|
parameter name, and type (mixer/send/device). Use this to see
|
|
44
44
|
what's already automated before writing new curves.
|
|
45
45
|
"""
|
|
46
|
+
if track_index < 0:
|
|
47
|
+
raise ValueError("track_index must be >= 0")
|
|
48
|
+
if clip_index < 0:
|
|
49
|
+
raise ValueError("clip_index must be >= 0")
|
|
46
50
|
return _get_ableton(ctx).send_command("get_clip_automation", {
|
|
47
51
|
"track_index": track_index,
|
|
48
52
|
"clip_index": clip_index,
|
|
@@ -72,6 +76,17 @@ def set_clip_automation(
|
|
|
72
76
|
Tip: Use apply_automation_shape to generate points from curves/recipes
|
|
73
77
|
instead of calculating points manually.
|
|
74
78
|
"""
|
|
79
|
+
if track_index < 0:
|
|
80
|
+
raise ValueError("track_index must be >= 0")
|
|
81
|
+
if clip_index < 0:
|
|
82
|
+
raise ValueError("clip_index must be >= 0")
|
|
83
|
+
if parameter_type not in ("device", "volume", "panning", "send"):
|
|
84
|
+
raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
|
|
85
|
+
if parameter_type == "device":
|
|
86
|
+
if device_index is None or parameter_index is None:
|
|
87
|
+
raise ValueError("device_index and parameter_index required for parameter_type='device'")
|
|
88
|
+
if parameter_type == "send" and send_index is None:
|
|
89
|
+
raise ValueError("send_index required for parameter_type='send'")
|
|
75
90
|
params: dict = {
|
|
76
91
|
"track_index": track_index,
|
|
77
92
|
"clip_index": clip_index,
|
|
@@ -102,11 +117,22 @@ def clear_clip_automation(
|
|
|
102
117
|
If parameter_type is omitted, clears ALL envelopes.
|
|
103
118
|
If provided, clears only that parameter's envelope.
|
|
104
119
|
"""
|
|
120
|
+
if track_index < 0:
|
|
121
|
+
raise ValueError("track_index must be >= 0")
|
|
122
|
+
if clip_index < 0:
|
|
123
|
+
raise ValueError("clip_index must be >= 0")
|
|
105
124
|
params: dict = {
|
|
106
125
|
"track_index": track_index,
|
|
107
126
|
"clip_index": clip_index,
|
|
108
127
|
}
|
|
109
128
|
if parameter_type is not None:
|
|
129
|
+
if parameter_type not in ("device", "volume", "panning", "send"):
|
|
130
|
+
raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
|
|
131
|
+
if parameter_type == "device":
|
|
132
|
+
if device_index is None or parameter_index is None:
|
|
133
|
+
raise ValueError("device_index and parameter_index required for parameter_type='device'")
|
|
134
|
+
if parameter_type == "send" and send_index is None:
|
|
135
|
+
raise ValueError("send_index required for parameter_type='send'")
|
|
110
136
|
params["parameter_type"] = parameter_type
|
|
111
137
|
if device_index is not None:
|
|
112
138
|
params["device_index"] = device_index
|
|
@@ -190,6 +216,19 @@ def apply_automation_shape(
|
|
|
190
216
|
- Throws: use spike with short duration (1-2 beats)
|
|
191
217
|
- Tremolo/pan: use sine with frequency in musical divisions
|
|
192
218
|
"""
|
|
219
|
+
# Validate indices and parameter_type (same rules as set_clip_automation)
|
|
220
|
+
if track_index < 0:
|
|
221
|
+
raise ValueError("track_index must be >= 0")
|
|
222
|
+
if clip_index < 0:
|
|
223
|
+
raise ValueError("clip_index must be >= 0")
|
|
224
|
+
if parameter_type not in ("device", "volume", "panning", "send"):
|
|
225
|
+
raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
|
|
226
|
+
if parameter_type == "device":
|
|
227
|
+
if device_index is None or parameter_index is None:
|
|
228
|
+
raise ValueError("device_index and parameter_index required for parameter_type='device'")
|
|
229
|
+
if parameter_type == "send" and send_index is None:
|
|
230
|
+
raise ValueError("send_index required for parameter_type='send'")
|
|
231
|
+
|
|
193
232
|
# Generate the curve
|
|
194
233
|
points = generate_curve(
|
|
195
234
|
curve_type=curve_type,
|
|
@@ -272,6 +311,19 @@ def apply_automation_recipe(
|
|
|
272
311
|
- vinyl_crackle: slow bit reduction movement
|
|
273
312
|
- stereo_narrow: collapse to mono before drop
|
|
274
313
|
"""
|
|
314
|
+
# Validate indices and parameter_type (same rules as set_clip_automation)
|
|
315
|
+
if track_index < 0:
|
|
316
|
+
raise ValueError("track_index must be >= 0")
|
|
317
|
+
if clip_index < 0:
|
|
318
|
+
raise ValueError("clip_index must be >= 0")
|
|
319
|
+
if parameter_type not in ("device", "volume", "panning", "send"):
|
|
320
|
+
raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
|
|
321
|
+
if parameter_type == "device":
|
|
322
|
+
if device_index is None or parameter_index is None:
|
|
323
|
+
raise ValueError("device_index and parameter_index required for parameter_type='device'")
|
|
324
|
+
if parameter_type == "send" and send_index is None:
|
|
325
|
+
raise ValueError("send_index required for parameter_type='send'")
|
|
326
|
+
|
|
275
327
|
points = generate_from_recipe(recipe, duration=duration, density=density)
|
|
276
328
|
|
|
277
329
|
if time_offset > 0:
|
|
@@ -180,12 +180,11 @@ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
|
|
|
180
180
|
|
|
181
181
|
def _validate_track_index(track_index: int):
|
|
182
182
|
if track_index < 0 and track_index != MASTER_TRACK_INDEX:
|
|
183
|
-
if track_index
|
|
183
|
+
if not (-99 <= track_index <= -1):
|
|
184
184
|
raise ValueError(
|
|
185
185
|
"track_index must be >= 0 for regular tracks, "
|
|
186
|
-
"
|
|
186
|
+
"-1..-99 for return tracks (-1=A, -2=B), or -1000 for master"
|
|
187
187
|
)
|
|
188
|
-
# Negative values -1..-99 are valid return track indices
|
|
189
188
|
|
|
190
189
|
|
|
191
190
|
def _validate_device_index(device_index: int):
|
|
@@ -94,6 +94,10 @@ def layer_euclidean_rhythms(
|
|
|
94
94
|
for layer in layers:
|
|
95
95
|
p = int(layer["pulses"])
|
|
96
96
|
s = int(layer["steps"])
|
|
97
|
+
if s < 1 or s > 64:
|
|
98
|
+
raise ValueError(f"steps must be between 1 and 64, got {s}")
|
|
99
|
+
if p < 0 or p > s:
|
|
100
|
+
raise ValueError(f"pulses must be between 0 and steps ({s}), got {p}")
|
|
97
101
|
rot = int(layer.get("rotation", 0))
|
|
98
102
|
pitch = int(layer["pitch"])
|
|
99
103
|
vel = int(layer.get("velocity", 100))
|
|
@@ -51,6 +51,22 @@ def _output_dir() -> Path:
|
|
|
51
51
|
return d
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _safe_output_path(directory: Path, filename: str) -> Path:
|
|
55
|
+
"""Join *filename* to *directory* with path-traversal containment.
|
|
56
|
+
|
|
57
|
+
Strips directory components (``../../evil.mid`` → ``evil.mid``),
|
|
58
|
+
resolves the result, and verifies it is still inside *directory*.
|
|
59
|
+
Raises ``ValueError`` on any escape attempt.
|
|
60
|
+
"""
|
|
61
|
+
safe_name = Path(filename).name # strip directory components
|
|
62
|
+
if not safe_name:
|
|
63
|
+
raise ValueError(f"Invalid filename: {filename!r}")
|
|
64
|
+
out = (directory / safe_name).resolve()
|
|
65
|
+
if not str(out).startswith(str(directory.resolve())):
|
|
66
|
+
raise ValueError(f"Filename escapes output directory: {filename!r}")
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
54
70
|
def _validate_midi_path(file_path: str) -> Path:
|
|
55
71
|
p = Path(file_path)
|
|
56
72
|
if not p.exists():
|
|
@@ -112,7 +128,7 @@ def export_clip_midi(
|
|
|
112
128
|
if not filename.endswith((".mid", ".midi")):
|
|
113
129
|
filename += ".mid"
|
|
114
130
|
|
|
115
|
-
out_path = _output_dir()
|
|
131
|
+
out_path = _safe_output_path(_output_dir(), filename)
|
|
116
132
|
|
|
117
133
|
midi = MIDIFile(1)
|
|
118
134
|
midi.addTempo(0, 0, tempo)
|
|
@@ -185,9 +201,11 @@ def import_midi_to_clip(
|
|
|
185
201
|
"clip_index": clip_index,
|
|
186
202
|
})
|
|
187
203
|
slot_has_clip = True
|
|
188
|
-
except AbletonConnectionError:
|
|
189
|
-
|
|
190
|
-
|
|
204
|
+
except AbletonConnectionError as exc:
|
|
205
|
+
msg = str(exc)
|
|
206
|
+
if "NOT_FOUND" not in msg and "STATE_ERROR" not in msg:
|
|
207
|
+
raise # propagate INDEX_ERROR, TIMEOUT, connection failures
|
|
208
|
+
# Slot is empty (NOT_FOUND) or no clip (STATE_ERROR)
|
|
191
209
|
|
|
192
210
|
if slot_has_clip:
|
|
193
211
|
# Clip exists — clear its notes before importing
|
|
@@ -669,7 +669,14 @@ def transpose_smart(
|
|
|
669
669
|
|
|
670
670
|
source_tonic = source_key["tonic"]
|
|
671
671
|
target_tonic = target["tonic"]
|
|
672
|
-
|
|
672
|
+
# Compute nearest-path semitone shift (never more than ±6)
|
|
673
|
+
raw_shift = target_tonic - source_tonic
|
|
674
|
+
if raw_shift > 6:
|
|
675
|
+
semitone_shift = raw_shift - 12
|
|
676
|
+
elif raw_shift < -6:
|
|
677
|
+
semitone_shift = raw_shift + 12
|
|
678
|
+
else:
|
|
679
|
+
semitone_shift = raw_shift
|
|
673
680
|
|
|
674
681
|
if mode == "chromatic":
|
|
675
682
|
transposed = []
|
|
@@ -23,21 +23,31 @@ def get_session_info(ctx: Context) -> dict:
|
|
|
23
23
|
return _get_ableton(ctx).send_command("get_session_info")
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _validate_tempo(tempo: float) -> None:
|
|
27
|
+
"""Validate tempo is within Ableton's accepted range."""
|
|
28
|
+
if not 20 <= tempo <= 999:
|
|
29
|
+
raise ValueError("Tempo must be between 20 and 999 BPM")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_time_signature(numerator: int, denominator: int) -> None:
|
|
33
|
+
"""Validate time signature components."""
|
|
34
|
+
if numerator < 1 or numerator > 99:
|
|
35
|
+
raise ValueError("Numerator must be between 1 and 99")
|
|
36
|
+
if denominator not in (1, 2, 4, 8, 16):
|
|
37
|
+
raise ValueError("Denominator must be 1, 2, 4, 8, or 16")
|
|
38
|
+
|
|
39
|
+
|
|
26
40
|
@mcp.tool()
|
|
27
41
|
def set_tempo(ctx: Context, tempo: float) -> dict:
|
|
28
42
|
"""Set the song tempo in BPM (20-999)."""
|
|
29
|
-
|
|
30
|
-
raise ValueError("Tempo must be between 20 and 999 BPM")
|
|
43
|
+
_validate_tempo(tempo)
|
|
31
44
|
return _get_ableton(ctx).send_command("set_tempo", {"tempo": tempo})
|
|
32
45
|
|
|
33
46
|
|
|
34
47
|
@mcp.tool()
|
|
35
48
|
def set_time_signature(ctx: Context, numerator: int, denominator: int) -> dict:
|
|
36
49
|
"""Set the time signature (e.g., 4/4, 3/4, 6/8)."""
|
|
37
|
-
|
|
38
|
-
raise ValueError("Numerator must be between 1 and 99")
|
|
39
|
-
if denominator not in (1, 2, 4, 8, 16):
|
|
40
|
-
raise ValueError("Denominator must be 1, 2, 4, 8, or 16")
|
|
50
|
+
_validate_time_signature(numerator, denominator)
|
|
41
51
|
return _get_ableton(ctx).send_command("set_time_signature", {
|
|
42
52
|
"numerator": numerator,
|
|
43
53
|
"denominator": denominator,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.12",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
5
|
"description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
6
6
|
"author": "Pilot Studio",
|
|
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
|
|
|
5
5
|
when this script is selected in Preferences > Link, Tempo & MIDI.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "1.9.
|
|
8
|
+
__version__ = "1.9.12"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from .server import LivePilotServer
|
|
@@ -85,29 +85,19 @@ def create_arrangement_clip(song, params):
|
|
|
85
85
|
first_clip_index = None
|
|
86
86
|
|
|
87
87
|
while pos < end_pos:
|
|
88
|
-
# Snapshot clip IDs before duplication to identify the new one
|
|
89
|
-
old_ids = set(id(c) for c in track.arrangement_clips)
|
|
90
|
-
|
|
91
88
|
track.duplicate_clip_to_arrangement(source_clip, pos)
|
|
92
89
|
|
|
93
|
-
# Find the
|
|
90
|
+
# Find the new clip by position (id()-based detection is unreliable
|
|
91
|
+
# because CPython can reuse addresses of GC'd LOM wrappers)
|
|
94
92
|
arr_clips = list(track.arrangement_clips)
|
|
95
93
|
new_clip = None
|
|
96
94
|
new_clip_idx = None
|
|
97
95
|
for i, c in enumerate(arr_clips):
|
|
98
|
-
if
|
|
96
|
+
if abs(c.start_time - pos) < 0.01:
|
|
99
97
|
new_clip = c
|
|
100
98
|
new_clip_idx = i
|
|
101
99
|
break
|
|
102
100
|
|
|
103
|
-
# Fallback: if id-based detection fails, match by position
|
|
104
|
-
if new_clip is None:
|
|
105
|
-
for i, c in enumerate(arr_clips):
|
|
106
|
-
if abs(c.start_time - pos) < 0.01:
|
|
107
|
-
new_clip = c
|
|
108
|
-
new_clip_idx = i
|
|
109
|
-
break
|
|
110
|
-
|
|
111
101
|
if new_clip is not None:
|
|
112
102
|
if first_clip_index is None:
|
|
113
103
|
first_clip_index = new_clip_idx
|
|
@@ -158,20 +148,24 @@ def create_arrangement_clip(song, params):
|
|
|
158
148
|
finally:
|
|
159
149
|
song.end_undo_step()
|
|
160
150
|
|
|
161
|
-
# Re-read to get accurate final state
|
|
151
|
+
# Re-read to get accurate final state — locate by start_time, not stored
|
|
152
|
+
# index, because the trim pass (remove_notes_extended) can shift indices.
|
|
162
153
|
arr_clips = list(track.arrangement_clips)
|
|
163
|
-
|
|
154
|
+
first_clip = None
|
|
155
|
+
for c in arr_clips:
|
|
156
|
+
if abs(c.start_time - start_time) < 0.01:
|
|
157
|
+
first_clip = c
|
|
158
|
+
break
|
|
159
|
+
if first_clip is None:
|
|
164
160
|
raise ValueError("Failed to place any clips in arrangement")
|
|
165
|
-
first_clip = arr_clips[first_clip_index]
|
|
166
161
|
|
|
167
162
|
return {
|
|
168
163
|
"track_index": track_index,
|
|
169
|
-
"clip_index": first_clip_index,
|
|
170
164
|
"start_time": start_time,
|
|
171
165
|
"length": length,
|
|
172
166
|
"clip_count": clip_count,
|
|
173
167
|
"source_length": source_length,
|
|
174
|
-
"name": first_clip.name
|
|
168
|
+
"name": first_clip.name,
|
|
175
169
|
}
|
|
176
170
|
|
|
177
171
|
|
|
@@ -76,13 +76,19 @@ def _navigate_path(browser, path):
|
|
|
76
76
|
return current
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
_MAX_SEARCH_ITERATIONS = 100000
|
|
80
|
+
|
|
81
|
+
|
|
79
82
|
def _search_recursive(item, name_filter, loadable_only, results, depth, max_depth,
|
|
80
|
-
max_results=100):
|
|
81
|
-
"""Recursively search browser children."""
|
|
83
|
+
max_results=100, _counter=None):
|
|
84
|
+
"""Recursively search browser children with iteration cap."""
|
|
85
|
+
if _counter is None:
|
|
86
|
+
_counter = [0] # mutable counter shared across recursion
|
|
82
87
|
if depth > max_depth or len(results) >= max_results:
|
|
83
88
|
return
|
|
84
89
|
for child in item.children:
|
|
85
|
-
|
|
90
|
+
_counter[0] += 1
|
|
91
|
+
if _counter[0] > _MAX_SEARCH_ITERATIONS or len(results) >= max_results:
|
|
86
92
|
return
|
|
87
93
|
match = True
|
|
88
94
|
if name_filter and name_filter.lower() not in child.name.lower():
|
|
@@ -102,7 +108,7 @@ def _search_recursive(item, name_filter, loadable_only, results, depth, max_dept
|
|
|
102
108
|
if child.is_folder:
|
|
103
109
|
_search_recursive(
|
|
104
110
|
child, name_filter, loadable_only, results, depth + 1, max_depth,
|
|
105
|
-
max_results
|
|
111
|
+
max_results, _counter
|
|
106
112
|
)
|
|
107
113
|
if len(results) >= max_results:
|
|
108
114
|
return
|
|
@@ -125,12 +131,17 @@ def get_browser_tree(song, params):
|
|
|
125
131
|
|
|
126
132
|
result = []
|
|
127
133
|
for name, item in categories.items():
|
|
128
|
-
children
|
|
129
|
-
|
|
134
|
+
# Count lazily without materializing the full children list
|
|
135
|
+
children_preview = []
|
|
136
|
+
count = 0
|
|
137
|
+
for c in item.children:
|
|
138
|
+
count += 1
|
|
139
|
+
if len(children_preview) < 20:
|
|
140
|
+
children_preview.append(c.name)
|
|
130
141
|
result.append({
|
|
131
142
|
"name": name,
|
|
132
|
-
"children_count":
|
|
133
|
-
"children_preview":
|
|
143
|
+
"children_count": count,
|
|
144
|
+
"children_preview": children_preview,
|
|
134
145
|
})
|
|
135
146
|
return {"categories": result}
|
|
136
147
|
|
|
@@ -20,16 +20,17 @@ def get_clip_automation(song, params):
|
|
|
20
20
|
envelopes = []
|
|
21
21
|
|
|
22
22
|
# Check mixer parameters: volume, panning, sends
|
|
23
|
+
# Use the specific parameter_type that set/clear accept (not generic "mixer")
|
|
23
24
|
mixer = track.mixer_device
|
|
24
|
-
for param_name, param in [
|
|
25
|
-
("Volume", mixer.volume),
|
|
26
|
-
("Pan", mixer.panning),
|
|
25
|
+
for param_name, param_type, param in [
|
|
26
|
+
("Volume", "volume", mixer.volume),
|
|
27
|
+
("Pan", "panning", mixer.panning),
|
|
27
28
|
]:
|
|
28
29
|
env = clip.automation_envelope(param)
|
|
29
30
|
if env is not None:
|
|
30
31
|
envelopes.append({
|
|
31
32
|
"parameter_name": param_name,
|
|
32
|
-
"parameter_type":
|
|
33
|
+
"parameter_type": param_type,
|
|
33
34
|
"has_envelope": True,
|
|
34
35
|
})
|
|
35
36
|
|
|
@@ -147,12 +147,14 @@ def set_clip_loop(song, params):
|
|
|
147
147
|
clip_index = int(params["clip_index"])
|
|
148
148
|
clip = get_clip(song, track_index, clip_index)
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if "start" in params:
|
|
153
|
-
clip.loop_start = float(params["start"])
|
|
150
|
+
# Set end before start to avoid Live's loop_start < loop_end clamping.
|
|
151
|
+
# Expanding the window first ensures the left edge can move freely.
|
|
154
152
|
if "end" in params:
|
|
155
153
|
clip.loop_end = float(params["end"])
|
|
154
|
+
if "start" in params:
|
|
155
|
+
clip.loop_start = float(params["start"])
|
|
156
|
+
if "enabled" in params:
|
|
157
|
+
clip.looping = bool(params["enabled"])
|
|
156
158
|
|
|
157
159
|
return {
|
|
158
160
|
"track_index": track_index,
|
|
@@ -199,11 +201,17 @@ def set_clip_warp_mode(song, params):
|
|
|
199
201
|
"3=Re-Pitch, 4=Complex, 6=Complex Pro" % mode
|
|
200
202
|
)
|
|
201
203
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
# Enable warping first so warp_mode assignment is accepted by Live,
|
|
205
|
+
# then disable afterwards if requested.
|
|
206
|
+
enable_warping = params.get("warping")
|
|
207
|
+
if enable_warping is not None and bool(enable_warping):
|
|
208
|
+
clip.warping = True
|
|
204
209
|
|
|
205
210
|
clip.warp_mode = mode
|
|
206
211
|
|
|
212
|
+
if enable_warping is not None and not bool(enable_warping):
|
|
213
|
+
clip.warping = False
|
|
214
|
+
|
|
207
215
|
return {
|
|
208
216
|
"track_index": track_index,
|
|
209
217
|
"clip_index": clip_index,
|
|
@@ -3,6 +3,7 @@ LivePilot - Device domain handlers (11 commands).
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import Live
|
|
6
|
+
from collections import deque
|
|
6
7
|
|
|
7
8
|
from .router import register
|
|
8
9
|
from .utils import get_track, get_device
|
|
@@ -178,6 +179,8 @@ def toggle_device(song, params):
|
|
|
178
179
|
break
|
|
179
180
|
if on_param is None:
|
|
180
181
|
# Fallback to parameter 0 for devices that don't use "Device On"
|
|
182
|
+
if not list(device.parameters):
|
|
183
|
+
raise ValueError("Device '%s' has no parameters to toggle" % device.name)
|
|
181
184
|
on_param = device.parameters[0]
|
|
182
185
|
|
|
183
186
|
on_param.value = 1.0 if active else 0.0
|
|
@@ -399,10 +402,10 @@ def find_and_load_device(song, params):
|
|
|
399
402
|
This ensures raw 'Operator' is found before 'Hello Operator.adg' buried
|
|
400
403
|
in a user_library subfolder."""
|
|
401
404
|
nonlocal iterations
|
|
402
|
-
# Queue of (item, depth) tuples
|
|
403
|
-
queue = [(category, 0)]
|
|
405
|
+
# Queue of (item, depth) tuples — deque for O(1) popleft
|
|
406
|
+
queue = deque([(category, 0)])
|
|
404
407
|
while queue:
|
|
405
|
-
item, depth = queue.
|
|
408
|
+
item, depth = queue.popleft()
|
|
406
409
|
if depth > 8:
|
|
407
410
|
continue
|
|
408
411
|
try:
|
|
@@ -187,12 +187,12 @@ class LivePilotServer(object):
|
|
|
187
187
|
except OSError as exc:
|
|
188
188
|
self._log("Client error: %s" % exc)
|
|
189
189
|
finally:
|
|
190
|
-
with self._client_lock:
|
|
191
|
-
self._client_connected = False
|
|
192
190
|
try:
|
|
193
191
|
client.close()
|
|
194
192
|
except OSError:
|
|
195
193
|
pass
|
|
194
|
+
with self._client_lock:
|
|
195
|
+
self._client_connected = False
|
|
196
196
|
self._log("Client disconnected")
|
|
197
197
|
|
|
198
198
|
def _handle_client(self, client):
|
|
@@ -205,6 +205,9 @@ class LivePilotServer(object):
|
|
|
205
205
|
if not data:
|
|
206
206
|
break
|
|
207
207
|
buf += data.decode("utf-8", errors="replace")
|
|
208
|
+
if len(buf) > 4 * 1024 * 1024: # 4 MB
|
|
209
|
+
self._log("Client buffer overflow — disconnecting")
|
|
210
|
+
break
|
|
208
211
|
while "\n" in buf:
|
|
209
212
|
line, buf = buf.split("\n", 1)
|
|
210
213
|
line = line.strip()
|
|
@@ -249,8 +252,14 @@ class LivePilotServer(object):
|
|
|
249
252
|
try:
|
|
250
253
|
self._cs.schedule_message(0, self._process_next_command)
|
|
251
254
|
except AssertionError:
|
|
252
|
-
#
|
|
253
|
-
|
|
255
|
+
# ControlSurface is disconnecting — return error instead of
|
|
256
|
+
# running LOM calls on the TCP thread (which would be unsafe)
|
|
257
|
+
response_queue.put({
|
|
258
|
+
"id": request_id,
|
|
259
|
+
"ok": False,
|
|
260
|
+
"error": {"code": "STATE_ERROR", "message": "Script is disconnecting"},
|
|
261
|
+
})
|
|
262
|
+
return
|
|
254
263
|
|
|
255
264
|
# Wait for response from main thread
|
|
256
265
|
try:
|
|
@@ -296,7 +305,8 @@ class LivePilotServer(object):
|
|
|
296
305
|
try:
|
|
297
306
|
self._cs.schedule_message(1, send_response) # ~100ms
|
|
298
307
|
except AssertionError:
|
|
299
|
-
|
|
308
|
+
# ControlSurface disconnecting — send result immediately
|
|
309
|
+
response_queue.put(result)
|
|
300
310
|
else:
|
|
301
311
|
response_queue.put(result)
|
|
302
312
|
# Drain any remaining queued commands
|
|
@@ -308,7 +318,9 @@ class LivePilotServer(object):
|
|
|
308
318
|
try:
|
|
309
319
|
self._cs.schedule_message(0, self._process_next_command)
|
|
310
320
|
except AssertionError:
|
|
311
|
-
|
|
321
|
+
# ControlSurface disconnecting — drop remaining commands
|
|
322
|
+
# rather than running LOM calls on the wrong thread
|
|
323
|
+
pass
|
|
312
324
|
|
|
313
325
|
# ── Socket I/O ───────────────────────────────────────────────────────
|
|
314
326
|
|
|
@@ -175,9 +175,14 @@ def duplicate_track(song, params):
|
|
|
175
175
|
"Track index %d out of range (0..%d)"
|
|
176
176
|
% (track_index, len(tracks) - 1)
|
|
177
177
|
)
|
|
178
|
+
count_before = len(tracks)
|
|
178
179
|
song.duplicate_track(track_index)
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
all_tracks = list(song.tracks)
|
|
181
|
+
# For group tracks, Ableton duplicates the group + all children.
|
|
182
|
+
# The duplicate block starts right after the original group's last child.
|
|
183
|
+
added = len(all_tracks) - count_before
|
|
184
|
+
new_index = track_index + added
|
|
185
|
+
return {"index": new_index, "name": all_tracks[new_index].name}
|
|
181
186
|
|
|
182
187
|
|
|
183
188
|
@register("set_track_name")
|
|
@@ -53,9 +53,9 @@ def get_session_info(song, params):
|
|
|
53
53
|
"metronome": song.metronome,
|
|
54
54
|
"record_mode": song.record_mode,
|
|
55
55
|
"session_record": song.session_record,
|
|
56
|
-
"track_count": len(
|
|
57
|
-
"return_track_count": len(
|
|
58
|
-
"scene_count": len(
|
|
56
|
+
"track_count": len(tracks_info),
|
|
57
|
+
"return_track_count": len(return_tracks_info),
|
|
58
|
+
"scene_count": len(scenes_info),
|
|
59
59
|
"tracks": tracks_info,
|
|
60
60
|
"return_tracks": return_tracks_info,
|
|
61
61
|
"scenes": scenes_info,
|
package/requirements.txt
CHANGED
|
@@ -3,9 +3,11 @@ numpy>=1.24.0
|
|
|
3
3
|
fastmcp>=3.0.0,<4.0.0
|
|
4
4
|
midiutil>=1.2.1
|
|
5
5
|
pretty_midi>=0.2.10
|
|
6
|
-
opycleid>=0.5.1
|
|
7
6
|
# v1.8 Perception Layer (offline analysis)
|
|
8
7
|
pyloudnorm>=0.1.0
|
|
9
8
|
soundfile>=0.12.0
|
|
10
9
|
scipy>=1.11.0
|
|
11
10
|
mutagen>=1.47.0
|
|
11
|
+
|
|
12
|
+
# Optional: neo-Riemannian group theory (not required — harmony engine is pure Python)
|
|
13
|
+
# pip install opycleid>=0.5.1
|