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,273 @@
1
+ """Generative music tools — Euclidean rhythms, tintinnabuli, phase shift, additive process.
2
+
3
+ 5 tools returning note arrays. Pure computation — no Ableton connection needed.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any, Optional
10
+
11
+ from fastmcp import Context
12
+
13
+ from ..server import mcp
14
+ from . import _generative_engine as gen
15
+ from . import _theory_engine as theory
16
+
17
+
18
+ def _ensure_list(value: Any) -> list:
19
+ if isinstance(value, str):
20
+ return json.loads(value)
21
+ return value
22
+
23
+
24
+ # -- Tool 1: generate_euclidean_rhythm --------------------------------------
25
+
26
+ @mcp.tool()
27
+ def generate_euclidean_rhythm(
28
+ ctx: Context,
29
+ pulses: int,
30
+ steps: int,
31
+ rotation: int = 0,
32
+ pitch: int = 36,
33
+ velocity: int = 100,
34
+ step_duration: float = 0.25,
35
+ ) -> dict:
36
+ """Generate a Euclidean rhythm using the Bjorklund algorithm.
37
+
38
+ Distributes pulses as evenly as possible across steps. Identifies
39
+ known rhythms (tresillo, cinquillo, bossa nova, etc.) when matched.
40
+ Returns note array — use add_notes to place in a clip.
41
+ """
42
+ if not 0 <= pulses <= 64:
43
+ return {"error": "pulses must be 0-64"}
44
+ if not 1 <= steps <= 64:
45
+ return {"error": "steps must be 1-64"}
46
+ if pulses > steps:
47
+ return {"error": "pulses must be <= steps"}
48
+
49
+ pattern = gen.bjorklund(pulses, steps)
50
+ if rotation:
51
+ pattern = gen.rotate_pattern(pattern, rotation)
52
+
53
+ notes = []
54
+ for i, hit in enumerate(pattern):
55
+ if hit:
56
+ notes.append({
57
+ "pitch": pitch,
58
+ "start_time": round(i * step_duration, 4),
59
+ "duration": step_duration,
60
+ "velocity": velocity,
61
+ })
62
+
63
+ return {
64
+ "notes": notes,
65
+ "pattern": pattern,
66
+ "name": gen.identify_rhythm(pulses, steps),
67
+ "total_duration": round(steps * step_duration, 4),
68
+ }
69
+
70
+
71
+ # -- Tool 2: layer_euclidean_rhythms ----------------------------------------
72
+
73
+ @mcp.tool()
74
+ def layer_euclidean_rhythms(
75
+ ctx: Context,
76
+ layers: Any,
77
+ ) -> dict:
78
+ """Stack multiple Euclidean rhythms for polyrhythmic textures.
79
+
80
+ Each layer specifies pulses, steps, pitch, and optional velocity/rotation.
81
+ Returns combined note array ready for add_notes.
82
+ """
83
+ layers = _ensure_list(layers)
84
+ if not layers:
85
+ return {"error": "At least one layer required"}
86
+
87
+ all_notes: list[dict] = []
88
+ layer_info: list[dict] = []
89
+ max_duration = 0.0
90
+
91
+ for layer in layers:
92
+ p = int(layer["pulses"])
93
+ s = int(layer["steps"])
94
+ rot = int(layer.get("rotation", 0))
95
+ pitch = int(layer["pitch"])
96
+ vel = int(layer.get("velocity", 100))
97
+ dur = float(layer.get("step_duration", 0.25))
98
+
99
+ pattern = gen.bjorklund(p, s)
100
+ if rot:
101
+ pattern = gen.rotate_pattern(pattern, rot)
102
+
103
+ layer_notes = []
104
+ for i, hit in enumerate(pattern):
105
+ if hit:
106
+ layer_notes.append({
107
+ "pitch": pitch,
108
+ "start_time": round(i * dur, 4),
109
+ "duration": dur,
110
+ "velocity": vel,
111
+ })
112
+
113
+ all_notes.extend(layer_notes)
114
+ total_dur = round(s * dur, 4)
115
+ max_duration = max(max_duration, total_dur)
116
+ layer_info.append({
117
+ "pattern": pattern,
118
+ "note_count": len(layer_notes),
119
+ "name": gen.identify_rhythm(p, s),
120
+ })
121
+
122
+ return {
123
+ "notes": sorted(all_notes, key=lambda n: n["start_time"]),
124
+ "layers": layer_info,
125
+ "total_duration": max_duration,
126
+ }
127
+
128
+
129
+ # -- Tool 3: generate_tintinnabuli ------------------------------------------
130
+
131
+ @mcp.tool()
132
+ def generate_tintinnabuli(
133
+ ctx: Context,
134
+ melody_notes: Any,
135
+ triad: str,
136
+ position: str = "nearest",
137
+ velocity: int = 80,
138
+ ) -> dict:
139
+ """Generate a tintinnabuli voice (Arvo Pärt technique).
140
+
141
+ For each melody note, finds the nearest note of the specified triad.
142
+ Returns the tintinnabuli voice as a separate note array — combine
143
+ with the original melody via add_notes for the full Pärt effect.
144
+ Only major and minor triads are supported.
145
+ """
146
+ melody_notes = _ensure_list(melody_notes)
147
+ if not melody_notes:
148
+ return {"error": "melody_notes cannot be empty"}
149
+ if position not in ("above", "below", "nearest"):
150
+ return {"error": "position must be 'above', 'below', or 'nearest'"}
151
+
152
+ try:
153
+ parsed = theory.parse_key(triad)
154
+ except ValueError:
155
+ return {"error": f"Cannot parse triad: {triad}"}
156
+ if parsed["mode"] not in ("major", "minor"):
157
+ return {"error": "Only major and minor triads are supported"}
158
+
159
+ root = parsed["tonic"]
160
+ if parsed["mode"] == "major":
161
+ triad_pcs = [root, (root + 4) % 12, (root + 7) % 12]
162
+ else:
163
+ triad_pcs = [root, (root + 3) % 12, (root + 7) % 12]
164
+
165
+ melody_pitches = [int(n["pitch"]) for n in melody_notes]
166
+ t_pitches = gen.tintinnabuli_voice(melody_pitches, triad_pcs, position)
167
+
168
+ notes = []
169
+ for i, n in enumerate(melody_notes):
170
+ notes.append({
171
+ "pitch": t_pitches[i],
172
+ "start_time": float(n["start_time"]),
173
+ "duration": float(n["duration"]),
174
+ "velocity": velocity,
175
+ })
176
+
177
+ triad_name = f"{theory.NOTE_NAMES[root]} {parsed['mode']}"
178
+ return {
179
+ "notes": notes,
180
+ "technique": "tintinnabuli",
181
+ "triad_used": triad_name,
182
+ "description": f"T-voice moves to {position} {triad_name} triad tone for each melody note",
183
+ }
184
+
185
+
186
+ # -- Tool 4: generate_phase_shift -------------------------------------------
187
+
188
+ @mcp.tool()
189
+ def generate_phase_shift(
190
+ ctx: Context,
191
+ pattern_notes: Any,
192
+ voices: int = 2,
193
+ shift_amount: float = 0.125,
194
+ total_length: float = 16.0,
195
+ ) -> dict:
196
+ """Generate a phase-shifted canon (Steve Reich technique).
197
+
198
+ Voice 0 loops the pattern normally. Each subsequent voice drifts
199
+ by shift_amount beats per repetition, creating gradual phase displacement.
200
+ Returns combined note array with velocity-encoded voices.
201
+ """
202
+ pattern_notes = _ensure_list(pattern_notes)
203
+ if not pattern_notes:
204
+ return {"error": "pattern_notes cannot be empty"}
205
+ if not 1 <= voices <= 8:
206
+ return {"error": "voices must be 1-8"}
207
+ if shift_amount <= 0:
208
+ return {"error": "shift_amount must be > 0"}
209
+
210
+ result_notes = gen.phase_shift(pattern_notes, voices, shift_amount, total_length)
211
+
212
+ pattern_length = max(n["start_time"] + n["duration"] for n in pattern_notes)
213
+
214
+ alignment = None
215
+ if voices == 2 and shift_amount > 0 and pattern_length > 0:
216
+ alignment = round((pattern_length / shift_amount) * pattern_length, 4)
217
+ if alignment > total_length:
218
+ alignment = None
219
+
220
+ return {
221
+ "notes": result_notes,
222
+ "voices": voices,
223
+ "shift_per_repeat": shift_amount,
224
+ "pattern_length": round(pattern_length, 4),
225
+ "full_alignment_at": alignment,
226
+ }
227
+
228
+
229
+ # -- Tool 5: generate_additive_process --------------------------------------
230
+
231
+ @mcp.tool()
232
+ def generate_additive_process(
233
+ ctx: Context,
234
+ melody_notes: Any,
235
+ direction: str = "forward",
236
+ repetitions_per_stage: int = 2,
237
+ ) -> dict:
238
+ """Generate an additive process (Philip Glass technique).
239
+
240
+ Forward: builds melody note by note (1, then 1-2, then 1-2-3...).
241
+ Backward: full melody, then removes from front.
242
+ Both: forward then backward.
243
+ Returns note array — use add_notes to place in a clip.
244
+ """
245
+ melody_notes = _ensure_list(melody_notes)
246
+ if not melody_notes:
247
+ return {"error": "melody_notes cannot be empty"}
248
+ if direction not in ("forward", "backward", "both"):
249
+ return {"error": "direction must be 'forward', 'backward', or 'both'"}
250
+ if repetitions_per_stage < 1:
251
+ return {"error": "repetitions_per_stage must be >= 1"}
252
+
253
+ result_notes = gen.additive_process(melody_notes, direction,
254
+ repetitions_per_stage)
255
+ n = len(melody_notes)
256
+ if direction == "forward":
257
+ stages = n
258
+ elif direction == "backward":
259
+ stages = n
260
+ else:
261
+ stages = (2 * n) - 1
262
+
263
+ total_duration = max(
264
+ (no["start_time"] + no["duration"] for no in result_notes),
265
+ default=0.0,
266
+ )
267
+
268
+ return {
269
+ "notes": result_notes,
270
+ "stages": stages,
271
+ "total_duration": round(total_duration, 4),
272
+ "direction": direction,
273
+ }
@@ -0,0 +1,253 @@
1
+ """Neo-Riemannian harmony tools — Tonnetz navigation, voice-leading paths,
2
+ progression classification, chromatic mediant suggestions.
3
+
4
+ 4 tools for advanced harmonic analysis and exploration.
5
+ Pure computation — no Ableton connection needed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from fastmcp import Context
14
+
15
+ from ..server import mcp
16
+ from . import _harmony_engine as harmony
17
+ from . import _theory_engine as theory
18
+
19
+
20
+ def _ensure_list(value: Any) -> list:
21
+ if isinstance(value, str):
22
+ return json.loads(value)
23
+ return value
24
+
25
+
26
+ # -- Tool 1: navigate_tonnetz ------------------------------------------------
27
+
28
+ @mcp.tool()
29
+ def navigate_tonnetz(
30
+ ctx: Context,
31
+ chord: str,
32
+ depth: int = 1,
33
+ ) -> dict:
34
+ """Show neo-Riemannian neighbors of a chord on the Tonnetz.
35
+
36
+ P (Parallel) flips the third: C major → C minor.
37
+ L (Leading-tone) shifts by semitone: C major → E minor.
38
+ R (Relative) shifts by whole tone: C major → A minor.
39
+
40
+ Use depth 2-3 to see compound transforms (PL, PR, PRL, etc.).
41
+ """
42
+ if not 1 <= depth <= 3:
43
+ return {"error": "depth must be 1-3"}
44
+ try:
45
+ root_pc, quality = harmony.parse_chord(chord)
46
+ except ValueError as e:
47
+ return {"error": str(e)}
48
+
49
+ all_neighbors = harmony.get_neighbors(root_pc, quality, depth)
50
+
51
+ descriptions = {
52
+ "P": "flip third (Parallel)",
53
+ "L": "shift by semitone (Leading-tone)",
54
+ "R": "shift by whole tone (Relative)",
55
+ }
56
+
57
+ depth_1 = {}
58
+ for label in ("P", "L", "R"):
59
+ if label in all_neighbors:
60
+ r, q = all_neighbors[label]
61
+ depth_1[label] = {
62
+ "chord": harmony.chord_to_str(r, q),
63
+ "transform": label,
64
+ "description": descriptions[label],
65
+ }
66
+
67
+ result: dict = {"chord": chord, "neighbors": depth_1}
68
+
69
+ if depth >= 2:
70
+ depth_2 = {}
71
+ for key, (r, q) in all_neighbors.items():
72
+ if len(key) == 2:
73
+ depth_2[key] = {
74
+ "chord": harmony.chord_to_str(r, q),
75
+ "transforms": key,
76
+ }
77
+ result["depth_2"] = depth_2
78
+
79
+ if depth >= 3:
80
+ depth_3 = {}
81
+ for key, (r, q) in all_neighbors.items():
82
+ if len(key) == 3:
83
+ depth_3[key] = {
84
+ "chord": harmony.chord_to_str(r, q),
85
+ "transforms": key,
86
+ }
87
+ result["depth_3"] = depth_3
88
+
89
+ return result
90
+
91
+
92
+ # -- Tool 2: find_voice_leading_path -----------------------------------------
93
+
94
+ @mcp.tool()
95
+ def find_voice_leading_path(
96
+ ctx: Context,
97
+ from_chord: str,
98
+ to_chord: str,
99
+ max_steps: int = 4,
100
+ ) -> dict:
101
+ """Find the shortest neo-Riemannian path between two chords.
102
+
103
+ Returns each intermediate chord and the specific voice movements.
104
+ This is the 'film score progression finder' — chromatic mediants,
105
+ hexatonic poles, and other cinematic chord moves.
106
+ """
107
+ if not 1 <= max_steps <= 6:
108
+ return {"error": "max_steps must be 1-6"}
109
+ try:
110
+ from_parsed = harmony.parse_chord(from_chord)
111
+ to_parsed = harmony.parse_chord(to_chord)
112
+ except ValueError as e:
113
+ return {"error": str(e)}
114
+
115
+ result = harmony.find_shortest_path(from_parsed, to_parsed, max_steps)
116
+
117
+ if not result["found"]:
118
+ return {
119
+ "from": from_chord,
120
+ "to": to_chord,
121
+ "found": False,
122
+ "steps": -1,
123
+ "path": [],
124
+ "transforms": [],
125
+ "voice_leading": [],
126
+ }
127
+
128
+ path_strs = [harmony.chord_to_str(*c) for c in result["path"]]
129
+ voice_leading = []
130
+ for i in range(len(result["path"]) - 1):
131
+ from_midi = harmony.chord_to_midi(*result["path"][i])
132
+ to_midi = harmony.chord_to_midi(*result["path"][i + 1])
133
+ movements = []
134
+ for f, t in zip(from_midi, to_midi):
135
+ if f != t:
136
+ movements.append(f"{theory.pitch_name(f)}→{theory.pitch_name(t)}")
137
+ voice_leading.append({
138
+ "from": from_midi,
139
+ "to": to_midi,
140
+ "movement": ", ".join(movements) if movements else "no movement",
141
+ })
142
+
143
+ return {
144
+ "from": from_chord,
145
+ "to": to_chord,
146
+ "found": True,
147
+ "steps": result["steps"],
148
+ "path": path_strs,
149
+ "transforms": result["transforms"],
150
+ "voice_leading": voice_leading,
151
+ }
152
+
153
+
154
+ # -- Tool 3: classify_progression --------------------------------------------
155
+
156
+ @mcp.tool()
157
+ def classify_progression(
158
+ ctx: Context,
159
+ chords: Any,
160
+ ) -> dict:
161
+ """Classify a chord progression by its neo-Riemannian transform pattern.
162
+
163
+ Identifies hexatonic cycles (PL), octatonic cycles (PR), diatonic
164
+ cycles (LR), and other known patterns. Pairs with analyze_harmony
165
+ to understand why a progression sounds 'cinematic' or 'otherworldly'.
166
+ """
167
+ chords = _ensure_list(chords)
168
+ if len(chords) < 2:
169
+ return {"error": "Need at least 2 chords to classify"}
170
+
171
+ try:
172
+ parsed = [harmony.parse_chord(c) for c in chords]
173
+ except ValueError as e:
174
+ return {"error": str(e)}
175
+
176
+ transforms = harmony.classify_transform_sequence(parsed)
177
+ pattern = "".join(transforms)
178
+
179
+ classification = "free neo-Riemannian progression"
180
+ notable_usage = None
181
+ clean = pattern.replace("?", "")
182
+
183
+ if len(clean) >= 2:
184
+ pair = clean[:2]
185
+ if pair in ("PL", "LP") and all(c in "PL" for c in clean):
186
+ classification = "hexatonic cycle fragment"
187
+ notable_usage = "Radiohead, film scores (Zimmer, Howard)"
188
+ elif pair in ("PR", "RP") and all(c in "PR" for c in clean):
189
+ classification = "octatonic cycle fragment"
190
+ notable_usage = "late Romantic (Wagner, Strauss), horror film scores"
191
+ elif pair in ("LR", "RL") and all(c in "LR" for c in clean):
192
+ classification = "diatonic cycle fragment"
193
+ notable_usage = "functional harmony, common in classical and pop"
194
+
195
+ if len(clean) == 1:
196
+ names = {"P": "parallel transform", "L": "leading-tone transform",
197
+ "R": "relative transform"}
198
+ classification = names.get(clean, classification)
199
+
200
+ return {
201
+ "chords": chords,
202
+ "transforms": transforms,
203
+ "pattern": pattern,
204
+ "classification": classification,
205
+ "notable_usage": notable_usage,
206
+ }
207
+
208
+
209
+ # -- Tool 4: suggest_chromatic_mediants --------------------------------------
210
+
211
+ @mcp.tool()
212
+ def suggest_chromatic_mediants(
213
+ ctx: Context,
214
+ chord: str,
215
+ ) -> dict:
216
+ """Suggest all chromatic mediant relations for a chord.
217
+
218
+ Chromatic mediants are chords a major/minor third away — they share
219
+ 0-1 common tones, creating maximum color shift with minimal voice movement.
220
+ Includes 'cinematic picks' highlighting the most film-score-friendly options.
221
+ """
222
+ try:
223
+ root_pc, quality = harmony.parse_chord(chord)
224
+ except ValueError as e:
225
+ return {"error": str(e)}
226
+
227
+ mediants = harmony.get_chromatic_mediants(root_pc, quality)
228
+
229
+ chord_pcs = set(harmony.chord_to_midi(root_pc, quality))
230
+ formatted = {}
231
+ for key, (r, q) in mediants.items():
232
+ mediant_pcs = set(harmony.chord_to_midi(r, q))
233
+ common = len(chord_pcs & mediant_pcs)
234
+ formatted[key] = {
235
+ "chord": harmony.chord_to_str(r, q),
236
+ "common_tones": common,
237
+ "relation": key.replace("_", " "),
238
+ }
239
+
240
+ cinematic = [
241
+ harmony.chord_to_str(*mediants["lower_major_third"]),
242
+ harmony.chord_to_str(*mediants["upper_major_third"]),
243
+ ]
244
+
245
+ return {
246
+ "chord": chord,
247
+ "chromatic_mediants": formatted,
248
+ "cinematic_picks": cinematic,
249
+ "explanation": (
250
+ "Chromatic mediants share 0-1 common tones with the original chord. "
251
+ "Maximum color shift with minimal voice movement — the 'epic' sound."
252
+ ),
253
+ }