livepilot 1.9.9 → 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 +92 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +131 -0
- package/README.md +112 -387
- package/SECURITY.md +48 -0
- package/bin/livepilot.js +42 -2
- package/livepilot/.Codex-plugin/plugin.json +8 -0
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/commands/beat.md +18 -6
- package/livepilot/commands/sounddesign.md +6 -5
- package/livepilot/skills/livepilot-core/SKILL.md +36 -13
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +11 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
- package/m4l_device/capture_2026_04_07_192216.wav +0 -0
- package/m4l_device/livepilot_bridge.js +487 -184
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +40 -1
- package/mcp_server/curves.py +5 -1
- package/mcp_server/m4l_bridge.py +93 -26
- package/mcp_server/server.py +26 -31
- package/mcp_server/tools/_perception_engine.py +26 -3
- package/mcp_server/tools/_theory_engine.py +36 -5
- package/mcp_server/tools/analyzer.py +74 -18
- package/mcp_server/tools/arrangement.py +20 -5
- package/mcp_server/tools/automation.py +56 -1
- package/mcp_server/tools/devices.py +201 -13
- package/mcp_server/tools/generative.py +8 -1
- package/mcp_server/tools/harmony.py +16 -3
- package/mcp_server/tools/midi_io.py +22 -4
- package/mcp_server/tools/notes.py +4 -1
- package/mcp_server/tools/perception.py +27 -1
- package/mcp_server/tools/scenes.py +4 -1
- package/mcp_server/tools/theory.py +23 -8
- 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 +25 -134
- 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/diagnostics.py +81 -5
- package/remote_script/LivePilot/router.py +22 -0
- package/remote_script/LivePilot/server.py +41 -17
- package/remote_script/LivePilot/tracks.py +7 -2
- package/remote_script/LivePilot/transport.py +3 -3
- package/requirements.txt +3 -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
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import socket
|
|
8
|
+
import subprocess
|
|
8
9
|
import threading
|
|
9
10
|
import time
|
|
10
11
|
import uuid
|
|
@@ -44,6 +45,33 @@ def _friendly_error(code: str, message: str, command_type: str) -> str:
|
|
|
44
45
|
return " ".join(parts)
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _identify_other_tcp_client(host: str, port: int) -> str | None:
|
|
49
|
+
"""Return a short description of another established client on the Live port."""
|
|
50
|
+
try:
|
|
51
|
+
out = subprocess.check_output(
|
|
52
|
+
["lsof", "-nP", f"-iTCP:{port}"],
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=3,
|
|
55
|
+
)
|
|
56
|
+
except (subprocess.CalledProcessError, FileNotFoundError, ValueError, OSError):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
target = f"->{host}:{port}"
|
|
60
|
+
my_pid = os.getpid()
|
|
61
|
+
for line in out.splitlines()[1:]:
|
|
62
|
+
parts = line.split()
|
|
63
|
+
if len(parts) < 2 or target not in line or "(ESTABLISHED)" not in line:
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
pid = int(parts[1])
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
if pid == my_pid:
|
|
70
|
+
continue
|
|
71
|
+
return f"PID {pid} ({parts[0]})"
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
47
75
|
class AbletonConnection:
|
|
48
76
|
"""TCP client that sends JSON commands to the LivePilot Remote Script."""
|
|
49
77
|
|
|
@@ -185,7 +213,7 @@ class AbletonConnection:
|
|
|
185
213
|
raise AbletonConnectionError(f"Failed to send command: {exc}") from exc
|
|
186
214
|
|
|
187
215
|
# Read until newline, preserving any trailing bytes in _recv_buf
|
|
188
|
-
buf =
|
|
216
|
+
buf = self._recv_buf
|
|
189
217
|
try:
|
|
190
218
|
while b"\n" not in buf:
|
|
191
219
|
chunk = self._socket.recv(4096)
|
|
@@ -194,9 +222,20 @@ class AbletonConnection:
|
|
|
194
222
|
self.disconnect()
|
|
195
223
|
raise AbletonConnectionError("Connection closed by Ableton")
|
|
196
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)")
|
|
197
229
|
except socket.timeout as exc:
|
|
198
230
|
self._recv_buf = buf
|
|
199
231
|
self.disconnect()
|
|
232
|
+
other_client = _identify_other_tcp_client(self.host, self.port)
|
|
233
|
+
if other_client:
|
|
234
|
+
raise AbletonConnectionError(
|
|
235
|
+
"Timeout waiting for response from Ableton. "
|
|
236
|
+
f"Another LivePilot client appears to be connected on {self.host}:{self.port} "
|
|
237
|
+
f"({other_client}). Disconnect the other client and retry."
|
|
238
|
+
) from exc
|
|
200
239
|
raise AbletonConnectionError(
|
|
201
240
|
f"Timeout waiting for response from Ableton ({RECV_TIMEOUT}s)"
|
|
202
241
|
) from exc
|
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
|
@@ -21,6 +21,50 @@ import time
|
|
|
21
21
|
from typing import Any, Optional
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _encode_string_arg(value: str) -> str:
|
|
25
|
+
"""Encode a Python string arg into an ASCII-safe OSC payload.
|
|
26
|
+
|
|
27
|
+
The Max JS side decodes values with the ``b64:`` prefix back to UTF-8.
|
|
28
|
+
Keeping the wire payload ASCII-only avoids OSC/client issues with
|
|
29
|
+
non-ASCII file paths and device names.
|
|
30
|
+
"""
|
|
31
|
+
encoded = base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii")
|
|
32
|
+
return "b64:" + encoded.rstrip("=")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_macos_path(path: str) -> str:
|
|
36
|
+
"""Convert Max-style HFS-ish paths into POSIX paths when possible."""
|
|
37
|
+
if len(path) >= 3 and path[1] == ":" and path[2] in ("/", "\\"):
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
colon = path.find(":")
|
|
41
|
+
slash = path.find("/")
|
|
42
|
+
if colon <= 0 or (slash != -1 and colon > slash):
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
rest = path[colon + 1:]
|
|
46
|
+
if ":" in rest:
|
|
47
|
+
rest = rest.replace(":", "/")
|
|
48
|
+
if not rest.startswith("/"):
|
|
49
|
+
rest = "/" + rest.lstrip("/\\")
|
|
50
|
+
return rest
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize_bridge_payload(value: Any) -> Any:
|
|
54
|
+
"""Normalize filesystem paths inside bridge payloads."""
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
normalized = {}
|
|
57
|
+
for key, item in value.items():
|
|
58
|
+
if key == "file_path" and isinstance(item, str):
|
|
59
|
+
normalized[key] = _normalize_macos_path(item)
|
|
60
|
+
else:
|
|
61
|
+
normalized[key] = _normalize_bridge_payload(item)
|
|
62
|
+
return normalized
|
|
63
|
+
if isinstance(value, list):
|
|
64
|
+
return [_normalize_bridge_payload(item) for item in value]
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
24
68
|
class SpectralCache:
|
|
25
69
|
"""Thread-safe cache for incoming spectral data from M4L.
|
|
26
70
|
|
|
@@ -97,6 +141,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
97
141
|
def __init__(self, cache: SpectralCache):
|
|
98
142
|
self.cache = cache
|
|
99
143
|
self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
|
|
144
|
+
self._chunk_times: dict[str, float] = {} # Monotonic timestamp per chunk sequence
|
|
100
145
|
self._chunk_id = 0
|
|
101
146
|
self._response_callback: Optional[asyncio.Future] = None
|
|
102
147
|
self._capture_future: Optional[asyncio.Future] = None
|
|
@@ -226,7 +271,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
226
271
|
# URL-safe base64 decode (- and _ instead of + and /)
|
|
227
272
|
padded = encoded + "=" * (-len(encoded) % 4)
|
|
228
273
|
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
|
|
229
|
-
result = json.loads(decoded)
|
|
274
|
+
result = _normalize_bridge_payload(json.loads(decoded))
|
|
230
275
|
if self._response_callback and not self._response_callback.done():
|
|
231
276
|
self._response_callback.set_result(result)
|
|
232
277
|
except Exception as exc:
|
|
@@ -240,6 +285,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
240
285
|
key = str(self._chunk_id)
|
|
241
286
|
if key not in self._chunks:
|
|
242
287
|
self._chunks[key] = {"parts": {}, "total": total}
|
|
288
|
+
self._chunk_times[key] = time.monotonic()
|
|
243
289
|
|
|
244
290
|
self._chunks[key]["parts"][index] = encoded
|
|
245
291
|
|
|
@@ -249,14 +295,22 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
249
295
|
for i in range(total):
|
|
250
296
|
full += self._chunks[key]["parts"][i]
|
|
251
297
|
del self._chunks[key]
|
|
298
|
+
self._chunk_times.pop(key, None)
|
|
252
299
|
self._handle_response(full)
|
|
253
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
|
+
|
|
254
308
|
def _handle_capture_complete(self, encoded: str) -> None:
|
|
255
309
|
"""Decode a /capture_complete OSC message and resolve _capture_future."""
|
|
256
310
|
try:
|
|
257
311
|
padded = encoded + "=" * (-len(encoded) % 4)
|
|
258
312
|
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
|
|
259
|
-
result = json.loads(decoded)
|
|
313
|
+
result = _normalize_bridge_payload(json.loads(decoded))
|
|
260
314
|
if self._capture_future and not self._capture_future.done():
|
|
261
315
|
self._capture_future.set_result(result)
|
|
262
316
|
except Exception as exc:
|
|
@@ -315,36 +369,48 @@ class M4LBridge:
|
|
|
315
369
|
if not self.cache.is_connected:
|
|
316
370
|
return {"error": "LivePilot Analyzer not connected. Drop it on the master track."}
|
|
317
371
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
self.receiver._capture_future.
|
|
321
|
-
|
|
322
|
-
loop = asyncio.get_running_loop()
|
|
323
|
-
future = loop.create_future()
|
|
324
|
-
if self.receiver:
|
|
325
|
-
self.receiver.set_capture_future(future)
|
|
326
|
-
|
|
327
|
-
osc_data = self._build_osc(command, args)
|
|
328
|
-
self._sock.sendto(osc_data, self._m4l_addr)
|
|
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()
|
|
329
376
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
return result
|
|
333
|
-
except asyncio.TimeoutError:
|
|
334
|
-
# Clean up the dangling future
|
|
377
|
+
loop = asyncio.get_running_loop()
|
|
378
|
+
future = loop.create_future()
|
|
335
379
|
if self.receiver:
|
|
336
|
-
self.receiver.
|
|
337
|
-
|
|
380
|
+
self.receiver.set_capture_future(future)
|
|
381
|
+
|
|
382
|
+
osc_data = self._build_osc(command, args)
|
|
383
|
+
self._sock.sendto(osc_data, self._m4l_addr)
|
|
338
384
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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"}
|
|
393
|
+
|
|
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():
|
|
342
403
|
self.receiver._capture_future.cancel()
|
|
343
404
|
self.receiver._capture_future = None
|
|
344
405
|
|
|
345
406
|
def _build_osc(self, address: str, args: tuple) -> bytes:
|
|
346
|
-
"""Build a minimal OSC message.
|
|
347
|
-
|
|
407
|
+
"""Build a minimal OSC message.
|
|
408
|
+
|
|
409
|
+
OSC addresses are ASCII-only command names.
|
|
410
|
+
String arguments are encoded into an ASCII-safe ``b64:...`` payload
|
|
411
|
+
and decoded back to UTF-8 in the Max bridge.
|
|
412
|
+
"""
|
|
413
|
+
# Address string — always ASCII (command names like "get_params")
|
|
348
414
|
addr_bytes = address.encode('ascii') + b'\x00'
|
|
349
415
|
while len(addr_bytes) % 4 != 0:
|
|
350
416
|
addr_bytes += b'\x00'
|
|
@@ -361,7 +427,8 @@ class M4LBridge:
|
|
|
361
427
|
arg_data += struct.pack('>f', arg)
|
|
362
428
|
elif isinstance(arg, str):
|
|
363
429
|
type_tags += "s"
|
|
364
|
-
|
|
430
|
+
encoded = _encode_string_arg(arg)
|
|
431
|
+
s_bytes = encoded.encode('ascii') + b'\x00'
|
|
365
432
|
while len(s_bytes) % 4 != 0:
|
|
366
433
|
s_bytes += b'\x00'
|
|
367
434
|
arg_data += s_bytes
|
package/mcp_server/server.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from contextlib import asynccontextmanager
|
|
4
4
|
import asyncio
|
|
5
5
|
import os
|
|
6
|
-
import signal
|
|
7
6
|
import subprocess
|
|
8
7
|
|
|
9
8
|
from fastmcp import FastMCP, Context # noqa: F401
|
|
@@ -12,12 +11,11 @@ from .connection import AbletonConnection
|
|
|
12
11
|
from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge
|
|
13
12
|
|
|
14
13
|
|
|
15
|
-
def
|
|
16
|
-
"""
|
|
14
|
+
def _identify_port_holder(port: int) -> str | None:
|
|
15
|
+
"""Identify which process holds the given UDP port (for logging only).
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
.mcp.json launch the server).
|
|
17
|
+
Returns a string like "PID 12345 (python3 mcp_server)" or None if
|
|
18
|
+
identification fails. Never kills or modifies the holder.
|
|
21
19
|
"""
|
|
22
20
|
try:
|
|
23
21
|
out = subprocess.check_output(
|
|
@@ -29,18 +27,17 @@ def _kill_port_holder(port: int) -> None:
|
|
|
29
27
|
for pid_str in out.splitlines():
|
|
30
28
|
pid = int(pid_str)
|
|
31
29
|
if pid != my_pid:
|
|
32
|
-
# Only kill if it looks like a Python/LivePilot process
|
|
33
30
|
try:
|
|
34
31
|
cmdline = subprocess.check_output(
|
|
35
32
|
["ps", "-p", str(pid), "-o", "command="],
|
|
36
33
|
text=True, timeout=2,
|
|
37
34
|
).strip()
|
|
38
|
-
|
|
39
|
-
os.kill(pid, signal.SIGTERM)
|
|
35
|
+
return f"{pid} ({cmdline[:60]})"
|
|
40
36
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
41
|
-
|
|
37
|
+
return str(pid)
|
|
38
|
+
return None
|
|
42
39
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
|
43
|
-
|
|
40
|
+
return None
|
|
44
41
|
|
|
45
42
|
|
|
46
43
|
@asynccontextmanager
|
|
@@ -60,28 +57,18 @@ async def lifespan(server):
|
|
|
60
57
|
local_addr=('127.0.0.1', 9880),
|
|
61
58
|
)
|
|
62
59
|
except OSError:
|
|
63
|
-
# Port 9880 already bound —
|
|
64
|
-
#
|
|
65
|
-
# Kill the stale holder so this instance gets the port.
|
|
60
|
+
# Port 9880 already bound — another LivePilot instance is running.
|
|
61
|
+
# Do NOT kill the holder; degrade gracefully instead.
|
|
66
62
|
import sys
|
|
63
|
+
holder_info = _identify_port_holder(9880)
|
|
67
64
|
print(
|
|
68
|
-
"LivePilot: UDP port 9880 in use —
|
|
65
|
+
"LivePilot: UDP port 9880 already in use%s — "
|
|
66
|
+
"analyzer/bridge tools will be unavailable. "
|
|
67
|
+
"Stop the other LivePilot instance to enable them."
|
|
68
|
+
% (f" (PID {holder_info})" if holder_info else ""),
|
|
69
69
|
file=sys.stderr,
|
|
70
70
|
)
|
|
71
|
-
|
|
72
|
-
await asyncio.sleep(0.3)
|
|
73
|
-
try:
|
|
74
|
-
transport, _ = await loop.create_datagram_endpoint(
|
|
75
|
-
lambda: receiver,
|
|
76
|
-
local_addr=('127.0.0.1', 9880),
|
|
77
|
-
)
|
|
78
|
-
except OSError:
|
|
79
|
-
print(
|
|
80
|
-
"LivePilot: WARNING — could not bind UDP 9880, "
|
|
81
|
-
"analyzer tools will be unavailable",
|
|
82
|
-
file=sys.stderr,
|
|
83
|
-
)
|
|
84
|
-
transport = None
|
|
71
|
+
transport = None
|
|
85
72
|
|
|
86
73
|
try:
|
|
87
74
|
yield {
|
|
@@ -132,12 +119,20 @@ from .tools import perception # noqa: F401, E402
|
|
|
132
119
|
|
|
133
120
|
def _coerce_schema_property(prop: dict) -> None:
|
|
134
121
|
"""Widen a single JSON Schema property to also accept strings."""
|
|
135
|
-
if prop.get("type") in ("integer", "number"):
|
|
122
|
+
if prop.get("type") in ("integer", "number") and "anyOf" not in prop:
|
|
136
123
|
original_type = prop.pop("type")
|
|
137
124
|
prop["anyOf"] = [{"type": original_type}, {"type": "string"}]
|
|
138
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
|
|
139
130
|
for variant in prop["anyOf"]:
|
|
140
|
-
|
|
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"])
|
|
141
136
|
|
|
142
137
|
|
|
143
138
|
def _get_all_tools():
|
|
@@ -77,6 +77,25 @@ def _normalize_to_lufs(
|
|
|
77
77
|
return tmp_path
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# True-peak helper
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _true_peak_dbtp(data: np.ndarray, sr: int) -> float:
|
|
85
|
+
"""Estimate EBU R128 true peak via 4x oversampling.
|
|
86
|
+
|
|
87
|
+
Uses scipy's resample_poly for phase-accurate upsampling,
|
|
88
|
+
then measures the absolute peak of the oversampled signal.
|
|
89
|
+
Returns the result in dBTP (decibels relative to true peak).
|
|
90
|
+
"""
|
|
91
|
+
from scipy.signal import resample_poly
|
|
92
|
+
|
|
93
|
+
# 4x oversample each channel independently
|
|
94
|
+
oversampled = resample_poly(data, up=4, down=1, axis=0)
|
|
95
|
+
peak_linear = float(np.max(np.abs(oversampled)))
|
|
96
|
+
return float(20.0 * np.log10(max(peak_linear, 1e-10)))
|
|
97
|
+
|
|
98
|
+
|
|
80
99
|
# ---------------------------------------------------------------------------
|
|
81
100
|
# compute_loudness
|
|
82
101
|
# ---------------------------------------------------------------------------
|
|
@@ -89,8 +108,8 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
|
89
108
|
detail: "summary" (default) or "full" (includes short_term_lufs array).
|
|
90
109
|
|
|
91
110
|
Returns:
|
|
92
|
-
dict with integrated_lufs, sample_peak_dbfs, rms_dbfs,
|
|
93
|
-
lra_lu, meets_streaming, and optionally short_term_lufs.
|
|
111
|
+
dict with integrated_lufs, true_peak_dbtp, sample_peak_dbfs, rms_dbfs,
|
|
112
|
+
crest_factor_db, lra_lu, meets_streaming, and optionally short_term_lufs.
|
|
94
113
|
"""
|
|
95
114
|
import pyloudnorm as pyln
|
|
96
115
|
|
|
@@ -110,12 +129,15 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
|
110
129
|
peak_linear = float(np.max(np.abs(data)))
|
|
111
130
|
sample_peak_dbfs = float(20.0 * np.log10(max(peak_linear, 1e-10)))
|
|
112
131
|
|
|
132
|
+
# True peak via 4x oversampling (EBU R128 compliant)
|
|
133
|
+
true_peak_dbtp = _true_peak_dbtp(data, sr)
|
|
134
|
+
|
|
113
135
|
# RMS dBFS
|
|
114
136
|
rms_linear = float(np.sqrt(np.mean(data ** 2)))
|
|
115
137
|
rms_dbfs = float(20.0 * np.log10(max(rms_linear, 1e-10)))
|
|
116
138
|
|
|
117
139
|
# Crest factor
|
|
118
|
-
crest_factor_db =
|
|
140
|
+
crest_factor_db = true_peak_dbtp - rms_dbfs
|
|
119
141
|
|
|
120
142
|
# Short-term LUFS (3s window, 1s hop) — also used for LRA
|
|
121
143
|
window_samples = int(sr * 3.0)
|
|
@@ -162,6 +184,7 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
|
162
184
|
|
|
163
185
|
result: dict[str, Any] = {
|
|
164
186
|
"integrated_lufs": round(integrated_lufs, 2),
|
|
187
|
+
"true_peak_dbtp": round(true_peak_dbtp, 2),
|
|
165
188
|
"sample_peak_dbfs": round(sample_peak_dbfs, 2),
|
|
166
189
|
"rms_dbfs": round(rms_dbfs, 2),
|
|
167
190
|
"crest_factor_db": round(crest_factor_db, 2),
|
|
@@ -35,19 +35,20 @@ PHRYGIAN_PROFILE = MAJOR_PROFILE[4:] + MAJOR_PROFILE[:4]
|
|
|
35
35
|
LYDIAN_PROFILE = MAJOR_PROFILE[5:] + MAJOR_PROFILE[:5]
|
|
36
36
|
MIXOLYDIAN_PROFILE = MAJOR_PROFILE[7:] + MAJOR_PROFILE[:7]
|
|
37
37
|
LOCRIAN_PROFILE = MAJOR_PROFILE[11:] + MAJOR_PROFILE[:11]
|
|
38
|
+
PHRYGIAN_DOMINANT_PROFILE = [6.35, 4.75, 1.75, 1.95, 4.9, 3.9, 1.8, 5.2, 3.6, 1.7, 3.4, 1.6]
|
|
38
39
|
|
|
39
40
|
MODE_PROFILES = {
|
|
40
41
|
'major': MAJOR_PROFILE, 'minor': MINOR_PROFILE,
|
|
41
42
|
'dorian': DORIAN_PROFILE, 'phrygian': PHRYGIAN_PROFILE,
|
|
42
43
|
'lydian': LYDIAN_PROFILE, 'mixolydian': MIXOLYDIAN_PROFILE,
|
|
43
|
-
'locrian': LOCRIAN_PROFILE,
|
|
44
|
+
'locrian': LOCRIAN_PROFILE, 'phrygian_dominant': PHRYGIAN_DOMINANT_PROFILE,
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
SCALES = {
|
|
47
48
|
'major': [0, 2, 4, 5, 7, 9, 11], 'minor': [0, 2, 3, 5, 7, 8, 10],
|
|
48
49
|
'dorian': [0, 2, 3, 5, 7, 9, 10], 'phrygian': [0, 1, 3, 5, 7, 8, 10],
|
|
49
50
|
'lydian': [0, 2, 4, 6, 7, 9, 11], 'mixolydian': [0, 2, 4, 5, 7, 9, 10],
|
|
50
|
-
'locrian': [0, 1, 3, 5, 6, 8, 10],
|
|
51
|
+
'locrian': [0, 1, 3, 5, 6, 8, 10], 'phrygian_dominant': [0, 1, 4, 5, 7, 8, 10],
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
TRIAD_QUALITIES = {
|
|
@@ -58,6 +59,25 @@ TRIAD_QUALITIES = {
|
|
|
58
59
|
'lydian': ['major', 'major', 'minor', 'diminished', 'major', 'minor', 'minor'],
|
|
59
60
|
'mixolydian': ['major', 'minor', 'diminished', 'major', 'minor', 'minor', 'major'],
|
|
60
61
|
'locrian': ['diminished', 'major', 'minor', 'minor', 'major', 'major', 'minor'],
|
|
62
|
+
'phrygian_dominant': ['major', 'major', 'diminished', 'minor', 'diminished', 'augmented', 'minor'],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
MODE_ALIASES = {
|
|
66
|
+
'major': 'major',
|
|
67
|
+
'ionian': 'major',
|
|
68
|
+
'maj': 'major',
|
|
69
|
+
'minor': 'minor',
|
|
70
|
+
'aeolian': 'minor',
|
|
71
|
+
'min': 'minor',
|
|
72
|
+
'dorian': 'dorian',
|
|
73
|
+
'phrygian': 'phrygian',
|
|
74
|
+
'lydian': 'lydian',
|
|
75
|
+
'mixolydian': 'mixolydian',
|
|
76
|
+
'locrian': 'locrian',
|
|
77
|
+
'phrygian dominant': 'phrygian_dominant',
|
|
78
|
+
'phrygian_dominant': 'phrygian_dominant',
|
|
79
|
+
'phrygian-dominant': 'phrygian_dominant',
|
|
80
|
+
'hijaz': 'phrygian_dominant',
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
CHORD_PATTERNS = {
|
|
@@ -81,6 +101,15 @@ def pitch_name(midi: int) -> str:
|
|
|
81
101
|
return NOTE_NAMES[midi % 12] + str(midi // 12 - 1)
|
|
82
102
|
|
|
83
103
|
|
|
104
|
+
def _normalize_mode_name(mode: str) -> str:
|
|
105
|
+
"""Normalize user-facing mode names into canonical scale ids."""
|
|
106
|
+
normalized = " ".join(mode.strip().lower().replace("_", " ").replace("-", " ").split())
|
|
107
|
+
canonical = MODE_ALIASES.get(normalized, normalized.replace(" ", "_"))
|
|
108
|
+
if canonical not in SCALES:
|
|
109
|
+
raise ValueError(f"Unknown mode: {mode}")
|
|
110
|
+
return canonical
|
|
111
|
+
|
|
112
|
+
|
|
84
113
|
def parse_key(key_str: str) -> dict:
|
|
85
114
|
"""Parse key string -> {tonic: 0-11, tonic_name: str, mode: str}.
|
|
86
115
|
|
|
@@ -100,7 +129,8 @@ def parse_key(key_str: str) -> dict:
|
|
|
100
129
|
|
|
101
130
|
parts = s.split()
|
|
102
131
|
raw_tonic = parts[0]
|
|
103
|
-
|
|
132
|
+
raw_mode = " ".join(parts[1:]) if len(parts) > 1 else 'major'
|
|
133
|
+
mode = _normalize_mode_name(raw_mode)
|
|
104
134
|
|
|
105
135
|
# Normalize tonic: capitalize first letter
|
|
106
136
|
tonic_name = raw_tonic[0].upper() + raw_tonic[1:]
|
|
@@ -117,17 +147,18 @@ def parse_key(key_str: str) -> dict:
|
|
|
117
147
|
|
|
118
148
|
def get_scale_pitches(tonic: int, mode: str) -> list[int]:
|
|
119
149
|
"""Return pitch classes (0-11) for the scale."""
|
|
120
|
-
intervals = SCALES
|
|
150
|
+
intervals = SCALES[_normalize_mode_name(mode)]
|
|
121
151
|
return [(tonic + iv) % 12 for iv in intervals]
|
|
122
152
|
|
|
123
153
|
|
|
124
154
|
def build_chord(degree: int, tonic: int, mode: str) -> dict:
|
|
125
155
|
"""Build triad from scale degree (0-indexed)."""
|
|
156
|
+
mode = _normalize_mode_name(mode)
|
|
126
157
|
scale = get_scale_pitches(tonic, mode)
|
|
127
158
|
root = scale[degree % 7]
|
|
128
159
|
third = scale[(degree + 2) % 7]
|
|
129
160
|
fifth = scale[(degree + 4) % 7]
|
|
130
|
-
quality = TRIAD_QUALITIES
|
|
161
|
+
quality = TRIAD_QUALITIES[mode][degree % 7]
|
|
131
162
|
return {
|
|
132
163
|
"root_pc": root,
|
|
133
164
|
"pitch_classes": [root, third, fifth],
|