livepilot 1.27.0 → 1.27.2
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 +68 -0
- package/README.md +26 -7
- package/bin/livepilot.js +86 -23
- package/installer/install.js +21 -12
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +8 -8
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- 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 +20 -1
- package/mcp_server/atlas/tools.py +83 -22
- package/mcp_server/composer/fast/brief_builder.py +6 -2
- package/mcp_server/composer/full/apply.py +9 -0
- package/mcp_server/composer/full/layer_planner.py +16 -6
- package/mcp_server/composer/tools.py +12 -6
- package/mcp_server/connection.py +2 -1
- package/mcp_server/creative_constraints/engine.py +46 -0
- package/mcp_server/experiment/engine.py +10 -3
- package/mcp_server/grader/client.py +30 -2
- package/mcp_server/grader/tools.py +8 -2
- package/mcp_server/hook_hunter/analyzer.py +15 -2
- package/mcp_server/m4l_bridge.py +145 -54
- package/mcp_server/memory/taste_graph.py +16 -0
- package/mcp_server/memory/technique_store.py +23 -3
- package/mcp_server/mix_engine/state_builder.py +4 -2
- package/mcp_server/musical_intelligence/detectors.py +11 -2
- package/mcp_server/musical_intelligence/tools.py +15 -2
- package/mcp_server/preview_studio/engine.py +30 -1
- package/mcp_server/project_brain/tools.py +56 -52
- package/mcp_server/reference_engine/tools.py +22 -2
- package/mcp_server/runtime/execution_router.py +6 -0
- package/mcp_server/runtime/live_version.py +27 -8
- package/mcp_server/runtime/mcp_dispatch.py +22 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- package/mcp_server/runtime/safety_kernel.py +11 -0
- package/mcp_server/sample_engine/critics.py +7 -3
- package/mcp_server/sample_engine/slice_workflow.py +3 -2
- package/mcp_server/sample_engine/tools.py +53 -21
- package/mcp_server/server.py +1 -1
- package/mcp_server/song_brain/builder.py +17 -1
- package/mcp_server/sound_design/tools.py +1 -1
- package/mcp_server/splice_client/http_bridge.py +43 -1
- package/mcp_server/splice_client/models.py +7 -3
- package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
- package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
- package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
- package/mcp_server/tools/_composition_engine/models.py +6 -0
- package/mcp_server/tools/_composition_engine/sections.py +4 -0
- package/mcp_server/tools/clips.py +5 -4
- package/mcp_server/tools/composition.py +5 -1
- package/mcp_server/tools/midi_io.py +40 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
- package/mcp_server/user_corpus/runner.py +7 -1
- package/mcp_server/user_corpus/scanners/amxd.py +24 -13
- package/mcp_server/user_corpus/tools.py +45 -9
- package/mcp_server/wonder_mode/tools.py +66 -27
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +21 -8
- package/remote_script/LivePilot/server.py +23 -3
- package/remote_script/LivePilot/tracks.py +11 -5
- package/requirements.txt +1 -1
- package/server.json +2 -2
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -30,6 +30,7 @@ import socket
|
|
|
30
30
|
import struct
|
|
31
31
|
import threading
|
|
32
32
|
import time
|
|
33
|
+
import uuid
|
|
33
34
|
from typing import Any, Callable, Optional
|
|
34
35
|
|
|
35
36
|
|
|
@@ -477,7 +478,10 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
477
478
|
/rms f — RMS level
|
|
478
479
|
/pitch f f — MIDI note, amplitude
|
|
479
480
|
/response s — base64-encoded JSON (single packet)
|
|
480
|
-
/response_chunk i i s
|
|
481
|
+
/response_chunk [s] i i s — chunked response. New builds prefix the
|
|
482
|
+
per-response request id so chunks of
|
|
483
|
+
different commands never share a bucket;
|
|
484
|
+
legacy builds omit it: (index, total, data)
|
|
481
485
|
"""
|
|
482
486
|
|
|
483
487
|
# Band names keyed by how many bands the .amxd emits. 8 bands is the v1.x
|
|
@@ -497,7 +501,14 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
497
501
|
self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
|
|
498
502
|
self._chunk_times: dict[str, float] = {} # Monotonic timestamp per chunk sequence
|
|
499
503
|
self._chunk_id = 0
|
|
504
|
+
self._chunk_key: Optional[str] = None # Key of the single active reassembly bucket
|
|
500
505
|
self._response_callback: Optional[asyncio.Future] = None
|
|
506
|
+
self._response_request_id: Optional[str] = None
|
|
507
|
+
# Set True the first time a response arrives carrying a request id.
|
|
508
|
+
# Once the analyzer build is known to stamp ids, a response that
|
|
509
|
+
# arrives with NO id (or a mismatched one) while a future is live is a
|
|
510
|
+
# stale straggler and must be dropped — see _handle_response.
|
|
511
|
+
self._seen_request_id = False
|
|
501
512
|
self._capture_future: Optional[asyncio.Future] = None
|
|
502
513
|
self._miditool_handler: Optional[Callable[[str, dict, list], None]] = None
|
|
503
514
|
|
|
@@ -652,7 +663,16 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
652
663
|
self._handle_response(str(args[0]))
|
|
653
664
|
|
|
654
665
|
elif address == "/response_chunk" and len(args) >= 3:
|
|
655
|
-
|
|
666
|
+
# New builds prefix the request id (string): (rid, index, total, data).
|
|
667
|
+
# Legacy builds send (index, total, data). Distinguish by the type
|
|
668
|
+
# of the first arg — OSC ints decode to int, the id to a str.
|
|
669
|
+
if len(args) >= 4 and isinstance(args[0], str):
|
|
670
|
+
self._handle_chunk(
|
|
671
|
+
int(args[1]), int(args[2]), str(args[3]),
|
|
672
|
+
request_id=str(args[0]),
|
|
673
|
+
)
|
|
674
|
+
else:
|
|
675
|
+
self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
|
|
656
676
|
|
|
657
677
|
elif address == "/miditool/request" and len(args) >= 1:
|
|
658
678
|
self._handle_miditool_request(str(args[0]))
|
|
@@ -664,24 +684,52 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
664
684
|
def _handle_response(self, encoded: str) -> None:
|
|
665
685
|
"""Decode a single-packet base64 response.
|
|
666
686
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
687
|
+
Updated analyzer JS echoes ``_livepilot_request_id`` on every response
|
|
688
|
+
(single-packet here, and on the chunk header for chunked responses).
|
|
689
|
+
Correlation rules, once this device is known to stamp ids:
|
|
690
|
+
* a response whose id does NOT match the in-flight future is dropped;
|
|
691
|
+
* a response with NO id at all is also dropped — it is a stale
|
|
692
|
+
straggler (e.g. a batched read that outlived its timeout), not a
|
|
693
|
+
pre-request-id build.
|
|
694
|
+
Pre-request-id builds (which never stamp an id) stay supported: until
|
|
695
|
+
the first id is ever seen, no-id responses are accepted.
|
|
673
696
|
"""
|
|
674
697
|
try:
|
|
675
698
|
# URL-safe base64 decode (- and _ instead of + and /)
|
|
676
699
|
padded = encoded + "=" * (-len(encoded) % 4)
|
|
677
700
|
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
|
|
678
701
|
result = _normalize_bridge_payload(json.loads(decoded))
|
|
702
|
+
response_request_id = None
|
|
703
|
+
if isinstance(result, dict):
|
|
704
|
+
response_request_id = result.pop("_livepilot_request_id", None)
|
|
705
|
+
|
|
706
|
+
if response_request_id is not None:
|
|
707
|
+
# This analyzer build stamps request ids — remember it so a
|
|
708
|
+
# later no-id straggler is recognised as stale rather than
|
|
709
|
+
# mistaken for an old build that never sends ids.
|
|
710
|
+
self._seen_request_id = True
|
|
711
|
+
|
|
679
712
|
cb = self._response_callback
|
|
713
|
+
expected_request_id = self._response_request_id
|
|
714
|
+
if cb and not cb.done() and expected_request_id is not None:
|
|
715
|
+
mismatched = (
|
|
716
|
+
response_request_id is not None
|
|
717
|
+
and str(response_request_id) != str(expected_request_id)
|
|
718
|
+
)
|
|
719
|
+
missing_but_expected = (
|
|
720
|
+
response_request_id is None and self._seen_request_id
|
|
721
|
+
)
|
|
722
|
+
if mismatched or missing_but_expected:
|
|
723
|
+
# Stale/uncorrelated reply — drop it and keep waiting for
|
|
724
|
+
# the response that actually matches this command.
|
|
725
|
+
return
|
|
726
|
+
|
|
680
727
|
if cb and not cb.done():
|
|
681
728
|
cb.set_result(result)
|
|
682
729
|
# Clear regardless — either we consumed it, or it was already
|
|
683
730
|
# done/abandoned. Future packets with no owner get dropped.
|
|
684
731
|
self._response_callback = None
|
|
732
|
+
self._response_request_id = None
|
|
685
733
|
except Exception as exc:
|
|
686
734
|
import sys
|
|
687
735
|
print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
|
|
@@ -717,52 +765,62 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
717
765
|
import sys
|
|
718
766
|
print(f"LivePilot: miditool handler error: {exc}", file=sys.stderr)
|
|
719
767
|
|
|
720
|
-
def _handle_chunk(
|
|
768
|
+
def _handle_chunk(
|
|
769
|
+
self,
|
|
770
|
+
index: int,
|
|
771
|
+
total: int,
|
|
772
|
+
encoded: str,
|
|
773
|
+
request_id: Optional[str] = None,
|
|
774
|
+
) -> None:
|
|
721
775
|
"""Reassemble chunked responses.
|
|
722
776
|
|
|
723
|
-
|
|
724
|
-
``
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
777
|
+
New analyzer builds stamp the per-response request id on every
|
|
778
|
+
``/response_chunk`` header, so chunks from different commands land in
|
|
779
|
+
SEPARATE buckets and can never be interleaved — even if a stale chunk
|
|
780
|
+
from a timed-out command arrives mid-flight. Legacy builds omit the id
|
|
781
|
+
(``request_id`` None/""); they fall back to a single rolling bucket,
|
|
782
|
+
which is safe because ``send_command`` serialises on ``_cmd_lock``.
|
|
783
|
+
|
|
784
|
+
Hardening (v1.27.2): an index outside ``[0, total)`` is dropped — a
|
|
785
|
+
duplicate or malformed packet must never count toward completion — and
|
|
786
|
+
reassembly only fires once EVERY index ``0..total-1`` is present. The
|
|
787
|
+
old ``len(parts) == total`` check could KeyError on a duplicate or
|
|
788
|
+
out-of-range index, silently losing the response and forcing a full
|
|
789
|
+
timeout.
|
|
735
790
|
"""
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
key =
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
# to the previous sequence's parts — that's the corruption
|
|
746
|
-
# path. Log once so it's diagnosable.
|
|
747
|
-
import sys
|
|
748
|
-
print(
|
|
749
|
-
f"LivePilot: chunk index={index}/{total} arrived before "
|
|
750
|
-
f"index=0 — starting fresh bucket. UDP reordering on "
|
|
751
|
-
f"loopback suggests system load.",
|
|
752
|
-
file=sys.stderr,
|
|
753
|
-
)
|
|
754
|
-
self._chunk_id += 1
|
|
755
|
-
key = str(self._chunk_id)
|
|
791
|
+
# Drop chunks that cannot belong to a well-formed response.
|
|
792
|
+
if total <= 0 or index < 0 or index >= total:
|
|
793
|
+
return
|
|
794
|
+
|
|
795
|
+
if request_id:
|
|
796
|
+
# Collision-free per-response bucket.
|
|
797
|
+
key = f"rid:{request_id}"
|
|
798
|
+
active = self._chunks.get(key)
|
|
799
|
+
if active is None or active["total"] != total:
|
|
756
800
|
self._chunks[key] = {"parts": {}, "total": total}
|
|
757
801
|
self._chunk_times[key] = time.monotonic()
|
|
802
|
+
self._chunk_key = key
|
|
803
|
+
else:
|
|
804
|
+
# Legacy single-active-bucket model (no id on the wire). A chunk
|
|
805
|
+
# with a different `total` means a new response started (e.g. the
|
|
806
|
+
# previous one timed out without ever completing) — evict + reopen.
|
|
807
|
+
active = self._chunks.get(self._chunk_key)
|
|
808
|
+
if active is None or active["total"] != total:
|
|
809
|
+
if active is not None:
|
|
810
|
+
self._chunks.pop(self._chunk_key, None)
|
|
811
|
+
self._chunk_times.pop(self._chunk_key, None)
|
|
812
|
+
self._chunk_id += 1
|
|
813
|
+
self._chunk_key = str(self._chunk_id)
|
|
814
|
+
self._chunks[self._chunk_key] = {"parts": {}, "total": total}
|
|
815
|
+
self._chunk_times[self._chunk_key] = time.monotonic()
|
|
816
|
+
key = self._chunk_key
|
|
758
817
|
|
|
759
|
-
self._chunks[key]["parts"]
|
|
818
|
+
parts = self._chunks[key]["parts"]
|
|
819
|
+
parts[index] = encoded
|
|
760
820
|
|
|
761
|
-
if len(
|
|
762
|
-
#
|
|
763
|
-
full = ""
|
|
764
|
-
for i in range(total):
|
|
765
|
-
full += self._chunks[key]["parts"][i]
|
|
821
|
+
if len(parts) == total and all(i in parts for i in range(total)):
|
|
822
|
+
# Every chunk present — reassemble in order.
|
|
823
|
+
full = "".join(parts[i] for i in range(total))
|
|
766
824
|
del self._chunks[key]
|
|
767
825
|
self._chunk_times.pop(key, None)
|
|
768
826
|
self._handle_response(full)
|
|
@@ -782,13 +840,22 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
782
840
|
result = _normalize_bridge_payload(json.loads(decoded))
|
|
783
841
|
if self._capture_future and not self._capture_future.done():
|
|
784
842
|
self._capture_future.set_result(result)
|
|
843
|
+
# Clear the reference so a completed future isn't retained until the
|
|
844
|
+
# next capture, and so send_capture's done()-guarded cancel has no
|
|
845
|
+
# stale object to consider.
|
|
846
|
+
self._capture_future = None
|
|
785
847
|
except Exception as exc:
|
|
786
848
|
import sys
|
|
787
849
|
print(f"LivePilot: failed to decode capture response: {exc}", file=sys.stderr)
|
|
788
850
|
|
|
789
|
-
def set_response_future(
|
|
851
|
+
def set_response_future(
|
|
852
|
+
self,
|
|
853
|
+
future: Optional[asyncio.Future],
|
|
854
|
+
request_id: Optional[str] = None,
|
|
855
|
+
) -> None:
|
|
790
856
|
"""Set a future to be resolved with the next response."""
|
|
791
857
|
self._response_callback = future
|
|
858
|
+
self._response_request_id = request_id if future is not None else None
|
|
792
859
|
|
|
793
860
|
def set_capture_future(self, future: asyncio.Future) -> None:
|
|
794
861
|
"""Set a future to be resolved when a capture_complete OSC arrives."""
|
|
@@ -812,6 +879,12 @@ class M4LBridge:
|
|
|
812
879
|
self.receiver = receiver
|
|
813
880
|
self.miditool_cache = miditool_cache
|
|
814
881
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
882
|
+
# Non-blocking so a momentarily-full send buffer never stalls the
|
|
883
|
+
# asyncio event loop. The miditool response path sends from inside a
|
|
884
|
+
# DatagramProtocol callback that runs ON the loop thread — a blocking
|
|
885
|
+
# sendto there would freeze every pending coroutine. UDP loopback sends
|
|
886
|
+
# essentially never block; _safe_sendto guards the rare case anyway.
|
|
887
|
+
self._sock.setblocking(False)
|
|
815
888
|
self._m4l_addr = ("127.0.0.1", 9881)
|
|
816
889
|
self._cmd_lock: Optional[asyncio.Lock] = None
|
|
817
890
|
# BUG-audit-C1: send_capture uses _capture_future, which is
|
|
@@ -827,6 +900,22 @@ class M4LBridge:
|
|
|
827
900
|
if self.receiver.miditool_cache is None and miditool_cache is not None:
|
|
828
901
|
self.receiver.miditool_cache = miditool_cache
|
|
829
902
|
|
|
903
|
+
def _safe_sendto(self, data: bytes) -> None:
|
|
904
|
+
"""Send a UDP packet without ever blocking the event loop.
|
|
905
|
+
|
|
906
|
+
The socket is non-blocking; if the OS send buffer is momentarily full
|
|
907
|
+
(vanishingly rare on loopback) sendto raises BlockingIOError. We drop
|
|
908
|
+
the packet rather than stall — the caller's timeout/retry handles it.
|
|
909
|
+
"""
|
|
910
|
+
try:
|
|
911
|
+
self._sock.sendto(data, self._m4l_addr)
|
|
912
|
+
except BlockingIOError:
|
|
913
|
+
import sys
|
|
914
|
+
print(
|
|
915
|
+
"LivePilot: M4L UDP send buffer full — packet dropped",
|
|
916
|
+
file=sys.stderr,
|
|
917
|
+
)
|
|
918
|
+
|
|
830
919
|
def _dispatch_miditool_request(
|
|
831
920
|
self, request_id: str, context: dict, notes: list,
|
|
832
921
|
) -> None:
|
|
@@ -871,7 +960,7 @@ class M4LBridge:
|
|
|
871
960
|
"""
|
|
872
961
|
payload = {"tool_name": tool_name or "", "params": params or {}}
|
|
873
962
|
osc = self._build_osc("miditool/config", (json.dumps(payload),))
|
|
874
|
-
self.
|
|
963
|
+
self._safe_sendto(osc)
|
|
875
964
|
|
|
876
965
|
def send_miditool_response(self, request_id: str, notes: list) -> None:
|
|
877
966
|
"""Send transformed notes back to the JS bridge.
|
|
@@ -881,7 +970,7 @@ class M4LBridge:
|
|
|
881
970
|
"""
|
|
882
971
|
payload = {"request_id": str(request_id or ""), "notes": list(notes or [])}
|
|
883
972
|
osc = self._build_osc("miditool/response", (json.dumps(payload),))
|
|
884
|
-
self.
|
|
973
|
+
self._safe_sendto(osc)
|
|
885
974
|
|
|
886
975
|
async def send_command(self, command: str, *args: Any, timeout: float = 5.0) -> dict:
|
|
887
976
|
"""Send an OSC command to the M4L device and wait for the response."""
|
|
@@ -907,12 +996,14 @@ class M4LBridge:
|
|
|
907
996
|
# Create a future for the response
|
|
908
997
|
loop = asyncio.get_running_loop()
|
|
909
998
|
future = loop.create_future()
|
|
910
|
-
|
|
999
|
+
request_id = uuid.uuid4().hex
|
|
1000
|
+
self.receiver.set_response_future(future, request_id=request_id)
|
|
911
1001
|
|
|
912
1002
|
# Build and send OSC message (no leading / — Max udpreceive
|
|
913
1003
|
# passes messagename with / intact to JS, breaking dispatch)
|
|
914
|
-
|
|
915
|
-
self.
|
|
1004
|
+
request_arg = f"__livepilot_request_id:{request_id}"
|
|
1005
|
+
osc_data = self._build_osc(command, (*args, request_arg))
|
|
1006
|
+
self._safe_sendto(osc_data)
|
|
916
1007
|
|
|
917
1008
|
# Wait for response with timeout
|
|
918
1009
|
try:
|
|
@@ -960,7 +1051,7 @@ class M4LBridge:
|
|
|
960
1051
|
self.receiver.set_capture_future(future)
|
|
961
1052
|
|
|
962
1053
|
osc_data = self._build_osc(command, args)
|
|
963
|
-
self.
|
|
1054
|
+
self._safe_sendto(osc_data)
|
|
964
1055
|
|
|
965
1056
|
try:
|
|
966
1057
|
result = await asyncio.wait_for(future, timeout=timeout)
|
|
@@ -182,6 +182,14 @@ class TasteGraph:
|
|
|
182
182
|
self.evidence_count += 1
|
|
183
183
|
self.last_updated_ms = now
|
|
184
184
|
|
|
185
|
+
# Write-back to persistent store
|
|
186
|
+
if self._persistent_store is not None:
|
|
187
|
+
try:
|
|
188
|
+
self._persistent_store.record_device_use(device_name, positive)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
logger.debug("record_device_use failed: %s", exc)
|
|
191
|
+
pass # persistence is best-effort
|
|
192
|
+
|
|
185
193
|
def update_novelty_from_experiment(
|
|
186
194
|
self, chose_bold: bool, goal_mode: str = "improve",
|
|
187
195
|
) -> None:
|
|
@@ -200,6 +208,14 @@ class TasteGraph:
|
|
|
200
208
|
new_val = max(0.0, current - 0.05)
|
|
201
209
|
self.novelty_bands[goal_mode] = new_val
|
|
202
210
|
|
|
211
|
+
# Write-back to persistent store
|
|
212
|
+
if self._persistent_store is not None:
|
|
213
|
+
try:
|
|
214
|
+
self._persistent_store.update_novelty(chose_bold, goal_mode)
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
logger.debug("update_novelty failed: %s", exc)
|
|
217
|
+
pass # persistence is best-effort
|
|
218
|
+
|
|
203
219
|
# ── Ranking ──────────────────────────────────────────────────────
|
|
204
220
|
|
|
205
221
|
def rank_moves(
|
|
@@ -31,6 +31,19 @@ class TechniqueStore:
|
|
|
31
31
|
self._lock = threading.Lock()
|
|
32
32
|
self._initialized = False
|
|
33
33
|
self._data: dict = {"version": 1, "techniques": []}
|
|
34
|
+
# Signature (mtime_ns, size) of the file as we last loaded it. Lets
|
|
35
|
+
# multiple TechniqueStore instances pointing at the same file pick up
|
|
36
|
+
# each other's writes (reload-on-read) instead of caching stale data
|
|
37
|
+
# for the life of the process.
|
|
38
|
+
self._loaded_sig: Optional[tuple] = None
|
|
39
|
+
|
|
40
|
+
def _file_signature(self) -> Optional[tuple]:
|
|
41
|
+
"""Return (mtime_ns, size) of the backing file, or None if absent."""
|
|
42
|
+
try:
|
|
43
|
+
st = self._file.stat()
|
|
44
|
+
except OSError:
|
|
45
|
+
return None
|
|
46
|
+
return (st.st_mtime_ns, st.st_size)
|
|
34
47
|
|
|
35
48
|
def _ensure_initialized(self) -> None:
|
|
36
49
|
"""Lazily create directory and load data on first access.
|
|
@@ -40,12 +53,14 @@ class TechniqueStore:
|
|
|
40
53
|
Thread-safe: uses double-checked locking to prevent concurrent
|
|
41
54
|
callers from racing on initialization.
|
|
42
55
|
"""
|
|
43
|
-
|
|
56
|
+
# Fast path: already initialized AND the file on disk has not changed
|
|
57
|
+
# since we last loaded it (no other instance has written).
|
|
58
|
+
if self._initialized and self._file_signature() == self._loaded_sig:
|
|
44
59
|
return
|
|
45
60
|
with self._lock:
|
|
46
61
|
# Double-check after acquiring lock — another thread may have
|
|
47
|
-
#
|
|
48
|
-
if self._initialized:
|
|
62
|
+
# (re)loaded while we were waiting.
|
|
63
|
+
if self._initialized and self._file_signature() == self._loaded_sig:
|
|
49
64
|
return
|
|
50
65
|
try:
|
|
51
66
|
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -58,10 +73,12 @@ class TechniqueStore:
|
|
|
58
73
|
try:
|
|
59
74
|
with open(self._file, "r") as f:
|
|
60
75
|
self._data = json.load(f)
|
|
76
|
+
self._loaded_sig = self._file_signature()
|
|
61
77
|
except (json.JSONDecodeError, ValueError):
|
|
62
78
|
corrupt = self._file.with_suffix(".json.corrupt")
|
|
63
79
|
self._file.rename(corrupt)
|
|
64
80
|
self._data = {"version": 1, "techniques": []}
|
|
81
|
+
self._loaded_sig = None
|
|
65
82
|
else:
|
|
66
83
|
self._data = {"version": 1, "techniques": []}
|
|
67
84
|
self._flush()
|
|
@@ -77,6 +94,9 @@ class TechniqueStore:
|
|
|
77
94
|
f.flush()
|
|
78
95
|
os.fsync(f.fileno())
|
|
79
96
|
os.replace(str(tmp), str(self._file))
|
|
97
|
+
# Record the signature of our own write so this instance does not
|
|
98
|
+
# needlessly reload the data it already holds in memory.
|
|
99
|
+
self._loaded_sig = self._file_signature()
|
|
80
100
|
|
|
81
101
|
# ── public API ───────────────────────────────────────────────
|
|
82
102
|
|
|
@@ -241,8 +241,10 @@ def build_dynamics_state(
|
|
|
241
241
|
|
|
242
242
|
crest = 20 * math.log10(max(peak_linear, 1e-10) / max(rms_linear, 1e-10))
|
|
243
243
|
|
|
244
|
-
# Over-compressed
|
|
245
|
-
|
|
244
|
+
# Over-compressed band is 3-6 dB crest. Below 3 dB the signal is so flat
|
|
245
|
+
# that the dynamics critic should report the stronger `flat_dynamics`
|
|
246
|
+
# issue instead, which only fires when over_compressed is False.
|
|
247
|
+
over_compressed = 3.0 <= crest < 6.0
|
|
246
248
|
|
|
247
249
|
# Headroom = distance from peak to 0 dBFS
|
|
248
250
|
if peak_linear > 0:
|
|
@@ -134,9 +134,15 @@ def detect_repetition_fatigue(
|
|
|
134
134
|
staleness = min(1.0, (reuse_count / total - 1) * 0.3) if total else 0
|
|
135
135
|
report.section_staleness[name] = round(max(0, staleness), 3)
|
|
136
136
|
|
|
137
|
-
# Overall fatigue level
|
|
137
|
+
# Overall fatigue level — saturating combine (probabilistic OR) so that
|
|
138
|
+
# adding more issues never *reduces* fatigue. A plain mean would let a
|
|
139
|
+
# cluster of low-severity issues dilute a single serious one.
|
|
138
140
|
if report.issues:
|
|
139
|
-
|
|
141
|
+
remaining_freshness = 1.0
|
|
142
|
+
for issue in report.issues:
|
|
143
|
+
severity = max(0.0, min(1.0, issue["severity"]))
|
|
144
|
+
remaining_freshness *= (1.0 - severity)
|
|
145
|
+
report.fatigue_level = min(1.0, 1.0 - remaining_freshness)
|
|
140
146
|
|
|
141
147
|
# Recommendations
|
|
142
148
|
if report.fatigue_level > 0.5:
|
|
@@ -337,6 +343,9 @@ def infer_section_purposes(
|
|
|
337
343
|
if position < 0.15 and density < 0.5:
|
|
338
344
|
purpose = "setup"
|
|
339
345
|
confidence = 0.7
|
|
346
|
+
elif density >= 0.8 and density_delta >= 0:
|
|
347
|
+
purpose = "payoff"
|
|
348
|
+
confidence = 0.65
|
|
340
349
|
elif density_delta > 0.2:
|
|
341
350
|
purpose = "tension"
|
|
342
351
|
confidence = 0.6
|
|
@@ -215,8 +215,21 @@ def compare_phrase_renders(
|
|
|
215
215
|
|
|
216
216
|
critiques = []
|
|
217
217
|
for path in file_paths:
|
|
218
|
-
#
|
|
219
|
-
|
|
218
|
+
# Run offline analysis per file so each render gets a real critique
|
|
219
|
+
loudness_data = None
|
|
220
|
+
spectrum_data = None
|
|
221
|
+
try:
|
|
222
|
+
from ..tools._perception_engine import compute_loudness
|
|
223
|
+
loudness_data = compute_loudness(path, detail="full")
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
logger.debug("compare_phrase_renders loudness failed for %s: %s", path, exc)
|
|
226
|
+
try:
|
|
227
|
+
from ..tools._perception_engine import compute_spectral
|
|
228
|
+
spectrum_data = compute_spectral(path)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
logger.debug("compare_phrase_renders spectral failed for %s: %s", path, exc)
|
|
231
|
+
|
|
232
|
+
critique = phrase_critic.analyze_phrase(loudness_data, spectrum_data, target)
|
|
220
233
|
critique.render_id = path.split("/")[-1] if isinstance(path, str) and "/" in path else str(path)
|
|
221
234
|
critiques.append(critique)
|
|
222
235
|
|
|
@@ -33,6 +33,35 @@ def store_preview_set(ps: PreviewSet) -> None:
|
|
|
33
33
|
del _preview_sets[oldest_key]
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
# Statuses that represent work a user (or caller) has already invested in:
|
|
37
|
+
# a compared ranking or a committed pick. A fresh request that hashes to the
|
|
38
|
+
# same set_id must NOT clobber these — it branches to a distinct id instead.
|
|
39
|
+
_PROTECTED_STATUSES = {"committed", "compared"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_set_id(base_id: str) -> str:
|
|
43
|
+
"""Return a set_id that won't clobber a protected (committed/compared) set.
|
|
44
|
+
|
|
45
|
+
The base_id is a deterministic hash of request_text + kernel_id, so a
|
|
46
|
+
re-request reuses it by design. That reuse is fine while the existing set
|
|
47
|
+
is still 'pending'/'discarded' (nothing of value to lose). But if the
|
|
48
|
+
existing set under base_id has been compared or committed, overwriting it
|
|
49
|
+
would silently drop its rankings / committed pick. In that case we branch
|
|
50
|
+
to a distinct, still-deterministic id (base_id + a monotonic suffix) so the
|
|
51
|
+
protected set survives and the new set gets its own slot.
|
|
52
|
+
"""
|
|
53
|
+
existing = _preview_sets.get(base_id)
|
|
54
|
+
if existing is None or existing.status not in _PROTECTED_STATUSES:
|
|
55
|
+
return base_id
|
|
56
|
+
suffix = 2
|
|
57
|
+
while True:
|
|
58
|
+
candidate = f"{base_id}_b{suffix}"
|
|
59
|
+
occupant = _preview_sets.get(candidate)
|
|
60
|
+
if occupant is None or occupant.status not in _PROTECTED_STATUSES:
|
|
61
|
+
return candidate
|
|
62
|
+
suffix += 1
|
|
63
|
+
|
|
64
|
+
|
|
36
65
|
# ── Creation ──────────────────────────────────────────────────────
|
|
37
66
|
|
|
38
67
|
|
|
@@ -58,7 +87,7 @@ def create_preview_set(
|
|
|
58
87
|
flags the resulting PreviewSet with ``degradation.is_degraded=True``
|
|
59
88
|
so callers can tell a synthesized compile from a real one.
|
|
60
89
|
"""
|
|
61
|
-
set_id = _compute_set_id(request_text, kernel_id)
|
|
90
|
+
set_id = _resolve_set_id(_compute_set_id(request_text, kernel_id))
|
|
62
91
|
now = int(time.time() * 1000)
|
|
63
92
|
|
|
64
93
|
moves = available_moves or []
|