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.
- package/CHANGELOG.md +29 -0
- package/README.md +20 -5
- package/bin/livepilot.js +2 -2
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/server.py +3 -0
- package/mcp_server/tools/_generative_engine.py +271 -0
- package/mcp_server/tools/_harmony_engine.py +207 -0
- package/mcp_server/tools/generative.py +273 -0
- package/mcp_server/tools/harmony.py +253 -0
- package/mcp_server/tools/midi_io.py +305 -0
- package/package.json +2 -2
- package/plugin/plugin.json +2 -2
- package/plugin/skills/livepilot-core/SKILL.md +44 -6
- package/plugin/skills/livepilot-core/references/overview.md +3 -3
- package/remote_script/LivePilot/__init__.py +2 -2
- package/requirements.txt +3 -0
|
@@ -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
|
+
}
|