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.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +92 -0
  4. package/CODE_OF_CONDUCT.md +27 -0
  5. package/CONTRIBUTING.md +131 -0
  6. package/README.md +112 -387
  7. package/SECURITY.md +48 -0
  8. package/bin/livepilot.js +42 -2
  9. package/livepilot/.Codex-plugin/plugin.json +8 -0
  10. package/livepilot/.claude-plugin/plugin.json +1 -1
  11. package/livepilot/commands/beat.md +18 -6
  12. package/livepilot/commands/sounddesign.md +6 -5
  13. package/livepilot/skills/livepilot-core/SKILL.md +36 -13
  14. package/livepilot/skills/livepilot-core/references/overview.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +11 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
  18. package/m4l_device/capture_2026_04_07_192216.wav +0 -0
  19. package/m4l_device/livepilot_bridge.js +487 -184
  20. package/mcp_server/__init__.py +1 -1
  21. package/mcp_server/connection.py +40 -1
  22. package/mcp_server/curves.py +5 -1
  23. package/mcp_server/m4l_bridge.py +93 -26
  24. package/mcp_server/server.py +26 -31
  25. package/mcp_server/tools/_perception_engine.py +26 -3
  26. package/mcp_server/tools/_theory_engine.py +36 -5
  27. package/mcp_server/tools/analyzer.py +74 -18
  28. package/mcp_server/tools/arrangement.py +20 -5
  29. package/mcp_server/tools/automation.py +56 -1
  30. package/mcp_server/tools/devices.py +201 -13
  31. package/mcp_server/tools/generative.py +8 -1
  32. package/mcp_server/tools/harmony.py +16 -3
  33. package/mcp_server/tools/midi_io.py +22 -4
  34. package/mcp_server/tools/notes.py +4 -1
  35. package/mcp_server/tools/perception.py +27 -1
  36. package/mcp_server/tools/scenes.py +4 -1
  37. package/mcp_server/tools/theory.py +23 -8
  38. package/mcp_server/tools/transport.py +16 -6
  39. package/package.json +1 -1
  40. package/remote_script/LivePilot/__init__.py +1 -1
  41. package/remote_script/LivePilot/arrangement.py +25 -134
  42. package/remote_script/LivePilot/browser.py +19 -8
  43. package/remote_script/LivePilot/clip_automation.py +5 -4
  44. package/remote_script/LivePilot/clips.py +14 -6
  45. package/remote_script/LivePilot/devices.py +6 -3
  46. package/remote_script/LivePilot/diagnostics.py +81 -5
  47. package/remote_script/LivePilot/router.py +22 -0
  48. package/remote_script/LivePilot/server.py +41 -17
  49. package/remote_script/LivePilot/tracks.py +7 -2
  50. package/remote_script/LivePilot/transport.py +3 -3
  51. package/requirements.txt +3 -1
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.9.9"
2
+ __version__ = "1.9.12"
@@ -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 = getattr(self, "_recv_buf", b"")
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
@@ -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
- while value > 1.0 or value < 0.0:
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
 
@@ -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
- # Cancel any stale capture future before creating a new one
319
- if self.receiver and self.receiver._capture_future and not self.receiver._capture_future.done():
320
- self.receiver._capture_future.cancel()
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
- try:
331
- result = await asyncio.wait_for(future, timeout=timeout)
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._capture_future = None
337
- return {"error": "M4L capture timeout — device may be busy or removed"}
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
- def cancel_capture_future(self) -> None:
340
- """Cancel any in-progress capture future (called by capture_stop)."""
341
- if self.receiver and self.receiver._capture_future and not self.receiver._capture_future.done():
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
- # Address string (null-terminated, padded to 4 bytes)
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
- s_bytes = arg.encode('ascii') + b'\x00'
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
@@ -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 _kill_port_holder(port: int) -> None:
16
- """Kill whichever process holds the given UDP port.
14
+ def _identify_port_holder(port: int) -> str | None:
15
+ """Identify which process holds the given UDP port (for logging only).
17
16
 
18
- Used to reclaim port 9880 when a stale duplicate server instance
19
- is hogging it (common when both project .mcp.json and plugin
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
- if "mcp_server" in cmdline or "livepilot" in cmdline.lower():
39
- os.kill(pid, signal.SIGTERM)
35
+ return f"{pid} ({cmdline[:60]})"
40
36
  except (subprocess.CalledProcessError, FileNotFoundError):
41
- pass # Can't verify — don't kill
37
+ return str(pid)
38
+ return None
42
39
  except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
43
- pass # lsof not found or no process — nothing to kill
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 — likely a duplicate server instance
64
- # (project .mcp.json + plugin .mcp.json both launching).
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 — reclaiming from stale instance",
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
- _kill_port_holder(9880)
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
- _coerce_schema_property(variant)
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, crest_factor_db,
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 = sample_peak_dbfs - rms_dbfs
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
- mode = parts[1].lower() if len(parts) > 1 else 'major'
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.get(mode, SCALES['major'])
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.get(mode, TRIAD_QUALITIES['major'])[degree % 7]
161
+ quality = TRIAD_QUALITIES[mode][degree % 7]
131
162
  return {
132
163
  "root_pc": root,
133
164
  "pitch_classes": [root, third, fifth],