livepilot 1.27.1 → 1.27.3
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 +86 -0
- package/README.md +10 -7
- package/bin/livepilot.js +156 -37
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +48 -11
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/fast/brief_builder.py +6 -2
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +16 -9
- package/mcp_server/connection.py +69 -10
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +217 -62
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +57 -1
- package/mcp_server/runtime/mcp_dispatch.py +22 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +27 -25
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +21 -8
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/tracks.py +11 -5
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- package/server.json +2 -2
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -23,6 +23,7 @@ OSC address convention:
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import asyncio
|
|
26
|
+
import atexit
|
|
26
27
|
import base64
|
|
27
28
|
import json
|
|
28
29
|
import random
|
|
@@ -30,6 +31,7 @@ import socket
|
|
|
30
31
|
import struct
|
|
31
32
|
import threading
|
|
32
33
|
import time
|
|
34
|
+
import uuid
|
|
33
35
|
from typing import Any, Callable, Optional
|
|
34
36
|
|
|
35
37
|
|
|
@@ -158,15 +160,21 @@ class MidiToolCache:
|
|
|
158
160
|
self._context: Optional[dict] = None
|
|
159
161
|
self._notes: Optional[list] = None
|
|
160
162
|
self._last_seen = 0.0
|
|
163
|
+
self._request_time = 0.0
|
|
161
164
|
self._connected = False
|
|
162
165
|
self._target_tool: Optional[str] = None
|
|
163
166
|
self._target_params: dict = {}
|
|
164
167
|
|
|
165
168
|
def set_request(self, context: dict, notes: list) -> None:
|
|
166
169
|
with self._lock:
|
|
170
|
+
now = time.monotonic()
|
|
167
171
|
self._context = context
|
|
168
172
|
self._notes = notes
|
|
169
|
-
self._last_seen =
|
|
173
|
+
self._last_seen = now
|
|
174
|
+
# Timestamp the request payload independently of _last_seen so a
|
|
175
|
+
# later mark_ready() heartbeat (which bumps _last_seen for
|
|
176
|
+
# is_connected) cannot resurrect an expired context/notes payload.
|
|
177
|
+
self._request_time = now
|
|
170
178
|
self._connected = True
|
|
171
179
|
|
|
172
180
|
def mark_ready(self) -> None:
|
|
@@ -179,7 +187,7 @@ class MidiToolCache:
|
|
|
179
187
|
with self._lock:
|
|
180
188
|
if self._context is None:
|
|
181
189
|
return None
|
|
182
|
-
age = time.monotonic() - self.
|
|
190
|
+
age = time.monotonic() - self._request_time
|
|
183
191
|
if age > self._max_age:
|
|
184
192
|
return None
|
|
185
193
|
return dict(self._context)
|
|
@@ -188,7 +196,7 @@ class MidiToolCache:
|
|
|
188
196
|
with self._lock:
|
|
189
197
|
if self._notes is None:
|
|
190
198
|
return None
|
|
191
|
-
age = time.monotonic() - self.
|
|
199
|
+
age = time.monotonic() - self._request_time
|
|
192
200
|
if age > self._max_age:
|
|
193
201
|
return None
|
|
194
202
|
return list(self._notes)
|
|
@@ -477,7 +485,10 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
477
485
|
/rms f — RMS level
|
|
478
486
|
/pitch f f — MIDI note, amplitude
|
|
479
487
|
/response s — base64-encoded JSON (single packet)
|
|
480
|
-
/response_chunk i i s
|
|
488
|
+
/response_chunk [s] i i s — chunked response. New builds prefix the
|
|
489
|
+
per-response request id so chunks of
|
|
490
|
+
different commands never share a bucket;
|
|
491
|
+
legacy builds omit it: (index, total, data)
|
|
481
492
|
"""
|
|
482
493
|
|
|
483
494
|
# Band names keyed by how many bands the .amxd emits. 8 bands is the v1.x
|
|
@@ -499,8 +510,18 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
499
510
|
self._chunk_id = 0
|
|
500
511
|
self._chunk_key: Optional[str] = None # Key of the single active reassembly bucket
|
|
501
512
|
self._response_callback: Optional[asyncio.Future] = None
|
|
513
|
+
self._response_request_id: Optional[str] = None
|
|
514
|
+
# Set True the first time a response arrives carrying a request id.
|
|
515
|
+
# Once the analyzer build is known to stamp ids, a response that
|
|
516
|
+
# arrives with NO id (or a mismatched one) while a future is live is a
|
|
517
|
+
# stale straggler and must be dropped — see _handle_response.
|
|
518
|
+
self._seen_request_id = False
|
|
502
519
|
self._capture_future: Optional[asyncio.Future] = None
|
|
503
520
|
self._miditool_handler: Optional[Callable[[str, dict, list], None]] = None
|
|
521
|
+
# Source-address allowlist: the M4L device always sends from loopback.
|
|
522
|
+
# Track which non-loopback senders we've already warned about so a
|
|
523
|
+
# spoofing/collision source doesn't spam stderr once per packet.
|
|
524
|
+
self._warned_sources: set[str] = set()
|
|
504
525
|
|
|
505
526
|
def set_miditool_handler(self, handler: Optional[Callable[[str, dict, list], None]]) -> None:
|
|
506
527
|
"""Register a callback invoked on each /miditool/request packet.
|
|
@@ -514,7 +535,27 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
514
535
|
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
|
515
536
|
self.transport = transport
|
|
516
537
|
|
|
538
|
+
# Any local process can send to this UDP port, so a stray/malicious
|
|
539
|
+
# sender could poison the spectral cache or spoof /response packets to
|
|
540
|
+
# resolve pending bridge futures. The M4L device always sends from
|
|
541
|
+
# loopback, so anything else is dropped. This is a source-address
|
|
542
|
+
# allowlist, not authentication — a full shared-token handshake between
|
|
543
|
+
# the .amxd and the bridge is a deeper fix, tracked separately.
|
|
544
|
+
_ALLOWED_HOSTS = ("127.0.0.1", "::1")
|
|
545
|
+
|
|
517
546
|
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
|
547
|
+
host = addr[0] if addr else None
|
|
548
|
+
if host not in self._ALLOWED_HOSTS:
|
|
549
|
+
if host not in self._warned_sources:
|
|
550
|
+
self._warned_sources.add(host)
|
|
551
|
+
import sys
|
|
552
|
+
print(
|
|
553
|
+
f"LivePilot: dropping UDP packet from non-loopback source "
|
|
554
|
+
f"{addr!r} on spectral bridge port (further packets from "
|
|
555
|
+
"this source will be dropped silently)",
|
|
556
|
+
file=sys.stderr,
|
|
557
|
+
)
|
|
558
|
+
return
|
|
518
559
|
try:
|
|
519
560
|
self._parse_osc(data)
|
|
520
561
|
except Exception as exc:
|
|
@@ -653,7 +694,16 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
653
694
|
self._handle_response(str(args[0]))
|
|
654
695
|
|
|
655
696
|
elif address == "/response_chunk" and len(args) >= 3:
|
|
656
|
-
|
|
697
|
+
# New builds prefix the request id (string): (rid, index, total, data).
|
|
698
|
+
# Legacy builds send (index, total, data). Distinguish by the type
|
|
699
|
+
# of the first arg — OSC ints decode to int, the id to a str.
|
|
700
|
+
if len(args) >= 4 and isinstance(args[0], str):
|
|
701
|
+
self._handle_chunk(
|
|
702
|
+
int(args[1]), int(args[2]), str(args[3]),
|
|
703
|
+
request_id=str(args[0]),
|
|
704
|
+
)
|
|
705
|
+
else:
|
|
706
|
+
self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
|
|
657
707
|
|
|
658
708
|
elif address == "/miditool/request" and len(args) >= 1:
|
|
659
709
|
self._handle_miditool_request(str(args[0]))
|
|
@@ -665,24 +715,52 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
665
715
|
def _handle_response(self, encoded: str) -> None:
|
|
666
716
|
"""Decode a single-packet base64 response.
|
|
667
717
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
718
|
+
Updated analyzer JS echoes ``_livepilot_request_id`` on every response
|
|
719
|
+
(single-packet here, and on the chunk header for chunked responses).
|
|
720
|
+
Correlation rules, once this device is known to stamp ids:
|
|
721
|
+
* a response whose id does NOT match the in-flight future is dropped;
|
|
722
|
+
* a response with NO id at all is also dropped — it is a stale
|
|
723
|
+
straggler (e.g. a batched read that outlived its timeout), not a
|
|
724
|
+
pre-request-id build.
|
|
725
|
+
Pre-request-id builds (which never stamp an id) stay supported: until
|
|
726
|
+
the first id is ever seen, no-id responses are accepted.
|
|
674
727
|
"""
|
|
675
728
|
try:
|
|
676
729
|
# URL-safe base64 decode (- and _ instead of + and /)
|
|
677
730
|
padded = encoded + "=" * (-len(encoded) % 4)
|
|
678
731
|
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
|
|
679
732
|
result = _normalize_bridge_payload(json.loads(decoded))
|
|
733
|
+
response_request_id = None
|
|
734
|
+
if isinstance(result, dict):
|
|
735
|
+
response_request_id = result.pop("_livepilot_request_id", None)
|
|
736
|
+
|
|
737
|
+
if response_request_id is not None:
|
|
738
|
+
# This analyzer build stamps request ids — remember it so a
|
|
739
|
+
# later no-id straggler is recognised as stale rather than
|
|
740
|
+
# mistaken for an old build that never sends ids.
|
|
741
|
+
self._seen_request_id = True
|
|
742
|
+
|
|
680
743
|
cb = self._response_callback
|
|
744
|
+
expected_request_id = self._response_request_id
|
|
745
|
+
if cb and not cb.done() and expected_request_id is not None:
|
|
746
|
+
mismatched = (
|
|
747
|
+
response_request_id is not None
|
|
748
|
+
and str(response_request_id) != str(expected_request_id)
|
|
749
|
+
)
|
|
750
|
+
missing_but_expected = (
|
|
751
|
+
response_request_id is None and self._seen_request_id
|
|
752
|
+
)
|
|
753
|
+
if mismatched or missing_but_expected:
|
|
754
|
+
# Stale/uncorrelated reply — drop it and keep waiting for
|
|
755
|
+
# the response that actually matches this command.
|
|
756
|
+
return
|
|
757
|
+
|
|
681
758
|
if cb and not cb.done():
|
|
682
759
|
cb.set_result(result)
|
|
683
760
|
# Clear regardless — either we consumed it, or it was already
|
|
684
761
|
# done/abandoned. Future packets with no owner get dropped.
|
|
685
762
|
self._response_callback = None
|
|
763
|
+
self._response_request_id = None
|
|
686
764
|
except Exception as exc:
|
|
687
765
|
import sys
|
|
688
766
|
print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
|
|
@@ -691,7 +769,8 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
691
769
|
"""Decode a /miditool/request packet and dispatch to the configured handler.
|
|
692
770
|
|
|
693
771
|
Payload: base64(JSON({request_id, context, notes})).
|
|
694
|
-
Packet arrives on the
|
|
772
|
+
Packet arrives on the asyncio event-loop thread (SpectralReceiver is a
|
|
773
|
+
DatagramProtocol, not a separate thread); the registered handler is
|
|
695
774
|
expected to schedule work on an event loop rather than block here.
|
|
696
775
|
"""
|
|
697
776
|
try:
|
|
@@ -718,49 +797,62 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
718
797
|
import sys
|
|
719
798
|
print(f"LivePilot: miditool handler error: {exc}", file=sys.stderr)
|
|
720
799
|
|
|
721
|
-
def _handle_chunk(
|
|
800
|
+
def _handle_chunk(
|
|
801
|
+
self,
|
|
802
|
+
index: int,
|
|
803
|
+
total: int,
|
|
804
|
+
encoded: str,
|
|
805
|
+
request_id: Optional[str] = None,
|
|
806
|
+
) -> None:
|
|
722
807
|
"""Reassemble chunked responses.
|
|
723
808
|
|
|
724
|
-
|
|
725
|
-
``
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
809
|
+
New analyzer builds stamp the per-response request id on every
|
|
810
|
+
``/response_chunk`` header, so chunks from different commands land in
|
|
811
|
+
SEPARATE buckets and can never be interleaved — even if a stale chunk
|
|
812
|
+
from a timed-out command arrives mid-flight. Legacy builds omit the id
|
|
813
|
+
(``request_id`` None/""); they fall back to a single rolling bucket,
|
|
814
|
+
which is safe because ``send_command`` serialises on ``_cmd_lock``.
|
|
815
|
+
|
|
816
|
+
Hardening (v1.27.2): an index outside ``[0, total)`` is dropped — a
|
|
817
|
+
duplicate or malformed packet must never count toward completion — and
|
|
818
|
+
reassembly only fires once EVERY index ``0..total-1`` is present. The
|
|
819
|
+
old ``len(parts) == total`` check could KeyError on a duplicate or
|
|
820
|
+
out-of-range index, silently losing the response and forcing a full
|
|
821
|
+
timeout.
|
|
736
822
|
"""
|
|
737
|
-
#
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
self._chunks
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
823
|
+
# Drop chunks that cannot belong to a well-formed response.
|
|
824
|
+
if total <= 0 or index < 0 or index >= total:
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
if request_id:
|
|
828
|
+
# Collision-free per-response bucket.
|
|
829
|
+
key = f"rid:{request_id}"
|
|
830
|
+
active = self._chunks.get(key)
|
|
831
|
+
if active is None or active["total"] != total:
|
|
832
|
+
self._chunks[key] = {"parts": {}, "total": total}
|
|
833
|
+
self._chunk_times[key] = time.monotonic()
|
|
834
|
+
self._chunk_key = key
|
|
835
|
+
else:
|
|
836
|
+
# Legacy single-active-bucket model (no id on the wire). A chunk
|
|
837
|
+
# with a different `total` means a new response started (e.g. the
|
|
838
|
+
# previous one timed out without ever completing) — evict + reopen.
|
|
839
|
+
active = self._chunks.get(self._chunk_key)
|
|
840
|
+
if active is None or active["total"] != total:
|
|
841
|
+
if active is not None:
|
|
842
|
+
self._chunks.pop(self._chunk_key, None)
|
|
843
|
+
self._chunk_times.pop(self._chunk_key, None)
|
|
844
|
+
self._chunk_id += 1
|
|
845
|
+
self._chunk_key = str(self._chunk_id)
|
|
846
|
+
self._chunks[self._chunk_key] = {"parts": {}, "total": total}
|
|
847
|
+
self._chunk_times[self._chunk_key] = time.monotonic()
|
|
848
|
+
key = self._chunk_key
|
|
849
|
+
|
|
850
|
+
parts = self._chunks[key]["parts"]
|
|
851
|
+
parts[index] = encoded
|
|
852
|
+
|
|
853
|
+
if len(parts) == total and all(i in parts for i in range(total)):
|
|
854
|
+
# Every chunk present — reassemble in order.
|
|
855
|
+
full = "".join(parts[i] for i in range(total))
|
|
764
856
|
del self._chunks[key]
|
|
765
857
|
self._chunk_times.pop(key, None)
|
|
766
858
|
self._handle_response(full)
|
|
@@ -780,13 +872,22 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
780
872
|
result = _normalize_bridge_payload(json.loads(decoded))
|
|
781
873
|
if self._capture_future and not self._capture_future.done():
|
|
782
874
|
self._capture_future.set_result(result)
|
|
875
|
+
# Clear the reference so a completed future isn't retained until the
|
|
876
|
+
# next capture, and so send_capture's done()-guarded cancel has no
|
|
877
|
+
# stale object to consider.
|
|
878
|
+
self._capture_future = None
|
|
783
879
|
except Exception as exc:
|
|
784
880
|
import sys
|
|
785
881
|
print(f"LivePilot: failed to decode capture response: {exc}", file=sys.stderr)
|
|
786
882
|
|
|
787
|
-
def set_response_future(
|
|
883
|
+
def set_response_future(
|
|
884
|
+
self,
|
|
885
|
+
future: Optional[asyncio.Future],
|
|
886
|
+
request_id: Optional[str] = None,
|
|
887
|
+
) -> None:
|
|
788
888
|
"""Set a future to be resolved with the next response."""
|
|
789
889
|
self._response_callback = future
|
|
890
|
+
self._response_request_id = request_id if future is not None else None
|
|
790
891
|
|
|
791
892
|
def set_capture_future(self, future: asyncio.Future) -> None:
|
|
792
893
|
"""Set a future to be resolved when a capture_complete OSC arrives."""
|
|
@@ -810,7 +911,14 @@ class M4LBridge:
|
|
|
810
911
|
self.receiver = receiver
|
|
811
912
|
self.miditool_cache = miditool_cache
|
|
812
913
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
914
|
+
# Non-blocking so a momentarily-full send buffer never stalls the
|
|
915
|
+
# asyncio event loop. The miditool response path sends from inside a
|
|
916
|
+
# DatagramProtocol callback that runs ON the loop thread — a blocking
|
|
917
|
+
# sendto there would freeze every pending coroutine. UDP loopback sends
|
|
918
|
+
# essentially never block; _safe_sendto guards the rare case anyway.
|
|
919
|
+
self._sock.setblocking(False)
|
|
813
920
|
self._m4l_addr = ("127.0.0.1", 9881)
|
|
921
|
+
self._closed = False
|
|
814
922
|
self._cmd_lock: Optional[asyncio.Lock] = None
|
|
815
923
|
# BUG-audit-C1: send_capture uses _capture_future, which is
|
|
816
924
|
# independent of _response_callback used by send_command.
|
|
@@ -825,13 +933,40 @@ class M4LBridge:
|
|
|
825
933
|
if self.receiver.miditool_cache is None and miditool_cache is not None:
|
|
826
934
|
self.receiver.miditool_cache = miditool_cache
|
|
827
935
|
|
|
936
|
+
# Best-effort: release this bridge's OUTGOING UDP 9881 send socket on a
|
|
937
|
+
# clean interpreter exit. NOTE: this is NOT the 9880 analyzer listener —
|
|
938
|
+
# that socket lives on the SpectralReceiver transport (created in the
|
|
939
|
+
# server lifespan) and is closed in the lifespan finally. The OS reclaims
|
|
940
|
+
# BOTH ports on any process death, INCLUDING the SIGTERM/SIGKILL the
|
|
941
|
+
# orphan-kill runbook uses (which bypasses atexit). The real defense
|
|
942
|
+
# against an orphan squatting 9880 is the §9b kill-orphan procedure +
|
|
943
|
+
# reconnect_bridge, not this handler.
|
|
944
|
+
atexit.register(self.close)
|
|
945
|
+
|
|
946
|
+
def _safe_sendto(self, data: bytes) -> None:
|
|
947
|
+
"""Send a UDP packet without ever blocking the event loop.
|
|
948
|
+
|
|
949
|
+
The socket is non-blocking; if the OS send buffer is momentarily full
|
|
950
|
+
(vanishingly rare on loopback) sendto raises BlockingIOError. We drop
|
|
951
|
+
the packet rather than stall — the caller's timeout/retry handles it.
|
|
952
|
+
"""
|
|
953
|
+
try:
|
|
954
|
+
self._sock.sendto(data, self._m4l_addr)
|
|
955
|
+
except BlockingIOError:
|
|
956
|
+
import sys
|
|
957
|
+
print(
|
|
958
|
+
"LivePilot: M4L UDP send buffer full — packet dropped",
|
|
959
|
+
file=sys.stderr,
|
|
960
|
+
)
|
|
961
|
+
|
|
828
962
|
def _dispatch_miditool_request(
|
|
829
963
|
self, request_id: str, context: dict, notes: list,
|
|
830
964
|
) -> None:
|
|
831
965
|
"""Handle a decoded /miditool/request: run the configured generator,
|
|
832
966
|
send /miditool/response back with {request_id, notes}.
|
|
833
967
|
|
|
834
|
-
Invoked from SpectralReceiver on the
|
|
968
|
+
Invoked from SpectralReceiver on the asyncio event-loop thread
|
|
969
|
+
(it is a DatagramProtocol, not a separate UDP thread). We do not
|
|
835
970
|
await anything here — generators are synchronous, pure Python.
|
|
836
971
|
If no target is configured, pass notes through unchanged (identity).
|
|
837
972
|
"""
|
|
@@ -869,7 +1004,7 @@ class M4LBridge:
|
|
|
869
1004
|
"""
|
|
870
1005
|
payload = {"tool_name": tool_name or "", "params": params or {}}
|
|
871
1006
|
osc = self._build_osc("miditool/config", (json.dumps(payload),))
|
|
872
|
-
self.
|
|
1007
|
+
self._safe_sendto(osc)
|
|
873
1008
|
|
|
874
1009
|
def send_miditool_response(self, request_id: str, notes: list) -> None:
|
|
875
1010
|
"""Send transformed notes back to the JS bridge.
|
|
@@ -879,7 +1014,7 @@ class M4LBridge:
|
|
|
879
1014
|
"""
|
|
880
1015
|
payload = {"request_id": str(request_id or ""), "notes": list(notes or [])}
|
|
881
1016
|
osc = self._build_osc("miditool/response", (json.dumps(payload),))
|
|
882
|
-
self.
|
|
1017
|
+
self._safe_sendto(osc)
|
|
883
1018
|
|
|
884
1019
|
async def send_command(self, command: str, *args: Any, timeout: float = 5.0) -> dict:
|
|
885
1020
|
"""Send an OSC command to the M4L device and wait for the response."""
|
|
@@ -905,12 +1040,14 @@ class M4LBridge:
|
|
|
905
1040
|
# Create a future for the response
|
|
906
1041
|
loop = asyncio.get_running_loop()
|
|
907
1042
|
future = loop.create_future()
|
|
908
|
-
|
|
1043
|
+
request_id = uuid.uuid4().hex
|
|
1044
|
+
self.receiver.set_response_future(future, request_id=request_id)
|
|
909
1045
|
|
|
910
1046
|
# Build and send OSC message (no leading / — Max udpreceive
|
|
911
1047
|
# passes messagename with / intact to JS, breaking dispatch)
|
|
912
|
-
|
|
913
|
-
self.
|
|
1048
|
+
request_arg = f"__livepilot_request_id:{request_id}"
|
|
1049
|
+
osc_data = self._build_osc(command, (*args, request_arg))
|
|
1050
|
+
self._safe_sendto(osc_data)
|
|
914
1051
|
|
|
915
1052
|
# Wait for response with timeout
|
|
916
1053
|
try:
|
|
@@ -958,7 +1095,7 @@ class M4LBridge:
|
|
|
958
1095
|
self.receiver.set_capture_future(future)
|
|
959
1096
|
|
|
960
1097
|
osc_data = self._build_osc(command, args)
|
|
961
|
-
self.
|
|
1098
|
+
self._safe_sendto(osc_data)
|
|
962
1099
|
|
|
963
1100
|
try:
|
|
964
1101
|
result = await asyncio.wait_for(future, timeout=timeout)
|
|
@@ -971,7 +1108,7 @@ class M4LBridge:
|
|
|
971
1108
|
async def cancel_capture_future(self) -> None:
|
|
972
1109
|
"""Resolve any in-progress capture future with a stopped result.
|
|
973
1110
|
|
|
974
|
-
Does NOT acquire
|
|
1111
|
+
Does NOT acquire _capture_lock — send_capture holds it during recording.
|
|
975
1112
|
Resolving (not cancelling) the future lets send_capture return a
|
|
976
1113
|
clean partial-result dict instead of raising CancelledError.
|
|
977
1114
|
"""
|
|
@@ -1025,4 +1162,22 @@ class M4LBridge:
|
|
|
1025
1162
|
return addr_bytes + tag_bytes + arg_data
|
|
1026
1163
|
|
|
1027
1164
|
def close(self) -> None:
|
|
1028
|
-
|
|
1165
|
+
"""Close the outgoing UDP socket and mark this bridge as shut down.
|
|
1166
|
+
|
|
1167
|
+
Idempotent — safe to call multiple times (from the server lifespan
|
|
1168
|
+
finally block AND from the atexit handler registered at construction).
|
|
1169
|
+
|
|
1170
|
+
Releases this bridge's OUTGOING UDP 9881 send socket (M4LBridge._sock).
|
|
1171
|
+
This is NOT the 9880 analyzer listener — that is the SpectralReceiver
|
|
1172
|
+
transport, closed in the server lifespan finally. Called from both the
|
|
1173
|
+
lifespan shutdown and an atexit handler; on abrupt process death the OS
|
|
1174
|
+
reclaims the socket regardless (see the §9b orphan-kill runbook for the
|
|
1175
|
+
actual stale-9880 defense).
|
|
1176
|
+
"""
|
|
1177
|
+
if self._closed:
|
|
1178
|
+
return
|
|
1179
|
+
self._closed = True
|
|
1180
|
+
try:
|
|
1181
|
+
self._sock.close()
|
|
1182
|
+
except OSError:
|
|
1183
|
+
pass
|
|
@@ -216,6 +216,49 @@ class TasteGraph:
|
|
|
216
216
|
logger.debug("update_novelty failed: %s", exc)
|
|
217
217
|
pass # persistence is best-effort
|
|
218
218
|
|
|
219
|
+
def record_anti_preference(self, dimension: str, direction: str) -> None:
|
|
220
|
+
"""Record a dimension avoidance and persist it across restarts.
|
|
221
|
+
|
|
222
|
+
Mirrors record_move_outcome's write-back contract: updates the
|
|
223
|
+
in-memory dimension_avoidances map AND persists to disk so the
|
|
224
|
+
avoidance survives a server restart. build_taste_graph hydrates
|
|
225
|
+
anti_preferences from the same persistent store, so without this
|
|
226
|
+
write-back the persisted read branch would stay dead (P2-29).
|
|
227
|
+
"""
|
|
228
|
+
now = int(time.time() * 1000)
|
|
229
|
+
self.dimension_avoidances[dimension] = direction
|
|
230
|
+
self.evidence_count += 1
|
|
231
|
+
self.last_updated_ms = now
|
|
232
|
+
|
|
233
|
+
# Write-back to persistent store
|
|
234
|
+
if self._persistent_store is not None:
|
|
235
|
+
try:
|
|
236
|
+
self._persistent_store.record_anti_preference(dimension, direction)
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
logger.debug("record_anti_preference failed: %s", exc)
|
|
239
|
+
pass # persistence is best-effort
|
|
240
|
+
|
|
241
|
+
def record_dimension_weight(self, dimension: str, value: float) -> None:
|
|
242
|
+
"""Set a dimension preference weight and persist it across restarts.
|
|
243
|
+
|
|
244
|
+
Mirrors record_move_outcome's write-back contract: updates the
|
|
245
|
+
in-memory dimension_weights map AND persists to disk so the weight
|
|
246
|
+
survives a server restart. build_taste_graph hydrates
|
|
247
|
+
dimension_weights from the same persistent store, so without this
|
|
248
|
+
write-back the persisted read branch would stay dead (P2-29).
|
|
249
|
+
"""
|
|
250
|
+
now = int(time.time() * 1000)
|
|
251
|
+
self.dimension_weights[dimension] = value
|
|
252
|
+
self.last_updated_ms = now
|
|
253
|
+
|
|
254
|
+
# Write-back to persistent store
|
|
255
|
+
if self._persistent_store is not None:
|
|
256
|
+
try:
|
|
257
|
+
self._persistent_store.record_dimension_weight(dimension, value)
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
logger.debug("record_dimension_weight failed: %s", exc)
|
|
260
|
+
pass # persistence is best-effort
|
|
261
|
+
|
|
219
262
|
# ── Ranking ──────────────────────────────────────────────────────
|
|
220
263
|
|
|
221
264
|
def rank_moves(
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
4
|
import json
|
|
5
|
+
import logging
|
|
5
6
|
import os
|
|
6
7
|
import threading
|
|
7
8
|
import uuid
|
|
@@ -10,6 +11,9 @@ from pathlib import Path
|
|
|
10
11
|
from typing import Any, Optional
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
13
17
|
VALID_TYPES = frozenset(
|
|
14
18
|
["beat_pattern", "device_chain", "mix_template", "browser_pin", "preference",
|
|
15
19
|
"outcome", "composition_outcome", "technique_card"]
|
|
@@ -74,9 +78,28 @@ class TechniqueStore:
|
|
|
74
78
|
with open(self._file, "r") as f:
|
|
75
79
|
self._data = json.load(f)
|
|
76
80
|
self._loaded_sig = self._file_signature()
|
|
77
|
-
except (json.JSONDecodeError, ValueError):
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
82
|
+
# Preserve the unparseable file before replacing it with
|
|
83
|
+
# defaults, so the user's data stays recoverable. Use a
|
|
84
|
+
# timestamped name so repeated corruptions don't clobber
|
|
85
|
+
# an earlier backup (a plain ".json.corrupt" target would
|
|
86
|
+
# be silently overwritten on POSIX / raise on Windows).
|
|
87
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
|
|
88
|
+
corrupt = self._file.with_suffix(f".json.corrupt.{ts}")
|
|
89
|
+
try:
|
|
90
|
+
self._file.rename(corrupt)
|
|
91
|
+
logger.warning(
|
|
92
|
+
"techniques.json failed to parse (%s); backed up "
|
|
93
|
+
"corrupt file to %s and reinitialized with defaults",
|
|
94
|
+
exc, corrupt,
|
|
95
|
+
)
|
|
96
|
+
except OSError as rename_exc:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"techniques.json failed to parse (%s) and the "
|
|
99
|
+
"corrupt backup could not be written (%s); "
|
|
100
|
+
"reinitialized with defaults — prior data lost",
|
|
101
|
+
exc, rename_exc,
|
|
102
|
+
)
|
|
80
103
|
self._data = {"version": 1, "techniques": []}
|
|
81
104
|
self._loaded_sig = None
|
|
82
105
|
else:
|