livepilot 1.9.18 → 1.9.19

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3
3
  "name": "dreamrec-LivePilot",
4
- "description": "Agentic MCP production system for Ableton Live 12 — 236 tools, 31 domains",
4
+ "description": "Agentic MCP production system for Ableton Live 12 — 236 tools, 32 domains",
5
5
  "owner": {
6
6
  "name": "dreamrec",
7
7
  "email": "dreamrec@users.noreply.github.com"
@@ -9,8 +9,8 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "livepilot",
12
- "description": "Agentic production system for Ableton Live 12 — 236 tools, 31 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
13
- "version": "1.9.18",
12
+ "description": "Agentic production system for Ableton Live 12 — 236 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
13
+ "version": "1.9.19",
14
14
  "author": {
15
15
  "name": "Pilot Studio"
16
16
  },
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.19 — Theory Engine & Meters Fix Pass (April 2026)
4
+
5
+ ### Bug Fixes
6
+ - **fix(mixing):** `get_track_meters` crashed on tracks with MIDI-only output — now guards `output_meter_*` with `has_audio_output` check
7
+ - **fix(mixing):** `get_mix_snapshot` same crash on MIDI-output tracks — same guard applied
8
+ - **fix(tracks):** `create_midi_track` / `create_audio_track` left newly created tracks armed — now auto-disarms unless `arm=true` param is passed
9
+ - **fix(theory):** `roman_numeral()` failed to recognize 7th chords (Dm7, Gm7, Bbmaj7) — now detects 7th intervals via triad-subset matching with scored best-match selection
10
+ - **fix(theory):** `roman_figure_to_pitches()` produced out-of-key pitches (C#, G#) for jazz figures in minor keys — now uses scale-derived chord quality and scale-derived 7th intervals instead of forcing quality from Roman numeral case
11
+ - **fix(harmony):** `parse_chord()` rejected "minor seventh", "dominant seventh" and other extended chord qualities — now normalizes to base triad for neo-Riemannian analysis
12
+ - **fix(harmony):** `classify_transform_sequence()` only detected single P/L/R transforms — now tries 2-step compound transforms (PL, PR, RL, etc.)
13
+ - **fix(theory):** `roman_numeral()` picked wrong chord when 7th created ambiguity (Bbmaj7 matched as Dm instead of Bb) — scoring prefers highest overlap + root-position bonus
14
+
3
15
  ## 1.9.18 — Deep Audit Fix Pass (April 2026)
4
16
 
5
17
  ### Critical Fixes
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.18",
4
- "description": "Agentic production system for Ableton Live 12 — 236 tools, 31 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
3
+ "version": "1.9.19",
4
+ "description": "Agentic production system for Ableton Live 12 — 236 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
5
5
  "author": {
6
6
  "name": "Pilot Studio"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.18",
4
- "description": "Agentic production system for Ableton Live 12 — 236 tools, 31 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
3
+ "version": "1.9.19",
4
+ "description": "Agentic production system for Ableton Live 12 — 236 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
5
5
  "author": {
6
6
  "name": "Pilot Studio"
7
7
  }
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: livepilot-core
3
- description: Core discipline for LivePilot — agentic production system for Ableton Live 12. 236 tools across 31 domains. This skill should be used whenever working with Ableton Live through MCP tools. Provides golden rules, tool speed tiers, error handling protocol, and pointers to domain and engine skills.
3
+ description: Core discipline for LivePilot — agentic production system for Ableton Live 12. 236 tools across 32 domains. This skill should be used whenever working with Ableton Live through MCP tools. Provides golden rules, tool speed tiers, error handling protocol, and pointers to domain and engine skills.
4
4
  ---
5
5
 
6
6
  # LivePilot Core — Ableton Live 12
7
7
 
8
- Agentic production system for Ableton Live 12. 236 tools across 31 domains, three layers:
8
+ Agentic production system for Ableton Live 12. 236 tools across 32 domains, three layers:
9
9
 
10
10
  - **Device Atlas** — 280+ instruments, 139 drum kits, 350+ impulse responses. Consult `references/device-atlas/` before loading any device. Never guess a device name.
11
11
  - **M4L Analyzer** — Real-time audio analysis on the master bus (8-band spectrum, RMS/peak, key detection). Optional — all core tools work without it.
@@ -1,6 +1,6 @@
1
- # LivePilot v1.9.18 — Architecture & Tool Reference
1
+ # LivePilot v1.9.19 — Architecture & Tool Reference
2
2
 
3
- Agentic production system for Ableton Live 12. 236 tools across 31 domains. Device atlas (280+ devices), spectral perception (M4L analyzer), technique memory, automation intelligence (16 curve types, 15 recipes), music theory (Krumhansl-Schmuckler, species counterpoint), generative algorithms (Euclidean rhythm, tintinnabuli, phase shift, additive process), neo-Riemannian harmony (PRL transforms, Tonnetz), MIDI file I/O.
3
+ Agentic production system for Ableton Live 12. 236 tools across 32 domains. Device atlas (280+ devices), spectral perception (M4L analyzer), technique memory, automation intelligence (16 curve types, 15 recipes), music theory (Krumhansl-Schmuckler, species counterpoint), generative algorithms (Euclidean rhythm, tintinnabuli, phase shift, additive process), neo-Riemannian harmony (PRL transforms, Tonnetz), MIDI file I/O.
4
4
 
5
5
  ## Architecture
6
6
 
@@ -84,7 +84,7 @@ function anything() {
84
84
  function dispatch(cmd, args) {
85
85
  switch(cmd) {
86
86
  case "ping":
87
- send_response({"ok": true, "version": "1.9.18"});
87
+ send_response({"ok": true, "version": "1.9.19"});
88
88
  break;
89
89
  case "get_params":
90
90
  cmd_get_params(args);
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.9.18"
2
+ __version__ = "1.9.19"
@@ -29,8 +29,26 @@ def chord_to_str(root_pc: int, quality: str) -> str:
29
29
  def parse_chord(chord_str: str) -> tuple[int, str]:
30
30
  """Parse 'C major' → (0, 'major'), 'F# minor' → (6, 'minor').
31
31
 
32
- Uses _theory_engine.parse_key() internally check what it returns!
32
+ Also handles 7th chord qualities by reducing to base triad:
33
+ 'D minor seventh' → (2, 'minor'), 'G dominant seventh' → (7, 'major').
34
+ Neo-Riemannian transforms operate on triads, so we strip extensions.
33
35
  """
36
+ # Normalize extended chord quality names to base triad
37
+ s = chord_str.strip()
38
+ _QUALITY_MAP = {
39
+ "minor seventh": "minor", "minor 7th": "minor", "minor7": "minor",
40
+ "major seventh": "major", "major 7th": "major", "major7": "major",
41
+ "dominant seventh": "major", "dominant 7th": "major", "dominant7": "major",
42
+ "diminished seventh": "minor", "diminished 7th": "minor",
43
+ "half-diminished seventh": "minor", "half-diminished": "minor",
44
+ }
45
+ for ext, base in _QUALITY_MAP.items():
46
+ if ext in s.lower():
47
+ # Extract root (everything before the quality)
48
+ idx = s.lower().index(ext)
49
+ root = s[:idx].strip() or s.split()[0]
50
+ return (engine.parse_key(f"{root} {base}")["tonic"], base)
51
+
34
52
  parsed = engine.parse_key(chord_str)
35
53
  mode = parsed["mode"]
36
54
  if mode not in ("major", "minor"):
@@ -177,14 +195,30 @@ def find_shortest_path(
177
195
  # ---------------------------------------------------------------------------
178
196
 
179
197
  def classify_transform_sequence(chords: list[tuple[int, str]]) -> list[str]:
180
- """Identify the PRL transform between each consecutive pair of chords."""
198
+ """Identify the PRL transform between each consecutive pair of chords.
199
+
200
+ Tries single transforms (P, L, R) first, then 2-step compound
201
+ transforms (PL, PR, LP, LR, RP, RL) for richer classification.
202
+ """
203
+ _COMPOUNDS = ["PL", "PR", "LP", "LR", "RP", "RL",
204
+ "PP", "LL", "RR"]
181
205
  result = []
182
206
  for i in range(len(chords) - 1):
183
207
  found = "?"
208
+ # Try single transforms first
184
209
  for label, fn in TRANSFORMS.items():
185
210
  if fn(*chords[i]) == chords[i + 1]:
186
211
  found = label
187
212
  break
213
+ # Try 2-step compound transforms
214
+ if found == "?":
215
+ for compound in _COMPOUNDS:
216
+ try:
217
+ if apply_transforms(*chords[i], compound) == chords[i + 1]:
218
+ found = compound
219
+ break
220
+ except (ValueError, KeyError):
221
+ continue
188
222
  result.append(found)
189
223
  return result
190
224
 
@@ -231,33 +231,73 @@ def chord_name(midi_pitches: list[int]) -> str:
231
231
 
232
232
 
233
233
  def roman_numeral(chord_pcs: list[int], tonic: int, mode: str) -> dict:
234
- """Match chord pitch classes -> Roman numeral figure."""
234
+ """Match chord pitch classes -> Roman numeral figure.
235
+
236
+ Recognizes triads and 7th chords by checking if the input contains
237
+ a scale-degree triad, then detecting the 7th (if any).
238
+ """
235
239
  pcs_set = set(pc % 12 for pc in chord_pcs)
236
240
  bass_pc = chord_pcs[0] % 12 if chord_pcs else 0
237
241
 
238
242
  best = {"figure": "?", "quality": "unknown", "degree": 0,
239
243
  "inversion": 0, "root_name": NOTE_NAMES[tonic]}
244
+ best_score = -1
240
245
 
241
246
  for degree in range(7):
242
247
  triad = build_chord(degree, tonic, mode)
243
248
  triad_set = set(triad["pitch_classes"])
244
- if pcs_set == triad_set or pcs_set.issubset(triad_set):
245
- quality = triad["quality"]
246
- label = ROMAN_LABELS[degree]
247
- if quality in ("minor", "diminished"):
248
- label = label.lower()
249
- if quality == "diminished":
250
- label += "\u00b0"
251
- # Detect inversion
252
- inv = 0
253
- if bass_pc != triad["root_pc"]:
254
- if bass_pc == triad["pitch_classes"][1]:
255
- inv = 1
256
- elif bass_pc == triad["pitch_classes"][2]:
257
- inv = 2
258
- best = {"figure": label, "quality": quality, "degree": degree,
259
- "inversion": inv, "root_name": triad["root_name"]}
260
- break
249
+ # Match: exact triad, triad is subset of input (7th chord),
250
+ # or input is subset of triad (power chord / omitted note)
251
+ if not (pcs_set == triad_set or triad_set.issubset(pcs_set)
252
+ or pcs_set.issubset(triad_set)):
253
+ continue
254
+
255
+ # Score: prefer matches with more overlap and bass-note match
256
+ overlap = len(pcs_set & triad_set)
257
+ score = overlap * 10
258
+ if bass_pc == triad["root_pc"]:
259
+ score += 5 # root position bonus
260
+
261
+ if score <= best_score:
262
+ continue
263
+
264
+ quality = triad["quality"]
265
+ label = ROMAN_LABELS[degree]
266
+ if quality in ("minor", "diminished"):
267
+ label = label.lower()
268
+ if quality == "diminished":
269
+ label += "\u00b0"
270
+
271
+ # Detect 7th: extra pitch class beyond the triad
272
+ extra_pcs = pcs_set - triad_set
273
+ if extra_pcs:
274
+ seventh_interval = (list(extra_pcs)[0] - triad["root_pc"]) % 12
275
+ if seventh_interval == 10: # minor/dominant 7th
276
+ label += "7"
277
+ if quality == "diminished":
278
+ quality = "half-diminished seventh"
279
+ elif quality == "minor":
280
+ quality = "minor seventh"
281
+ else:
282
+ quality = "dominant seventh"
283
+ elif seventh_interval == 11: # major 7th
284
+ label += "maj7"
285
+ quality = "major seventh"
286
+ elif seventh_interval == 9: # diminished 7th
287
+ label += "o7"
288
+ quality = "diminished seventh"
289
+
290
+ # Detect inversion
291
+ inv = 0
292
+ if bass_pc != triad["root_pc"]:
293
+ if bass_pc == triad["pitch_classes"][1]:
294
+ inv = 1
295
+ elif bass_pc == triad["pitch_classes"][2]:
296
+ inv = 2
297
+
298
+ best = {"figure": label, "quality": quality, "degree": degree,
299
+ "inversion": inv, "root_name": triad["root_name"]}
300
+ best_score = score
261
301
 
262
302
  return best
263
303
 
@@ -302,31 +342,48 @@ def roman_figure_to_pitches(figure: str, tonic: int, mode: str) -> dict:
302
342
  chord = build_chord(degree, tonic, mode)
303
343
  root_pc = (chord["root_pc"] + chromatic_shift) % 12
304
344
 
305
- # Build pitch classes based on quality
306
- if is_minor_quality:
307
- pcs = [root_pc, (root_pc + 3) % 12, (root_pc + 7) % 12]
345
+ # Build pitch classes based on quality.
346
+ # When there's no chromatic alteration, use scale-derived quality so
347
+ # that e.g. "vi7" in D minor correctly yields Bb major 7th, not Bb minor.
348
+ # Only force minor from case when there's an explicit accidental.
349
+ if chromatic_shift != 0 and is_minor_quality:
308
350
  quality = "minor"
351
+ elif chromatic_shift != 0 and not is_minor_quality:
352
+ quality = "major"
309
353
  else:
310
- # Use scale-derived quality
311
354
  quality = chord["quality"]
312
- if quality == "minor":
313
- pcs = [root_pc, (root_pc + 3) % 12, (root_pc + 7) % 12]
314
- elif quality == "diminished":
315
- pcs = [root_pc, (root_pc + 3) % 12, (root_pc + 6) % 12]
316
- elif quality == "augmented":
317
- pcs = [root_pc, (root_pc + 4) % 12, (root_pc + 8) % 12]
318
- else:
319
- pcs = [root_pc, (root_pc + 4) % 12, (root_pc + 7) % 12]
320
355
 
321
- # Handle suffix
356
+ if quality == "minor":
357
+ pcs = [root_pc, (root_pc + 3) % 12, (root_pc + 7) % 12]
358
+ elif quality == "diminished":
359
+ pcs = [root_pc, (root_pc + 3) % 12, (root_pc + 6) % 12]
360
+ elif quality == "augmented":
361
+ pcs = [root_pc, (root_pc + 4) % 12, (root_pc + 8) % 12]
362
+ else:
363
+ pcs = [root_pc, (root_pc + 4) % 12, (root_pc + 7) % 12]
364
+
365
+ # Handle suffix — derive 7th from the scale when possible
322
366
  suffix = remaining.lower()
323
367
  if suffix == "7":
324
- seventh = (root_pc + 10) % 12 # dominant/minor 7th
368
+ # Use scale-derived 7th: pitch class a diatonic 7th above the root
369
+ scale = get_scale_pitches(tonic, mode)
370
+ seventh_degree = (degree + 6) % 7 # 7th of the chord = 6 steps up
371
+ seventh = scale[seventh_degree]
372
+ seventh_interval = (seventh - root_pc) % 12
325
373
  pcs.append(seventh)
326
- if quality == "minor":
327
- quality = "minor seventh"
374
+ if seventh_interval == 11:
375
+ quality = "major seventh"
376
+ elif seventh_interval == 10:
377
+ if quality == "diminished":
378
+ quality = "half-diminished seventh"
379
+ elif quality == "minor":
380
+ quality = "minor seventh"
381
+ else:
382
+ quality = "dominant seventh"
383
+ elif seventh_interval == 9:
384
+ quality = "diminished seventh"
328
385
  else:
329
- quality = "dominant seventh"
386
+ quality = "minor seventh" if quality == "minor" else "dominant seventh"
330
387
  elif suffix == "o7":
331
388
  seventh = (root_pc + 9) % 12 # diminished 7th
332
389
  pcs.append(seventh)
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.18",
3
+ "version": "1.9.19",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 236 tools, 31 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
5
+ "description": "Agentic production system for Ableton Live 12 — 236 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
6
6
  "author": "Pilot Studio",
7
7
  "license": "MIT",
8
8
  "type": "commonjs",
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.9.18"
8
+ __version__ = "1.9.19"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from .server import LivePilotServer
@@ -132,11 +132,18 @@ def get_track_meters(song, params):
132
132
  entry = {
133
133
  "index": idx,
134
134
  "name": track.name,
135
- "level": track.output_meter_level,
136
135
  }
137
- if include_stereo:
138
- entry["left"] = track.output_meter_left
139
- entry["right"] = track.output_meter_right
136
+ if track.has_audio_output:
137
+ entry["level"] = track.output_meter_level
138
+ if include_stereo:
139
+ entry["left"] = track.output_meter_left
140
+ entry["right"] = track.output_meter_right
141
+ else:
142
+ entry["level"] = 0.0
143
+ entry["has_audio_output"] = False
144
+ if include_stereo:
145
+ entry["left"] = 0.0
146
+ entry["right"] = 0.0
140
147
  return entry
141
148
 
142
149
  if track_index is not None:
@@ -170,7 +177,7 @@ def get_mix_snapshot(song, params):
170
177
  tracks.append({
171
178
  "index": i,
172
179
  "name": track.name,
173
- "meter_level": track.output_meter_level,
180
+ "meter_level": track.output_meter_level if track.has_audio_output else 0.0,
174
181
  "volume": track.mixer_device.volume.value,
175
182
  "pan": track.mixer_device.panning.value,
176
183
  "mute": track.mute,
@@ -122,6 +122,9 @@ def create_midi_track(song, params):
122
122
  track.name = str(params["name"])
123
123
  if "color_index" in params:
124
124
  track.color_index = int(params["color_index"])
125
+ # Ableton auto-arms newly created tracks — disarm to avoid surprises
126
+ if track.arm and not params.get("arm", False):
127
+ track.arm = False
125
128
  return {"index": new_index, "name": track.name}
126
129
 
127
130
 
@@ -139,6 +142,9 @@ def create_audio_track(song, params):
139
142
  track.name = str(params["name"])
140
143
  if "color_index" in params:
141
144
  track.color_index = int(params["color_index"])
145
+ # Ableton auto-arms newly created tracks — disarm to avoid surprises
146
+ if track.arm and not params.get("arm", False):
147
+ track.arm = False
142
148
  return {"index": new_index, "name": track.name}
143
149
 
144
150