livepilot 1.23.4 → 1.23.6
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 +41 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/demo_story.py +9 -3
- package/mcp_server/atlas/device_atlas.json +1 -1
- package/mcp_server/atlas/extract_chain.py +17 -2
- package/mcp_server/tools/analyzer.py +47 -2
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/mixing.py +29 -3
- package/remote_script/LivePilot/server.py +38 -22
- package/remote_script/LivePilot/transport.py +15 -5
- package/server.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.23.6 — 2026-04-30
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`ensure_analyzer_on_master` cold-start disambiguation.** On a fresh Live boot the User Library browser cache is uncached, and `find_and_load_device("LivePilot_Analyzer")` can exceed the 20s recv timeout while BFS-ing the user library tree. The previous catch-block surfaced `status="install_required"` even though the .amxd is sitting at the canonical install path — sending the agent into a reinstall loop. New `_analyzer_amxd_installed_at_user_library()` filesystem check disambiguates: when the .amxd is present at `~/Music/Ableton/User Library/Presets/Audio Effects/Max Audio Effect/`, the tool now returns `status="cache_cold"` with a retry hint instead. Genuine missing-.amxd path still returns `install_required` correctly. (`mcp_server/tools/analyzer.py`)
|
|
8
|
+
- **`set_compressor_sidechain` diagnostic depth.** When `_find_sidechain_surface` returns None on Compressor2 (Live 12.3.6+), the raised error now includes `class=...` + `canonical_parent.class=...` + a widened child-attribute walk (`input_routings`, `routing_inputs` added to the probe). Lets the next live failure leave a tighter trail so the actual Compressor2 LOM shape can be confirmed in one round trip rather than a separate probe session. No behavior change for legacy Compressor (I) or the two known Compressor2 hypotheses. (`remote_script/LivePilot/mixing.py`)
|
|
9
|
+
- **`atlas_demo_story` production_decision class-name leak.** `harmonic-foundation` and `rhythmic-driver` role branches fell back to `primary_cls` when `user_name` was empty — producing useless prose like "InstrumentGroupDevice chosen as harmonic spine." Both branches now use `primary_uname or t_name or primary_cls`, matching the texture-role fallback that was already correct. (`mcp_server/atlas/demo_story.py`)
|
|
10
|
+
- **`atlas_extract_chain` Macro N labeling on M4L devices.** The rack-device path already resolved producer-named macros via `resolve_preset_for_device`, but the M4L (.amxd) device path emitted raw `Macro N`. Now both paths cross-reference the preset sidecar — PitchLoop89's "Spectral Stretch" gets the proper name instead of "Macro 2" so `set_device_parameter` resolves at execution time. (`mcp_server/atlas/extract_chain.py`)
|
|
11
|
+
- **Bundled atlas stats refresh.** `stats.enriched_devices` was stale at `87` (from a v1.21.x scan); the actual `enriched=True` flag count had grown to `135`. Recounted in place. No tool consumes the field with semantically-different behavior, but the stale stat had drifted past the soft-warn threshold's intent. (`mcp_server/atlas/device_atlas.json`)
|
|
12
|
+
|
|
13
|
+
### Docs / drift
|
|
14
|
+
|
|
15
|
+
- **`docs/M4L_BRIDGE.md`**: amxd ping reference and bridge-cmd count corrected — was `version: "1.23.1"` and "30 commands" from prior releases; now `1.23.6` and `32`.
|
|
16
|
+
- **`livepilot/skills/livepilot-core/references/genre-vocabularies.md`**: added Synthwave / Retrowave / Outrun entry to match the 15-genre claim in CLAUDE.md/AGENTS.md (file had 14; the YAML packet at `concepts/genres/synthwave.yaml` had been the source-of-truth for v1.18+).
|
|
17
|
+
|
|
18
|
+
### Tests
|
|
19
|
+
|
|
20
|
+
- `tests/test_ensure_analyzer_on_master.py` — 2 new tests under `TestColdBrowserCacheDisambiguation` covering the cache-cold branch (`.amxd` present → cache_cold) and the install-required branch (`.amxd` absent → install_required). Existing `test_returns_install_required_when_device_not_in_browser` updated to monkeypatch the new path-check helper so it exercises the genuinely-not-installed path regardless of dev-machine state.
|
|
21
|
+
- Total: **3409 passing**, 1 skipped, 0 failed (up from 3407 in v1.23.5).
|
|
22
|
+
|
|
23
|
+
### Audit notes
|
|
24
|
+
|
|
25
|
+
A full read-only audit pass against `BUGS.md` + `BUGS_TESTING_2026-04-30.md` confirmed that 14 of the bugs documented in those files were already fixed in v1.23.4's `bugfixes-2026-04-26` commit batch but never marked closed in the testing doc. No-ops here, but worth noting that the 38-bug list in the testing file is now ~5 genuinely-open items.
|
|
26
|
+
|
|
27
|
+
## v1.23.5 — 2026-04-30
|
|
28
|
+
|
|
29
|
+
### Fixed (Remote Script reliability — credit: PR #35 reporter)
|
|
30
|
+
|
|
31
|
+
Two production-blocking bugs in the Remote Script that ship into the Ableton process. Reported by @juancarlosaxtro-hash in [PR #35](https://github.com/dreamrec/LivePilot/pull/35); reimplemented cleanly here with regression tests because the originally-submitted patch contained Python indentation errors that broke `transport.py` parse.
|
|
32
|
+
|
|
33
|
+
- **TCP server now replaces stale clients instead of rejecting new connections.** Previously, when the MCP server restarted uncleanly (e.g. user relaunched Claude Desktop), the Remote Script's `recv()` loop didn't notice the disconnect for up to a second. During that window, the legitimate reconnect attempt was rejected with `STATE_ERROR("Another client is already connected")` — often requiring a full Ableton restart to recover. New behavior: when the accept loop sees a new connection while one is "active", it closes the stale socket from the accept loop, joins the old client thread (with 2 s timeout, OUTSIDE the lock so the thread's `finally` block can acquire it), then accepts the new connection. Single-client architecture means a new connection is proof the old one is dead. New `_current_client` field tracks the active socket so the accept loop can kick it; the `_run_client_session` finally only nulls `_current_client` if it still points at the closing client (the accept loop may have already replaced it). (`remote_script/LivePilot/server.py`)
|
|
34
|
+
- **`get_session_info` no longer crashes on Group tracks.** `song.tracks` includes Group tracks, which raise a `RuntimeError` on `arm` / `has_midi_input` / `has_audio_input` access. `hasattr()` returns `True` regardless because Live's LOM doesn't use `AttributeError` — only try/except on the actual access works. Without the guard, any session with a Group track failed the entire session-info call with *"Main and Return Tracks have no 'Arm' state!"* Surgical try/except around the three LOM-fragile properties only; other fields (`name`, `color_index`, `mute`, `solo`) populate normally; the fragile ones return `None` on Group tracks. (`remote_script/LivePilot/transport.py`)
|
|
35
|
+
|
|
36
|
+
### Tests
|
|
37
|
+
- `tests/test_remote_server_single_client.py` — rewrote `test_second_client_gets_explicit_state_error` → `test_second_client_replaces_stale_connection` for the new kick-stale semantics.
|
|
38
|
+
- `tests/test_remote_transport_group_tracks.py` — new file, 4 tests covering normal-track baseline, mixed Group+normal sessions, all-Group edge case, and non-fragile-field preservation. Hermetic loader stubs the Ableton-only `Live` module + `version_detect` helpers; autouse fixture restores `sys.modules` to prevent test pollution.
|
|
39
|
+
- Total: **3407 passing**, 1 skipped, 0 failed (up from 3403 in v1.23.4).
|
|
40
|
+
|
|
41
|
+
### CI green check
|
|
42
|
+
- All 9 jobs green on commit `4aec8e6` (run `25174868624`): metadata-drift, js-entrypoint, amxd-freeze-drift, python-tests × {macos, ubuntu, windows} × {3.11, 3.12}.
|
|
43
|
+
|
|
3
44
|
## v1.23.4 — 2026-04-30
|
|
4
45
|
|
|
5
46
|
### Fixed (2026-04-30 live-test wave — 38 bugs surfaced, 36 fixed, 2 deferred to v1.23.5)
|
|
Binary file
|
|
@@ -34,7 +34,7 @@ outlets = 2; // 0: to udpsend (responses), 1: to buffer~/status
|
|
|
34
34
|
// Single source of truth for the bridge version — bumped alongside the
|
|
35
35
|
// rest of the release manifest. Surfaced in the UI via messnamed("livepilot_version", ...)
|
|
36
36
|
// so the frozen .amxd visibly reports which build it was last exported from.
|
|
37
|
-
var VERSION = "1.23.
|
|
37
|
+
var VERSION = "1.23.6";
|
|
38
38
|
|
|
39
39
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
40
40
|
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.23.
|
|
2
|
+
__version__ = "1.23.6"
|
|
@@ -608,19 +608,25 @@ def demo_story(
|
|
|
608
608
|
primary_uname = primary_device.get("user_name") or ""
|
|
609
609
|
nonzero = _count_nonzero_macros(devices)
|
|
610
610
|
|
|
611
|
+
# BUG-E5: when user_name is empty AND primary device is a Group/Rack
|
|
612
|
+
# device, primary_cls reads as "InstrumentGroupDevice" /
|
|
613
|
+
# "AudioEffectGroupDevice" — useless prose like "InstrumentGroupDevice
|
|
614
|
+
# chosen as harmonic spine". Fall back to track name first, then class.
|
|
615
|
+
primary_label = primary_uname or t_name or primary_cls or "<unknown>"
|
|
616
|
+
|
|
611
617
|
if t_type == "ReturnTrack":
|
|
612
618
|
prod_decision = (
|
|
613
|
-
f"{
|
|
619
|
+
f"{primary_label} shared return — applies uniformly across all sends."
|
|
614
620
|
)
|
|
615
621
|
elif role == "harmonic-foundation":
|
|
616
622
|
prod_decision = (
|
|
617
|
-
f"{
|
|
623
|
+
f"{primary_label} chosen as harmonic spine; "
|
|
618
624
|
f"{nonzero} macro(s) committed to specific values, "
|
|
619
625
|
"suggesting deliberate timbral targeting."
|
|
620
626
|
)
|
|
621
627
|
elif role == "rhythmic-driver":
|
|
622
628
|
prod_decision = (
|
|
623
|
-
f"Drum rack '{
|
|
629
|
+
f"Drum rack '{primary_label}' provides rhythmic drive. "
|
|
624
630
|
f"{nonzero} non-default macro values indicate custom tuning."
|
|
625
631
|
)
|
|
626
632
|
elif role == "texture":
|
|
@@ -492,18 +492,33 @@ def _emit_execution_steps(
|
|
|
492
492
|
"device_index": device_index,
|
|
493
493
|
})
|
|
494
494
|
if fidelity != "structure-only" and macros:
|
|
495
|
+
# BUG-E2#1 (M4L path): producer-named macros on devices like
|
|
496
|
+
# PitchLoop89 ("Spectral Stretch") get raw "Macro N" labels here
|
|
497
|
+
# unless we cross-reference the preset sidecar — same as the rack
|
|
498
|
+
# path at line ~395. Without name resolution, set_device_parameter
|
|
499
|
+
# by name silently NOT-FOUNDs at execution time.
|
|
500
|
+
m4l_macro_names: dict[int, str] = {}
|
|
501
|
+
if uname and pack_name:
|
|
502
|
+
m4l_match = resolve_preset_for_device(pack_name, cls, uname)
|
|
503
|
+
m4l_macro_names = m4l_match.get("macro_names") or {}
|
|
495
504
|
macro_subset = (
|
|
496
505
|
_get_nonzero_macros(macros)
|
|
497
506
|
if fidelity == "exact"
|
|
498
507
|
else _top_k_macros_by_deviation(macros, k=5)
|
|
499
508
|
)
|
|
500
509
|
for m in macro_subset:
|
|
510
|
+
resolved_name = m4l_macro_names.get(m["index"]) or f"Macro {m['index'] + 1}"
|
|
511
|
+
source_tag = (
|
|
512
|
+
"als-parse+adg-parse"
|
|
513
|
+
if resolved_name != f"Macro {m['index'] + 1}"
|
|
514
|
+
else "als-parse"
|
|
515
|
+
)
|
|
501
516
|
steps.append({
|
|
502
517
|
"action": "set_device_parameter",
|
|
503
518
|
"device_index": device_index,
|
|
504
|
-
"parameter_name":
|
|
519
|
+
"parameter_name": resolved_name,
|
|
505
520
|
"value": round(_safe_float(m.get("value", "0")), 2),
|
|
506
|
-
"comment": f"M4L macro {m['index'] + 1} [SOURCE:
|
|
521
|
+
"comment": f"M4L macro {m['index'] + 1} [SOURCE: {source_tag}]",
|
|
507
522
|
})
|
|
508
523
|
return steps, warnings
|
|
509
524
|
|
|
@@ -2003,6 +2003,31 @@ def _load_analyzer_impl(ctx, track_index: int, device_name: str,
|
|
|
2003
2003
|
)
|
|
2004
2004
|
|
|
2005
2005
|
|
|
2006
|
+
def _analyzer_amxd_installed_at_user_library() -> bool:
|
|
2007
|
+
"""BUG-T#1: distinguish cold-browser-cache from genuinely-not-installed.
|
|
2008
|
+
|
|
2009
|
+
On a fresh Live boot the User Library browser tree is uncached, and
|
|
2010
|
+
find_and_load_device can exceed the recv_timeout while doing the deep
|
|
2011
|
+
BFS for ``LivePilot_Analyzer.amxd``. The catch-block then surfaces
|
|
2012
|
+
``install_required`` even though the .amxd is sitting at the canonical
|
|
2013
|
+
install path. This helper does a cheap filesystem check so the caller
|
|
2014
|
+
can return ``cache_cold`` (retry hint) instead of misleading the agent
|
|
2015
|
+
into a reinstall loop.
|
|
2016
|
+
|
|
2017
|
+
Returns True iff a file named ``LivePilot_Analyzer.amxd`` exists under
|
|
2018
|
+
the User Library Max Audio Effect directory.
|
|
2019
|
+
"""
|
|
2020
|
+
from pathlib import Path
|
|
2021
|
+
try:
|
|
2022
|
+
path = (Path.home()
|
|
2023
|
+
/ "Music" / "Ableton" / "User Library"
|
|
2024
|
+
/ "Presets" / "Audio Effects" / "Max Audio Effect"
|
|
2025
|
+
/ f"{_ANALYZER_DEVICE_NAME}.amxd")
|
|
2026
|
+
return path.is_file()
|
|
2027
|
+
except Exception:
|
|
2028
|
+
return False
|
|
2029
|
+
|
|
2030
|
+
|
|
2006
2031
|
@mcp.tool()
|
|
2007
2032
|
def ensure_analyzer_on_master(ctx: Context) -> dict:
|
|
2008
2033
|
"""Idempotent pre-flight: load LivePilot_Analyzer on master if missing.
|
|
@@ -2077,8 +2102,28 @@ def ensure_analyzer_on_master(ctx: Context) -> dict:
|
|
|
2077
2102
|
allow_duplicate=False,
|
|
2078
2103
|
)
|
|
2079
2104
|
except Exception as exc:
|
|
2080
|
-
#
|
|
2081
|
-
# install_m4l_device
|
|
2105
|
+
# BUG-T#1: distinguish two failure modes that look identical here:
|
|
2106
|
+
# (a) genuinely not installed — install_m4l_device path is right
|
|
2107
|
+
# (b) installed but Ableton's browser cache is cold (first call
|
|
2108
|
+
# after Live boot can exceed recv_timeout while BFS-ing the
|
|
2109
|
+
# User Library tree). In (b), pointing the agent at
|
|
2110
|
+
# install_m4l_device is wrong — the file is already there.
|
|
2111
|
+
if _analyzer_amxd_installed_at_user_library():
|
|
2112
|
+
return {
|
|
2113
|
+
"status": "cache_cold",
|
|
2114
|
+
"error": str(exc),
|
|
2115
|
+
"hint": (
|
|
2116
|
+
"LivePilot_Analyzer.amxd is installed at "
|
|
2117
|
+
"~/Music/Ableton/User Library/Presets/Audio Effects/"
|
|
2118
|
+
"Max Audio Effect/, but the browser search timed out — "
|
|
2119
|
+
"Ableton's User Library cache is typically cold on the "
|
|
2120
|
+
"first call after a fresh Live boot. Retry "
|
|
2121
|
+
"ensure_analyzer_on_master once; the second call usually "
|
|
2122
|
+
"completes in <1s. If it keeps timing out, click any "
|
|
2123
|
+
"User Library entry in Ableton's browser to warm the "
|
|
2124
|
+
"cache, then retry."
|
|
2125
|
+
),
|
|
2126
|
+
}
|
|
2082
2127
|
return {
|
|
2083
2128
|
"status": "install_required",
|
|
2084
2129
|
"error": str(exc),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.6",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
5
|
"description": "Agentic production system for Ableton Live 12 \u2014 453 tools, 54 domains, 44 semantic moves. Device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, 12 creative intelligence engines",
|
|
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.23.
|
|
8
|
+
__version__ = "1.23.6"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from . import router
|
|
@@ -371,11 +371,17 @@ def _find_sidechain_surface(device):
|
|
|
371
371
|
|
|
372
372
|
|
|
373
373
|
def _collect_routing_diagnostic(device):
|
|
374
|
-
"""Return a
|
|
374
|
+
"""Return a structured trail of routing/sidechain attrs on device + likely children.
|
|
375
375
|
|
|
376
376
|
Used as a breadcrumb in the error message when _find_sidechain_surface
|
|
377
377
|
returns None, so the first failing call tells us what the current Live
|
|
378
378
|
build actually exposes without a separate probe session.
|
|
379
|
+
|
|
380
|
+
BUG-A3 v2 (2026-04-30): the diagnostic now includes class_name and the
|
|
381
|
+
canonical_parent class so the next live-probe operator can immediately
|
|
382
|
+
tell whether they're looking at a Compressor / Compressor2 / something
|
|
383
|
+
else. Also widens the child-name search to include Live 12.x patterns
|
|
384
|
+
(input_routings, routing_inputs).
|
|
379
385
|
"""
|
|
380
386
|
def _attrs(obj, prefix):
|
|
381
387
|
try:
|
|
@@ -388,8 +394,23 @@ def _collect_routing_diagnostic(device):
|
|
|
388
394
|
return []
|
|
389
395
|
return [prefix + n for n in names]
|
|
390
396
|
|
|
397
|
+
parts = []
|
|
398
|
+
try:
|
|
399
|
+
parts.append("class=%s" % device.class_name)
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
try:
|
|
403
|
+
cp = getattr(device, "canonical_parent", None)
|
|
404
|
+
if cp is not None:
|
|
405
|
+
parts.append("canonical_parent.class=%s" % type(cp).__name__)
|
|
406
|
+
except Exception:
|
|
407
|
+
pass
|
|
408
|
+
|
|
391
409
|
found = list(_attrs(device, "device."))
|
|
392
|
-
|
|
410
|
+
# Probe widened in v2 to include Live 12.x patterns. Order is
|
|
411
|
+
# least-to-most exotic so legacy shapes take precedence.
|
|
412
|
+
for child_name in ("sidechain_input", "input", "sidechain", "routing",
|
|
413
|
+
"input_routings", "routing_inputs"):
|
|
393
414
|
try:
|
|
394
415
|
child = getattr(device, child_name, None)
|
|
395
416
|
except Exception:
|
|
@@ -397,7 +418,12 @@ def _collect_routing_diagnostic(device):
|
|
|
397
418
|
if child is None:
|
|
398
419
|
continue
|
|
399
420
|
found.extend(_attrs(child, "device.%s." % child_name))
|
|
400
|
-
|
|
421
|
+
|
|
422
|
+
if found:
|
|
423
|
+
parts.append("attrs=[%s]" % ", ".join(found))
|
|
424
|
+
else:
|
|
425
|
+
parts.append("attrs=<none>")
|
|
426
|
+
return " | ".join(parts) if parts else "<none>"
|
|
401
427
|
|
|
402
428
|
|
|
403
429
|
@register("set_compressor_sidechain")
|
|
@@ -86,6 +86,12 @@ class LivePilotServer(object):
|
|
|
86
86
|
self._command_queue = queue.Queue()
|
|
87
87
|
self._client_lock = threading.Lock()
|
|
88
88
|
self._client_connected = False
|
|
89
|
+
# Track the active client socket so we can close it from the accept
|
|
90
|
+
# loop when a new connection arrives. See _server_loop's kick-stale
|
|
91
|
+
# flow — without this, an unclean MCP-server restart leaves the
|
|
92
|
+
# Remote Script in a state where new connections get rejected until
|
|
93
|
+
# the old socket times out (often requiring an Ableton restart).
|
|
94
|
+
self._current_client = None
|
|
89
95
|
|
|
90
96
|
# ── Public API ───────────────────────────────────────────────────────
|
|
91
97
|
|
|
@@ -138,30 +144,35 @@ class LivePilotServer(object):
|
|
|
138
144
|
while self._running:
|
|
139
145
|
try:
|
|
140
146
|
client, addr = self._server_socket.accept()
|
|
147
|
+
# Single-client design: a new connection means the previous one
|
|
148
|
+
# is dead. Close the stale socket and join its thread (outside
|
|
149
|
+
# the lock so the thread's finally block can acquire it), then
|
|
150
|
+
# accept the new connection. Without this, the server could
|
|
151
|
+
# reject reconnections for up to 1s after an unclean MCP-server
|
|
152
|
+
# restart — the old recv() loop hadn't yet observed EOF.
|
|
153
|
+
stale_thread = None
|
|
154
|
+
stale_client = None
|
|
155
|
+
with self._client_lock:
|
|
156
|
+
if self._client_connected and self._current_client is not None:
|
|
157
|
+
stale_client = self._current_client
|
|
158
|
+
stale_thread = self._client_thread
|
|
159
|
+
self._log(
|
|
160
|
+
"Replacing stale client with new connection from %s:%d" % addr
|
|
161
|
+
)
|
|
162
|
+
if stale_client is not None:
|
|
163
|
+
try:
|
|
164
|
+
stale_client.close()
|
|
165
|
+
except OSError:
|
|
166
|
+
pass
|
|
167
|
+
if stale_thread is not None and stale_thread.is_alive():
|
|
168
|
+
# 2s is generous — the old recv() unblocks the moment we
|
|
169
|
+
# close the socket above, then the thread's finally block
|
|
170
|
+
# acquires the lock, resets _client_connected, and exits.
|
|
171
|
+
stale_thread.join(timeout=2)
|
|
172
|
+
|
|
141
173
|
with self._client_lock:
|
|
142
|
-
if self._client_connected:
|
|
143
|
-
# Reject concurrent clients with an explicit message
|
|
144
|
-
self._log("Rejected client from %s:%d (another client is connected)" % addr)
|
|
145
|
-
try:
|
|
146
|
-
reject = json.dumps({
|
|
147
|
-
"id": "system",
|
|
148
|
-
"ok": False,
|
|
149
|
-
"error": {
|
|
150
|
-
"code": "STATE_ERROR",
|
|
151
|
-
"message": "Another client is already connected. "
|
|
152
|
-
"LivePilot accepts one client at a time. "
|
|
153
|
-
"Disconnect the current client first."
|
|
154
|
-
}
|
|
155
|
-
}) + "\n"
|
|
156
|
-
client.sendall(reject.encode("utf-8"))
|
|
157
|
-
except OSError:
|
|
158
|
-
pass
|
|
159
|
-
try:
|
|
160
|
-
client.close()
|
|
161
|
-
except OSError:
|
|
162
|
-
pass
|
|
163
|
-
continue
|
|
164
174
|
self._client_connected = True
|
|
175
|
+
self._current_client = client
|
|
165
176
|
self._client_thread = threading.Thread(
|
|
166
177
|
target=self._run_client_session,
|
|
167
178
|
args=(client, addr),
|
|
@@ -194,6 +205,11 @@ class LivePilotServer(object):
|
|
|
194
205
|
pass
|
|
195
206
|
with self._client_lock:
|
|
196
207
|
self._client_connected = False
|
|
208
|
+
# Only clear _current_client if it still points at us — the
|
|
209
|
+
# accept loop may have already replaced us with a new client
|
|
210
|
+
# (in which case _current_client is the new socket).
|
|
211
|
+
if self._current_client is client:
|
|
212
|
+
self._current_client = None
|
|
197
213
|
self._log("Client disconnected")
|
|
198
214
|
|
|
199
215
|
def _handle_client(self, client):
|
|
@@ -11,16 +11,26 @@ def get_session_info(song, params):
|
|
|
11
11
|
"""Return comprehensive session state."""
|
|
12
12
|
tracks_info = []
|
|
13
13
|
for i, track in enumerate(song.tracks):
|
|
14
|
-
|
|
14
|
+
track_data = {
|
|
15
15
|
"index": i,
|
|
16
16
|
"name": track.name,
|
|
17
17
|
"color_index": track.color_index,
|
|
18
|
-
"has_midi_input": track.has_midi_input,
|
|
19
|
-
"has_audio_input": track.has_audio_input,
|
|
20
18
|
"mute": track.mute,
|
|
21
19
|
"solo": track.solo,
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
}
|
|
21
|
+
# Group tracks (and any Return tracks that leak into song.tracks)
|
|
22
|
+
# don't expose `arm` / `has_midi_input` / `has_audio_input`. The
|
|
23
|
+
# Live Object Model raises a RuntimeError on access — and crucially
|
|
24
|
+
# `hasattr()` returns True regardless, so we must use try/except.
|
|
25
|
+
try:
|
|
26
|
+
track_data["arm"] = track.arm
|
|
27
|
+
track_data["has_midi_input"] = track.has_midi_input
|
|
28
|
+
track_data["has_audio_input"] = track.has_audio_input
|
|
29
|
+
except Exception:
|
|
30
|
+
track_data["arm"] = None
|
|
31
|
+
track_data["has_midi_input"] = None
|
|
32
|
+
track_data["has_audio_input"] = None
|
|
33
|
+
tracks_info.append(track_data)
|
|
24
34
|
|
|
25
35
|
return_tracks_info = []
|
|
26
36
|
for i, track in enumerate(song.return_tracks):
|