livepilot 1.6.5 → 1.7.1

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.
@@ -0,0 +1,305 @@
1
+ """MIDI file I/O tools — export, import, analyze, piano roll.
2
+
3
+ 4 tools bridging LivePilot's session clips with .mid files.
4
+ Tools 1-2 require Ableton connection. Tools 3-4 are offline-capable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import statistics
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ from fastmcp import Context
16
+
17
+ from ..server import mcp
18
+ from . import _theory_engine as theory
19
+
20
+
21
+ def _get_ableton(ctx: Context):
22
+ return ctx.lifespan_context["ableton"]
23
+
24
+
25
+ def _require_midiutil():
26
+ try:
27
+ from midiutil import MIDIFile
28
+ return MIDIFile
29
+ except ImportError:
30
+ raise ImportError(
31
+ "midiutil is required for MIDI export. "
32
+ "Install with: pip install midiutil"
33
+ )
34
+
35
+
36
+ def _require_pretty_midi():
37
+ try:
38
+ import pretty_midi
39
+ return pretty_midi
40
+ except ImportError:
41
+ raise ImportError(
42
+ "pretty-midi is required for this tool. "
43
+ "Install with: pip install pretty-midi"
44
+ )
45
+
46
+
47
+ def _output_dir() -> Path:
48
+ d = Path.home() / "Documents" / "LivePilot" / "outputs" / "midi"
49
+ d.mkdir(parents=True, exist_ok=True)
50
+ return d
51
+
52
+
53
+ def _validate_midi_path(file_path: str) -> Path:
54
+ p = Path(file_path)
55
+ if not p.exists():
56
+ raise FileNotFoundError(f"File not found: {file_path}")
57
+ if p.suffix.lower() not in (".mid", ".midi"):
58
+ raise ValueError(f"Not a MIDI file: {file_path}")
59
+ return p
60
+
61
+
62
+ # -- Tool 1: export_clip_midi ------------------------------------------------
63
+
64
+ @mcp.tool()
65
+ def export_clip_midi(
66
+ ctx: Context,
67
+ track_index: int,
68
+ clip_index: int,
69
+ filename: Optional[str] = None,
70
+ ) -> dict:
71
+ """Export a session clip's notes to a .mid file.
72
+
73
+ Fetches notes from the clip and writes them to a standard MIDI file.
74
+ Auto-generates filename from track/clip if not provided.
75
+ """
76
+ MIDIFile = _require_midiutil()
77
+ ableton = _get_ableton(ctx)
78
+
79
+ notes_result = ableton.send_command("get_notes", {
80
+ "track_index": track_index,
81
+ "clip_index": clip_index,
82
+ })
83
+ notes = notes_result.get("notes", [])
84
+
85
+ session = ableton.send_command("get_session_info", {})
86
+ tempo = float(session.get("tempo", 120.0))
87
+
88
+ if not filename:
89
+ filename = f"track{track_index}_clip{clip_index}.mid"
90
+ if not filename.endswith((".mid", ".midi")):
91
+ filename += ".mid"
92
+
93
+ out_path = _output_dir() / filename
94
+
95
+ midi = MIDIFile(1)
96
+ midi.addTempo(0, 0, tempo)
97
+
98
+ duration_beats = 0.0
99
+ for n in notes:
100
+ start = float(n["start_time"])
101
+ dur = float(n["duration"])
102
+ pitch = int(n["pitch"])
103
+ vel = int(n.get("velocity", 100))
104
+ midi.addNote(0, 0, pitch, start, dur, vel)
105
+ duration_beats = max(duration_beats, start + dur)
106
+
107
+ with open(out_path, "wb") as f:
108
+ midi.writeFile(f)
109
+
110
+ return {
111
+ "file_path": str(out_path),
112
+ "note_count": len(notes),
113
+ "duration_beats": round(duration_beats, 4),
114
+ "tempo": tempo,
115
+ }
116
+
117
+
118
+ # -- Tool 2: import_midi_to_clip ---------------------------------------------
119
+
120
+ @mcp.tool()
121
+ def import_midi_to_clip(
122
+ ctx: Context,
123
+ file_path: str,
124
+ track_index: int,
125
+ clip_index: int,
126
+ create_clip: bool = True,
127
+ ) -> dict:
128
+ """Load a .mid file into a session clip.
129
+
130
+ Reads MIDI, converts timing to beats using session tempo, and writes
131
+ notes into the target clip slot. Creates the clip if needed.
132
+ """
133
+ pretty_midi = _require_pretty_midi()
134
+ ableton = _get_ableton(ctx)
135
+
136
+ path = _validate_midi_path(file_path)
137
+ pm = pretty_midi.PrettyMIDI(str(path))
138
+
139
+ session = ableton.send_command("get_session_info", {})
140
+ tempo = float(session.get("tempo", 120.0))
141
+
142
+ notes_raw = []
143
+ for inst in pm.instruments:
144
+ for n in inst.notes:
145
+ start_beat = round(n.start * (tempo / 60.0), 3)
146
+ dur_beat = round((n.end - n.start) * (tempo / 60.0), 3)
147
+ notes_raw.append({
148
+ "pitch": n.pitch,
149
+ "start_time": start_beat,
150
+ "duration": max(dur_beat, 0.001),
151
+ "velocity": n.velocity,
152
+ })
153
+
154
+ seen = set()
155
+ notes = []
156
+ for n in notes_raw:
157
+ key = (n["pitch"], round(n["start_time"], 3), round(n["duration"], 3))
158
+ if key not in seen:
159
+ seen.add(key)
160
+ notes.append(n)
161
+
162
+ notes = notes[:2000]
163
+
164
+ duration_beats = max((n["start_time"] + n["duration"] for n in notes),
165
+ default=4.0)
166
+
167
+ if create_clip:
168
+ ableton.send_command("create_clip", {
169
+ "track_index": track_index,
170
+ "clip_index": clip_index,
171
+ "length": round(duration_beats, 2),
172
+ })
173
+
174
+ if notes:
175
+ ableton.send_command("add_notes", {
176
+ "track_index": track_index,
177
+ "clip_index": clip_index,
178
+ "notes": notes,
179
+ })
180
+
181
+ return {
182
+ "note_count": len(notes),
183
+ "duration_beats": round(duration_beats, 4),
184
+ "tempo_source": tempo,
185
+ }
186
+
187
+
188
+ # -- Tool 3: analyze_midi_file -----------------------------------------------
189
+
190
+ @mcp.tool()
191
+ def analyze_midi_file(
192
+ ctx: Context,
193
+ file_path: str,
194
+ ) -> dict:
195
+ """Analyze a .mid file — works offline, no Ableton needed.
196
+
197
+ Returns note count, duration, tempo, pitch range, instruments,
198
+ velocity stats, density curve, and estimated key.
199
+ """
200
+ pretty_midi = _require_pretty_midi()
201
+ path = _validate_midi_path(file_path)
202
+ pm = pretty_midi.PrettyMIDI(str(path))
203
+
204
+ all_notes = []
205
+ for inst in pm.instruments:
206
+ for n in inst.notes:
207
+ all_notes.append(n)
208
+
209
+ if not all_notes:
210
+ return {
211
+ "note_count": 0,
212
+ "duration_seconds": round(pm.get_end_time(), 2),
213
+ "tempo_estimates": list(pm.get_tempo_changes()[1]),
214
+ "pitch_range": [0, 0],
215
+ "instruments": [i.name for i in pm.instruments],
216
+ "velocity_stats": {},
217
+ "density_curve": [],
218
+ "key_estimate": "unknown",
219
+ }
220
+
221
+ pitches = [n.pitch for n in all_notes]
222
+ velocities = [n.velocity for n in all_notes]
223
+ duration = pm.get_end_time()
224
+
225
+ density_curve = []
226
+ window = 1.0
227
+ t = 0.0
228
+ while t < duration:
229
+ count = sum(1 for n in all_notes if t <= n.start < t + window)
230
+ density_curve.append({
231
+ "time": round(t, 1),
232
+ "density": count / window,
233
+ })
234
+ t += window
235
+
236
+ notes_for_key = [
237
+ {"pitch": n.pitch, "duration": n.end - n.start}
238
+ for n in all_notes
239
+ ]
240
+ key_result = theory.detect_key(notes_for_key)
241
+ key_str = f"{key_result['tonic_name']} {key_result['mode']}"
242
+
243
+ vel_stats = {
244
+ "mean": round(statistics.mean(velocities), 1),
245
+ "min": min(velocities),
246
+ "max": max(velocities),
247
+ "std": round(statistics.stdev(velocities), 1) if len(velocities) > 1 else 0.0,
248
+ }
249
+
250
+ return {
251
+ "note_count": len(all_notes),
252
+ "duration_seconds": round(duration, 2),
253
+ "tempo_estimates": [round(t, 1) for t in pm.get_tempo_changes()[1]],
254
+ "pitch_range": [min(pitches), max(pitches)],
255
+ "instruments": [i.name for i in pm.instruments],
256
+ "velocity_stats": vel_stats,
257
+ "density_curve": density_curve,
258
+ "key_estimate": key_str,
259
+ }
260
+
261
+
262
+ # -- Tool 4: extract_piano_roll ----------------------------------------------
263
+
264
+ @mcp.tool()
265
+ def extract_piano_roll(
266
+ ctx: Context,
267
+ file_path: str,
268
+ resolution: float = 0.125,
269
+ ) -> dict:
270
+ """Extract a 2D piano roll matrix from a .mid file. Offline-capable.
271
+
272
+ Returns a velocity matrix [pitch_index][time_step] trimmed to
273
+ the actual pitch range. Resolution is in beats (0.125 = 32nd note).
274
+ """
275
+ pretty_midi = _require_pretty_midi()
276
+ path = _validate_midi_path(file_path)
277
+ pm = pretty_midi.PrettyMIDI(str(path))
278
+
279
+ tempo_changes = pm.get_tempo_changes()
280
+ tempo = float(tempo_changes[1][0]) if len(tempo_changes[1]) > 0 else 120.0
281
+ fs = (tempo / 60.0) / resolution
282
+
283
+ roll = pm.get_piano_roll(fs=fs) # shape (128, T)
284
+
285
+ active_pitches = roll.sum(axis=1).nonzero()[0]
286
+ if len(active_pitches) == 0:
287
+ return {
288
+ "piano_roll": [],
289
+ "pitch_min": 0,
290
+ "pitch_max": 0,
291
+ "time_steps": 0,
292
+ "resolution": resolution,
293
+ }
294
+
295
+ pitch_min = int(active_pitches[0])
296
+ pitch_max = int(active_pitches[-1])
297
+ trimmed = roll[pitch_min:pitch_max + 1, :]
298
+
299
+ return {
300
+ "piano_roll": trimmed.astype(int).tolist(),
301
+ "pitch_min": pitch_min,
302
+ "pitch_max": pitch_max,
303
+ "time_steps": int(trimmed.shape[1]),
304
+ "resolution": resolution,
305
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.6.5",
3
+ "version": "1.7.1",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "AI copilot for Ableton Live 12 — 142 tools, device atlas (280+ devices), real-time audio analysis, automation intelligence, and technique memory",
5
+ "description": "AI copilot for Ableton Live 12 — 155 tools, device atlas (280+ devices), real-time audio analysis, generative algorithms, neo-Riemannian harmony, MIDI file I/O, and technique memory",
6
6
  "author": "Pilot Studio",
7
7
  "license": "MIT",
8
8
  "type": "commonjs",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.6.5",
4
- "description": "AI copilot for Ableton Live 12 — 142 tools, device atlas (280+ devices), real-time audio analysis, automation intelligence, and technique memory",
3
+ "version": "1.7.0",
4
+ "description": "AI copilot for Ableton Live 12 — 155 tools, device atlas (280+ devices), real-time audio analysis, generative algorithms, neo-Riemannian harmony, MIDI file I/O, and technique memory",
5
5
  "author": "Pilot Studio",
6
6
  "skills": [
7
7
  "skills/livepilot-core",
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: livepilot-core
3
- description: Core discipline for controlling Ableton Live 12 through LivePilot's 142 MCP tools, device atlas (280+ devices), M4L analyzer (spectrum/RMS/key detection), automation intelligence (16 curve types, 15 recipes), music theory analysis, and technique memory. Use whenever working with Ableton Live through MCP tools.
3
+ description: Core discipline for controlling Ableton Live 12 through LivePilot's 155 MCP tools, device atlas (280+ devices), M4L analyzer (spectrum/RMS/key detection), automation intelligence (16 curve types, 15 recipes), music theory analysis, generative algorithms, neo-Riemannian harmony, MIDI file I/O, and technique memory. Use whenever working with Ableton Live through MCP tools.
4
4
  ---
5
5
 
6
6
  # LivePilot Core — Ableton Live 12 AI Copilot
7
7
 
8
- LivePilot is an agentic production system for Ableton Live 12. It combines 142 MCP tools with three layers of intelligence:
8
+ LivePilot is an agentic production system for Ableton Live 12. It combines 155 MCP tools with three layers of intelligence:
9
9
 
10
10
  - **Device Atlas** — A structured knowledge corpus of 280+ instruments, 139 drum kits, and 350+ impulse responses. Consult the atlas before loading any device. It contains real browser URIs, preset names, and sonic descriptions. Never guess a device name — look it up.
11
11
  - **M4L Analyzer** — Real-time audio analysis on the master bus (8-band spectrum, RMS/peak, key detection). Use it to verify mixing decisions, detect frequency problems, and find the key before writing harmonic content.
12
12
  - **Technique Memory** — Persistent storage for production decisions. Consult `memory_recall` before creative tasks to understand the user's taste. Save techniques when the user likes something. The memory shapes future decisions without constraining them.
13
13
 
14
- These layers sit on top of 142 deterministic tools across 13 domains: transport, tracks, clips, MIDI notes, devices, scenes, mixing, browser, arrangement, technique memory, real-time DSP analysis, automation, and music theory.
14
+ These layers sit on top of 155 deterministic tools across 16 domains: transport, tracks, clips, MIDI notes, devices, scenes, mixing, browser, arrangement, technique memory, real-time DSP analysis, automation, music theory, generative algorithms, neo-Riemannian harmony, and MIDI file I/O.
15
15
 
16
16
  ## Golden Rules
17
17
 
@@ -32,7 +32,7 @@ These layers sit on top of 142 deterministic tools across 13 domains: transport,
32
32
  Not all tools respond instantly. Know the tiers and act accordingly.
33
33
 
34
34
  ### Instant (<1s) — Use freely, no warning needed
35
- All 142 core tools (transport, tracks, clips, notes, devices, scenes, mixing, browser, arrangement, memory, automation, theory) plus Layer A perception tools (spectral shape, timbral profile, mel spectrum, chroma, onsets, harmonic/percussive, novelty, momentary loudness). These are the reflex tools — call them anytime without hesitation.
35
+ All 155 core tools (transport, tracks, clips, notes, devices, scenes, mixing, browser, arrangement, memory, automation, theory, generative, harmony, midi_io) plus Layer A perception tools (spectral shape, timbral profile, mel spectrum, chroma, onsets, harmonic/percussive, novelty, momentary loudness). These are the reflex tools — call them anytime without hesitation.
36
36
 
37
37
  ### Fast (1-5s) — Use freely, barely noticeable
38
38
  `analyze_loudness` · `analyze_dynamic_range` · `compare_loudness`
@@ -117,7 +117,7 @@ Never skip levels. The user's question determines the entry point, but always st
117
117
  - MIDI track with no instrument loaded
118
118
  - Notes programmed but clip not fired
119
119
 
120
- ## Tool Domains (142 total)
120
+ ## Tool Domains (155 total)
121
121
 
122
122
  ### Transport (12)
123
123
  `get_session_info` · `set_tempo` · `set_time_signature` · `start_playback` · `stop_playback` · `continue_playback` · `toggle_metronome` · `set_session_loop` · `undo` · `redo` · `get_recent_actions` · `get_session_diagnostics`
@@ -187,6 +187,44 @@ Music theory analysis — built-in pure Python engine, zero external dependencie
187
187
  - Use your own musical knowledge alongside these tools — the engine provides data, you provide interpretation
188
188
  - Processing time: 2-5s for generative tools (harmonize, countermelody)
189
189
 
190
+ ### Generative (5)
191
+ Algorithmic composition tools — Euclidean rhythms, minimalist techniques.
192
+
193
+ **Tools:** `generate_euclidean_rhythm` · `layer_euclidean_rhythms` · `generate_tintinnabuli` · `generate_phase_shift` · `generate_additive_process`
194
+
195
+ **Key discipline:**
196
+ - All generative tools return note arrays — use `add_notes` to place them in clips
197
+ - `generate_euclidean_rhythm` uses the Bjorklund algorithm and identifies named rhythms (e.g., "tresillo", "cinquillo")
198
+ - `layer_euclidean_rhythms` stacks multiple patterns for polyrhythmic textures across tracks
199
+ - `generate_tintinnabuli` implements Arvo Pärt's technique: a T-voice (triad arpeggio) against a M-voice (melody)
200
+ - `generate_phase_shift` implements Steve Reich's phasing: two identical patterns drifting apart over time
201
+ - `generate_additive_process` implements Philip Glass's technique: melody expanded by adding one note per iteration
202
+
203
+ ### Harmony (4)
204
+ Neo-Riemannian harmony tools — Tonnetz navigation, voice leading, chromatic mediants.
205
+
206
+ **Tools:** `navigate_tonnetz` · `find_voice_leading_path` · `classify_progression` · `suggest_chromatic_mediants`
207
+
208
+ **Key discipline:**
209
+ - These tools work with chord names and return harmonic relationships — no clip MIDI required
210
+ - `navigate_tonnetz` returns PRL (Parallel, Relative, Leading-tone) neighbors for any chord
211
+ - `find_voice_leading_path` finds the shortest harmonic path between two chords through Tonnetz space
212
+ - `classify_progression` identifies the neo-Riemannian transform pattern in a chord sequence
213
+ - `suggest_chromatic_mediants` returns all chromatic mediant relations with film score usage notes
214
+ - Opycleid library provides full Tonnetz; falls back to pure Python PRL if not installed
215
+
216
+ ### MIDI I/O (4)
217
+ MIDI file import/export — works with standard .mid files on disk.
218
+
219
+ **Tools:** `export_clip_midi` · `import_midi_to_clip` · `analyze_midi_file` · `extract_piano_roll`
220
+
221
+ **Key discipline:**
222
+ - `export_clip_midi` exports a session clip's notes to a .mid file at the specified path
223
+ - `import_midi_to_clip` loads a .mid file into a clip, replacing existing notes
224
+ - `analyze_midi_file` performs offline analysis of any .mid file (tempo, notes, structure) — does not require Ableton connection
225
+ - `extract_piano_roll` returns a 2D velocity matrix (pitch × time) from a .mid file for visualization or processing
226
+ - Dependencies: midiutil (export), pretty-midi (import/analysis) — lazy-loaded, ~5 MB total
227
+
190
228
  ## Workflow: Building a Beat
191
229
 
192
230
  1. `get_session_info` — check current state
@@ -328,7 +366,7 @@ Deep production knowledge lives in `references/`. Consult these when making crea
328
366
 
329
367
  | File | What's inside | When to consult |
330
368
  |------|--------------|-----------------|
331
- | `references/overview.md` | All 142 tools mapped with params, units, ranges | Quick lookup for any tool |
369
+ | `references/overview.md` | All 155 tools mapped with params, units, ranges | Quick lookup for any tool |
332
370
  | `references/midi-recipes.md` | Drum patterns by genre, chord voicings, scales, hi-hat techniques, humanization, polymetrics | Programming MIDI notes, building beats |
333
371
  | `references/sound-design.md` | Stock instruments/effects, parameter recipes for bass/pad/lead/pluck, device chain patterns | Loading and configuring devices |
334
372
  | `references/mixing-patterns.md` | Gain staging, parallel compression, sidechain, EQ by instrument, bus processing, stereo width | Setting volumes, panning, adding effects |
@@ -1,6 +1,6 @@
1
- # LivePilot v1.6.5 — Architecture & Tool Reference
1
+ # LivePilot v1.7.0 — Architecture & Tool Reference
2
2
 
3
- LivePilot is an agentic production system for Ableton Live 12. It combines 142 MCP tools with a device knowledge corpus, real-time audio analysis, automation intelligence, and persistent technique memory.
3
+ LivePilot is an agentic production system for Ableton Live 12. It combines 155 MCP tools with a device knowledge corpus, real-time audio analysis, automation intelligence, generative algorithms, neo-Riemannian harmony, MIDI file I/O, and persistent technique memory.
4
4
 
5
5
  ## Architecture
6
6
 
@@ -32,7 +32,7 @@ A flat tool list lets the AI press buttons. LivePilot's three layers give it con
32
32
 
33
33
  This turns "set EQ band 3 to -4 dB" into "cut 400 Hz by 4 dB, then read the spectrum to confirm the mud is actually reduced."
34
34
 
35
- ## The 142 Tools — What Each One Does
35
+ ## The 155 Tools — What Each One Does
36
36
 
37
37
  ### Transport (12) — Playback, tempo, global state, diagnostics
38
38
 
@@ -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.6.5"
8
+ __version__ = "1.7.0"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from .server import LivePilotServer
@@ -34,7 +34,7 @@ class LivePilot(ControlSurface):
34
34
  ControlSurface.__init__(self, c_instance)
35
35
  self._server = LivePilotServer(self)
36
36
  self._server.start()
37
- self.log_message("LivePilot v1.6.5 initialized")
37
+ self.log_message("LivePilot v1.7.0 initialized")
38
38
  self.show_message("LivePilot: Listening on port 9878")
39
39
 
40
40
  def disconnect(self):
package/requirements.txt CHANGED
@@ -1,2 +1,5 @@
1
1
  # LivePilot MCP Server dependencies
2
2
  fastmcp>=3.0.0,<4.0.0
3
+ midiutil>=1.2.1
4
+ pretty_midi>=0.2.10
5
+ opycleid>=0.5.1