livepilot 1.6.3 → 1.6.5
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 +28 -0
- package/README.md +6 -5
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/server.py +1 -0
- package/mcp_server/tools/_theory_engine.py +366 -0
- package/mcp_server/tools/theory.py +712 -0
- package/package.json +2 -2
- package/plugin/plugin.json +2 -2
- package/plugin/skills/livepilot-core/SKILL.md +70 -5
- package/plugin/skills/livepilot-core/references/overview.md +17 -3
- package/remote_script/LivePilot/__init__.py +2 -2
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
"""Music theory tools — pure Python, zero dependencies.
|
|
2
|
+
|
|
3
|
+
7 tools for harmonic analysis, chord suggestion, voice leading detection,
|
|
4
|
+
counterpoint generation, scale identification, harmonization, and intelligent
|
|
5
|
+
transposition — all working directly on live session clip data via get_notes.
|
|
6
|
+
|
|
7
|
+
Design principle: tools compute from data, the LLM interprets and explains.
|
|
8
|
+
Returns precise musical data (Roman numerals, pitch names, intervals), never
|
|
9
|
+
explanations the LLM already knows from training.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import random
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from fastmcp import Context
|
|
19
|
+
|
|
20
|
+
from ..server import mcp
|
|
21
|
+
from . import _theory_engine as engine
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# -- Shared utilities --------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def _get_ableton(ctx: Context):
|
|
27
|
+
return ctx.lifespan_context["ableton"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_clip_notes(ctx: Context, track_index: int, clip_index: int) -> list[dict]:
|
|
31
|
+
"""Fetch notes from a session clip via the remote script."""
|
|
32
|
+
result = _get_ableton(ctx).send_command("get_notes", {
|
|
33
|
+
"track_index": track_index,
|
|
34
|
+
"clip_index": clip_index,
|
|
35
|
+
})
|
|
36
|
+
return result.get("notes", [])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _detect_or_parse_key(notes: list[dict], key_hint: str | None = None) -> dict:
|
|
40
|
+
"""Detect key from notes, or parse the user's hint."""
|
|
41
|
+
if key_hint:
|
|
42
|
+
try:
|
|
43
|
+
return engine.parse_key(key_hint)
|
|
44
|
+
except ValueError:
|
|
45
|
+
pass
|
|
46
|
+
return engine.detect_key(notes)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _key_display(key_info: dict) -> str:
|
|
50
|
+
"""Format key info as 'C major' string."""
|
|
51
|
+
return f"{key_info['tonic_name']} {key_info['mode']}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -- Tool 1: analyze_harmony ------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
def analyze_harmony(
|
|
58
|
+
ctx: Context,
|
|
59
|
+
track_index: int,
|
|
60
|
+
clip_index: int,
|
|
61
|
+
key: Optional[str] = None,
|
|
62
|
+
) -> dict:
|
|
63
|
+
"""Analyze harmony of a MIDI clip: chords, Roman numerals, progression.
|
|
64
|
+
|
|
65
|
+
Reads notes directly from a session clip — no bouncing needed.
|
|
66
|
+
Auto-detects key if not provided.
|
|
67
|
+
|
|
68
|
+
Returns chord progression with Roman numeral analysis. The tool computes
|
|
69
|
+
the data; interpret the musical meaning yourself.
|
|
70
|
+
"""
|
|
71
|
+
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
72
|
+
if not notes:
|
|
73
|
+
return {"error": "No notes in clip", "suggestion": "Add notes first"}
|
|
74
|
+
|
|
75
|
+
key_info = _detect_or_parse_key(notes, key_hint=key)
|
|
76
|
+
tonic = key_info["tonic"]
|
|
77
|
+
mode = key_info["mode"]
|
|
78
|
+
|
|
79
|
+
chord_groups = engine.chordify(notes)
|
|
80
|
+
chords = []
|
|
81
|
+
|
|
82
|
+
for group in chord_groups:
|
|
83
|
+
pitches = group["pitches"]
|
|
84
|
+
pcs = group["pitch_classes"]
|
|
85
|
+
|
|
86
|
+
rn = engine.roman_numeral(pcs, tonic, mode)
|
|
87
|
+
cn = engine.chord_name(pitches)
|
|
88
|
+
|
|
89
|
+
entry = {
|
|
90
|
+
"beat": group["beat"],
|
|
91
|
+
"duration": group["duration"],
|
|
92
|
+
"pitches": [engine.pitch_name(p) for p in pitches],
|
|
93
|
+
"midi_pitches": pitches,
|
|
94
|
+
"chord_name": cn,
|
|
95
|
+
"roman_numeral": rn["figure"],
|
|
96
|
+
"figure": rn["figure"],
|
|
97
|
+
"quality": rn["quality"],
|
|
98
|
+
"inversion": rn["inversion"],
|
|
99
|
+
"scale_degree": rn["degree"] + 1,
|
|
100
|
+
}
|
|
101
|
+
chords.append(entry)
|
|
102
|
+
|
|
103
|
+
progression = " - ".join(c.get("figure", "?") for c in chords[:24])
|
|
104
|
+
|
|
105
|
+
key_result = {
|
|
106
|
+
"key": _key_display(key_info),
|
|
107
|
+
"confidence": key_info.get("confidence"),
|
|
108
|
+
}
|
|
109
|
+
if "alternatives" in key_info:
|
|
110
|
+
key_result["alternatives"] = [
|
|
111
|
+
f"{a['tonic_name']} {a['mode']}" for a in key_info["alternatives"][:3]
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"track_index": track_index,
|
|
116
|
+
"clip_index": clip_index,
|
|
117
|
+
**key_result,
|
|
118
|
+
"chord_count": len(chords),
|
|
119
|
+
"progression": progression,
|
|
120
|
+
"chords": chords[:32],
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# -- Tool 2: suggest_next_chord ---------------------------------------------
|
|
125
|
+
|
|
126
|
+
@mcp.tool()
|
|
127
|
+
def suggest_next_chord(
|
|
128
|
+
ctx: Context,
|
|
129
|
+
track_index: int,
|
|
130
|
+
clip_index: int,
|
|
131
|
+
key: Optional[str] = None,
|
|
132
|
+
style: str = "common_practice",
|
|
133
|
+
) -> dict:
|
|
134
|
+
"""Suggest the next chord based on the current progression.
|
|
135
|
+
|
|
136
|
+
Analyzes existing chords and suggests theory-valid continuations.
|
|
137
|
+
style: common_practice, jazz, modal, pop — affects which progressions
|
|
138
|
+
are preferred.
|
|
139
|
+
|
|
140
|
+
Returns concrete chord suggestions with pitches ready for add_notes.
|
|
141
|
+
"""
|
|
142
|
+
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
143
|
+
if not notes:
|
|
144
|
+
return {"error": "No notes in clip"}
|
|
145
|
+
|
|
146
|
+
key_info = _detect_or_parse_key(notes, key_hint=key)
|
|
147
|
+
tonic = key_info["tonic"]
|
|
148
|
+
mode = key_info["mode"]
|
|
149
|
+
|
|
150
|
+
chord_groups = engine.chordify(notes)
|
|
151
|
+
if not chord_groups:
|
|
152
|
+
return {"error": "No chords detected in clip"}
|
|
153
|
+
|
|
154
|
+
# Analyze last chord
|
|
155
|
+
last_group = chord_groups[-1]
|
|
156
|
+
last_rn = engine.roman_numeral(last_group["pitch_classes"], tonic, mode)
|
|
157
|
+
last_figure = last_rn["figure"]
|
|
158
|
+
|
|
159
|
+
# Progression maps by style
|
|
160
|
+
_progressions = {
|
|
161
|
+
"common_practice": {
|
|
162
|
+
"I": ["IV", "V", "vi", "ii"],
|
|
163
|
+
"ii": ["V", "vii\u00b0", "IV"],
|
|
164
|
+
"iii": ["vi", "IV", "ii"],
|
|
165
|
+
"IV": ["V", "I", "ii"],
|
|
166
|
+
"V": ["I", "vi", "IV"],
|
|
167
|
+
"vi": ["ii", "IV", "V", "I"],
|
|
168
|
+
"vii\u00b0": ["I", "iii"],
|
|
169
|
+
},
|
|
170
|
+
"jazz": {
|
|
171
|
+
"I": ["IV7", "ii7", "vi7", "bVII7"],
|
|
172
|
+
"ii7": ["V7", "bII7"],
|
|
173
|
+
"IV7": ["V7", "#ivo7", "bVII7"],
|
|
174
|
+
"V7": ["I", "vi", "bVI"],
|
|
175
|
+
"vi7": ["ii7", "IV7"],
|
|
176
|
+
},
|
|
177
|
+
"modal": {
|
|
178
|
+
"I": ["bVII", "IV", "v", "bIII"],
|
|
179
|
+
"IV": ["I", "bVII", "v"],
|
|
180
|
+
"v": ["bVII", "IV", "I"],
|
|
181
|
+
"bVII": ["I", "IV", "v"],
|
|
182
|
+
"bIII": ["IV", "bVII"],
|
|
183
|
+
},
|
|
184
|
+
"pop": {
|
|
185
|
+
"I": ["V", "vi", "IV"],
|
|
186
|
+
"ii": ["V", "IV"],
|
|
187
|
+
"IV": ["I", "V", "vi"],
|
|
188
|
+
"V": ["I", "vi", "IV"],
|
|
189
|
+
"vi": ["IV", "V", "I"],
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
style_map = _progressions.get(style, _progressions["common_practice"])
|
|
194
|
+
|
|
195
|
+
# Match the last chord to the closest key in the map
|
|
196
|
+
candidates = style_map.get(last_figure)
|
|
197
|
+
if not candidates:
|
|
198
|
+
for k in style_map:
|
|
199
|
+
if k.upper() == last_figure.upper():
|
|
200
|
+
candidates = style_map[k]
|
|
201
|
+
break
|
|
202
|
+
if not candidates:
|
|
203
|
+
candidates = style_map.get("I", ["IV", "V"])
|
|
204
|
+
|
|
205
|
+
# Build concrete suggestions with MIDI pitches
|
|
206
|
+
suggestions = []
|
|
207
|
+
for fig in candidates:
|
|
208
|
+
result = engine.roman_figure_to_pitches(fig, tonic, mode)
|
|
209
|
+
if "error" not in result:
|
|
210
|
+
suggestions.append({
|
|
211
|
+
"figure": fig,
|
|
212
|
+
"chord_name": engine.chord_name(result["midi_pitches"]),
|
|
213
|
+
"pitches": result["pitches"],
|
|
214
|
+
"midi_pitches": result["midi_pitches"],
|
|
215
|
+
"quality": result["quality"],
|
|
216
|
+
})
|
|
217
|
+
else:
|
|
218
|
+
suggestions.append({"figure": fig, "chord_name": fig})
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
"key": _key_display(key_info),
|
|
222
|
+
"last_chord": last_figure,
|
|
223
|
+
"style": style,
|
|
224
|
+
"suggestions": suggestions,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# -- Tool 3: detect_theory_issues -------------------------------------------
|
|
229
|
+
|
|
230
|
+
@mcp.tool()
|
|
231
|
+
def detect_theory_issues(
|
|
232
|
+
ctx: Context,
|
|
233
|
+
track_index: int,
|
|
234
|
+
clip_index: int,
|
|
235
|
+
key: Optional[str] = None,
|
|
236
|
+
strict: bool = False,
|
|
237
|
+
) -> dict:
|
|
238
|
+
"""Detect music theory issues: parallel fifths/octaves, out-of-key notes,
|
|
239
|
+
voice crossing, unresolved dominants.
|
|
240
|
+
|
|
241
|
+
strict=False: Only clear errors (parallels, out-of-key).
|
|
242
|
+
strict=True: Also flag style issues (large leaps, missing resolution).
|
|
243
|
+
|
|
244
|
+
Returns ranked issues with beat positions.
|
|
245
|
+
"""
|
|
246
|
+
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
247
|
+
if not notes:
|
|
248
|
+
return {"error": "No notes in clip"}
|
|
249
|
+
|
|
250
|
+
key_info = _detect_or_parse_key(notes, key_hint=key)
|
|
251
|
+
tonic = key_info["tonic"]
|
|
252
|
+
mode = key_info["mode"]
|
|
253
|
+
scale_pcs = set(engine.get_scale_pitches(tonic, mode))
|
|
254
|
+
|
|
255
|
+
issues = []
|
|
256
|
+
|
|
257
|
+
# 1. Out-of-key notes
|
|
258
|
+
for n in notes:
|
|
259
|
+
if n.get("mute", False):
|
|
260
|
+
continue
|
|
261
|
+
if n["pitch"] % 12 not in scale_pcs:
|
|
262
|
+
issues.append({
|
|
263
|
+
"type": "out_of_key",
|
|
264
|
+
"severity": "warning",
|
|
265
|
+
"beat": round(n["start_time"], 3),
|
|
266
|
+
"detail": f"{engine.pitch_name(n['pitch'])} not in {_key_display(key_info)}",
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
# 2. Parallel fifths/octaves and voice crossing
|
|
270
|
+
chord_groups = engine.chordify(notes)
|
|
271
|
+
for i in range(1, len(chord_groups)):
|
|
272
|
+
prev_pitches = chord_groups[i - 1]["pitches"]
|
|
273
|
+
curr_pitches = chord_groups[i]["pitches"]
|
|
274
|
+
beat = chord_groups[i]["beat"]
|
|
275
|
+
|
|
276
|
+
vl_issues = engine.check_voice_leading(prev_pitches, curr_pitches)
|
|
277
|
+
for vl in vl_issues:
|
|
278
|
+
severity = "error" if vl["type"] in ("parallel_fifths", "parallel_octaves") else "warning"
|
|
279
|
+
if vl["type"] == "hidden_fifth":
|
|
280
|
+
severity = "info"
|
|
281
|
+
if not strict:
|
|
282
|
+
continue
|
|
283
|
+
detail_map = {
|
|
284
|
+
"parallel_fifths": "Parallel fifths in outer voices",
|
|
285
|
+
"parallel_octaves": "Parallel octaves in outer voices",
|
|
286
|
+
"voice_crossing": "Voice crossing detected",
|
|
287
|
+
"hidden_fifth": "Hidden fifth in outer voices",
|
|
288
|
+
}
|
|
289
|
+
issues.append({
|
|
290
|
+
"type": vl["type"],
|
|
291
|
+
"severity": severity,
|
|
292
|
+
"beat": round(beat, 3),
|
|
293
|
+
"detail": detail_map.get(vl["type"], vl["type"]),
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
# 3. Unresolved dominant (strict mode)
|
|
297
|
+
if strict:
|
|
298
|
+
for i in range(len(chord_groups) - 1):
|
|
299
|
+
rn = engine.roman_numeral(chord_groups[i]["pitch_classes"], tonic, mode)
|
|
300
|
+
next_rn = engine.roman_numeral(chord_groups[i + 1]["pitch_classes"], tonic, mode)
|
|
301
|
+
if rn["figure"] in ('V', 'V7') and next_rn["figure"] not in ('I', 'i', 'vi', 'VI'):
|
|
302
|
+
issues.append({
|
|
303
|
+
"type": "unresolved_dominant",
|
|
304
|
+
"severity": "info",
|
|
305
|
+
"beat": round(chord_groups[i]["beat"], 3),
|
|
306
|
+
"detail": f"{rn['figure']} resolves to {next_rn['figure']} instead of tonic",
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
# 4. Large leaps without resolution (strict mode)
|
|
310
|
+
if strict:
|
|
311
|
+
sorted_notes = sorted(
|
|
312
|
+
[n for n in notes if not n.get("mute", False)],
|
|
313
|
+
key=lambda n: n["start_time"],
|
|
314
|
+
)
|
|
315
|
+
for i in range(1, len(sorted_notes)):
|
|
316
|
+
leap = abs(sorted_notes[i]["pitch"] - sorted_notes[i - 1]["pitch"])
|
|
317
|
+
if leap > 7:
|
|
318
|
+
issues.append({
|
|
319
|
+
"type": "large_leap",
|
|
320
|
+
"severity": "info",
|
|
321
|
+
"beat": round(sorted_notes[i]["start_time"], 3),
|
|
322
|
+
"detail": f"{leap} semitone leap",
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
severity_order = {"error": 0, "warning": 1, "info": 2}
|
|
326
|
+
issues.sort(key=lambda x: (severity_order.get(x["severity"], 3), x.get("beat", 0)))
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
"key": _key_display(key_info),
|
|
330
|
+
"strict_mode": strict,
|
|
331
|
+
"issue_count": len(issues),
|
|
332
|
+
"errors": sum(1 for i in issues if i["severity"] == "error"),
|
|
333
|
+
"warnings": sum(1 for i in issues if i["severity"] == "warning"),
|
|
334
|
+
"issues": issues[:30],
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# -- Tool 4: identify_scale -------------------------------------------------
|
|
339
|
+
|
|
340
|
+
@mcp.tool()
|
|
341
|
+
def identify_scale(
|
|
342
|
+
ctx: Context,
|
|
343
|
+
track_index: int,
|
|
344
|
+
clip_index: int,
|
|
345
|
+
) -> dict:
|
|
346
|
+
"""Identify the scale/mode of a MIDI clip beyond basic major/minor.
|
|
347
|
+
|
|
348
|
+
Uses Krumhansl-Schmuckler algorithm with 7 mode profiles (major, minor,
|
|
349
|
+
dorian, phrygian, lydian, mixolydian, locrian).
|
|
350
|
+
|
|
351
|
+
Returns ranked key matches with confidence scores.
|
|
352
|
+
"""
|
|
353
|
+
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
354
|
+
if not notes:
|
|
355
|
+
return {"error": "No notes in clip"}
|
|
356
|
+
|
|
357
|
+
detected = engine.detect_key(notes, mode_detection=True)
|
|
358
|
+
|
|
359
|
+
results = [{
|
|
360
|
+
"key": f"{detected['tonic_name']} {detected['mode']}",
|
|
361
|
+
"confidence": detected["confidence"],
|
|
362
|
+
"mode": detected["mode"],
|
|
363
|
+
"tonic": detected["tonic_name"],
|
|
364
|
+
}]
|
|
365
|
+
|
|
366
|
+
for alt in detected.get("alternatives", [])[:7]:
|
|
367
|
+
results.append({
|
|
368
|
+
"key": f"{alt['tonic_name']} {alt['mode']}",
|
|
369
|
+
"confidence": alt["confidence"],
|
|
370
|
+
"mode": alt["mode"],
|
|
371
|
+
"tonic": alt["tonic_name"],
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
# Pitch class usage for context
|
|
375
|
+
pitch_classes = defaultdict(float)
|
|
376
|
+
for n in notes:
|
|
377
|
+
if not n.get("mute", False):
|
|
378
|
+
pitch_classes[n["pitch"] % 12] += n["duration"]
|
|
379
|
+
|
|
380
|
+
pc_usage = {
|
|
381
|
+
engine.NOTE_NAMES[pc]: round(dur, 3)
|
|
382
|
+
for pc, dur in sorted(pitch_classes.items())
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
"top_match": results[0] if results else None,
|
|
387
|
+
"alternatives": results[1:],
|
|
388
|
+
"pitch_classes_used": len(pitch_classes),
|
|
389
|
+
"pitch_class_weights": pc_usage,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# -- Tool 5: harmonize_melody -----------------------------------------------
|
|
394
|
+
|
|
395
|
+
@mcp.tool()
|
|
396
|
+
def harmonize_melody(
|
|
397
|
+
ctx: Context,
|
|
398
|
+
track_index: int,
|
|
399
|
+
clip_index: int,
|
|
400
|
+
key: Optional[str] = None,
|
|
401
|
+
voices: int = 4,
|
|
402
|
+
) -> dict:
|
|
403
|
+
"""Generate a multi-voice harmonization of a melody from a MIDI clip.
|
|
404
|
+
|
|
405
|
+
Finds diatonic chords containing each melody note and voices them
|
|
406
|
+
following basic voice leading rules (smooth bass motion, no crossing).
|
|
407
|
+
|
|
408
|
+
voices: 2 (melody + bass) or 4 (SATB). Default 4.
|
|
409
|
+
Returns note data ready for add_notes on new tracks.
|
|
410
|
+
|
|
411
|
+
Processing time: 2-5s.
|
|
412
|
+
"""
|
|
413
|
+
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
414
|
+
if not notes:
|
|
415
|
+
return {"error": "No notes in clip"}
|
|
416
|
+
|
|
417
|
+
melody = sorted(
|
|
418
|
+
[n for n in notes if not n.get("mute", False)],
|
|
419
|
+
key=lambda n: n["start_time"],
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
key_info = _detect_or_parse_key(melody, key_hint=key)
|
|
423
|
+
tonic = key_info["tonic"]
|
|
424
|
+
mode = key_info["mode"]
|
|
425
|
+
|
|
426
|
+
result_voices = {"soprano": [], "bass": []}
|
|
427
|
+
if voices == 4:
|
|
428
|
+
result_voices["alto"] = []
|
|
429
|
+
result_voices["tenor"] = []
|
|
430
|
+
|
|
431
|
+
prev_bass_midi = None
|
|
432
|
+
|
|
433
|
+
for n in melody:
|
|
434
|
+
melody_pitch = n["pitch"]
|
|
435
|
+
beat = n["start_time"]
|
|
436
|
+
dur = n["duration"]
|
|
437
|
+
mel_pc = melody_pitch % 12
|
|
438
|
+
|
|
439
|
+
# Find the best diatonic chord containing this pitch
|
|
440
|
+
best_chord = None
|
|
441
|
+
for degree in [0, 3, 4, 5, 1, 2, 6]: # I, IV, V, vi, ii, iii, vii
|
|
442
|
+
chord = engine.build_chord(degree, tonic, mode)
|
|
443
|
+
if mel_pc in chord["pitch_classes"]:
|
|
444
|
+
best_chord = chord
|
|
445
|
+
break
|
|
446
|
+
|
|
447
|
+
if best_chord is None:
|
|
448
|
+
best_chord = engine.build_chord(0, tonic, mode)
|
|
449
|
+
|
|
450
|
+
# Build MIDI pitches for the chord
|
|
451
|
+
chord_midis = sorted([
|
|
452
|
+
60 + ((pc - best_chord["root_pc"]) % 12) + best_chord["root_pc"]
|
|
453
|
+
for pc in best_chord["pitch_classes"]
|
|
454
|
+
])
|
|
455
|
+
|
|
456
|
+
# Bass: root in low octave, smooth motion preferred
|
|
457
|
+
bass = 36 + best_chord["root_pc"]
|
|
458
|
+
if bass > 52:
|
|
459
|
+
bass -= 12
|
|
460
|
+
if bass < 36:
|
|
461
|
+
bass += 12
|
|
462
|
+
if prev_bass_midi is not None:
|
|
463
|
+
options = [bass, bass - 12, bass + 12]
|
|
464
|
+
options = [b for b in options if 33 <= b <= 55]
|
|
465
|
+
if options:
|
|
466
|
+
bass = min(options, key=lambda b: abs(b - prev_bass_midi))
|
|
467
|
+
prev_bass_midi = bass
|
|
468
|
+
|
|
469
|
+
vel = n.get("velocity", 100)
|
|
470
|
+
|
|
471
|
+
result_voices["soprano"].append({
|
|
472
|
+
"pitch": melody_pitch, "start_time": beat,
|
|
473
|
+
"duration": dur, "velocity": vel,
|
|
474
|
+
})
|
|
475
|
+
result_voices["bass"].append({
|
|
476
|
+
"pitch": bass, "start_time": beat,
|
|
477
|
+
"duration": dur, "velocity": int(vel * 0.8),
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
if voices == 4 and len(chord_midis) >= 2:
|
|
481
|
+
# Alto: chord tone near soprano
|
|
482
|
+
alto = chord_midis[1] if len(chord_midis) > 1 else chord_midis[0]
|
|
483
|
+
while alto < melody_pitch - 14:
|
|
484
|
+
alto += 12
|
|
485
|
+
while alto >= melody_pitch:
|
|
486
|
+
alto -= 12
|
|
487
|
+
if alto < bass:
|
|
488
|
+
alto += 12
|
|
489
|
+
|
|
490
|
+
# Tenor: chord tone between bass and alto
|
|
491
|
+
tenor = chord_midis[2] if len(chord_midis) > 2 else chord_midis[0]
|
|
492
|
+
while tenor < bass:
|
|
493
|
+
tenor += 12
|
|
494
|
+
while tenor >= alto:
|
|
495
|
+
tenor -= 12
|
|
496
|
+
if tenor < bass:
|
|
497
|
+
tenor = bass + (alto - bass) // 2
|
|
498
|
+
|
|
499
|
+
result_voices["alto"].append({
|
|
500
|
+
"pitch": max(36, min(96, alto)), "start_time": beat,
|
|
501
|
+
"duration": dur, "velocity": int(vel * 0.7),
|
|
502
|
+
})
|
|
503
|
+
result_voices["tenor"].append({
|
|
504
|
+
"pitch": max(36, min(96, tenor)), "start_time": beat,
|
|
505
|
+
"duration": dur, "velocity": int(vel * 0.7),
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
result = {
|
|
509
|
+
"key": _key_display(key_info),
|
|
510
|
+
"voices": voices,
|
|
511
|
+
"melody_notes": len(melody),
|
|
512
|
+
}
|
|
513
|
+
for voice_name, voice_notes in result_voices.items():
|
|
514
|
+
if voice_notes:
|
|
515
|
+
result[voice_name] = voice_notes
|
|
516
|
+
|
|
517
|
+
return result
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# -- Tool 6: generate_countermelody -----------------------------------------
|
|
521
|
+
|
|
522
|
+
@mcp.tool()
|
|
523
|
+
def generate_countermelody(
|
|
524
|
+
ctx: Context,
|
|
525
|
+
track_index: int,
|
|
526
|
+
clip_index: int,
|
|
527
|
+
key: Optional[str] = None,
|
|
528
|
+
species: int = 1,
|
|
529
|
+
range_low: int = 48,
|
|
530
|
+
range_high: int = 72,
|
|
531
|
+
seed: int = 0,
|
|
532
|
+
) -> dict:
|
|
533
|
+
"""Generate a countermelody using species counterpoint rules.
|
|
534
|
+
|
|
535
|
+
species: 1 (note-against-note), 2 (2 notes per melody note).
|
|
536
|
+
Follows strict rules: no parallel fifths/octaves, contrary motion
|
|
537
|
+
preferred, consonant intervals on strong beats.
|
|
538
|
+
|
|
539
|
+
Returns note data ready for add_notes on a new track.
|
|
540
|
+
Processing time: 2-5s.
|
|
541
|
+
"""
|
|
542
|
+
random.seed(seed)
|
|
543
|
+
|
|
544
|
+
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
545
|
+
if not notes:
|
|
546
|
+
return {"error": "No notes in clip"}
|
|
547
|
+
|
|
548
|
+
melody = sorted(
|
|
549
|
+
[n for n in notes if not n.get("mute", False)],
|
|
550
|
+
key=lambda n: n["start_time"],
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
key_info = _detect_or_parse_key(melody, key_hint=key)
|
|
554
|
+
scale_pcs = set(engine.get_scale_pitches(key_info["tonic"], key_info["mode"]))
|
|
555
|
+
|
|
556
|
+
# Build pool of scale pitches in range
|
|
557
|
+
pool = [p for p in range(range_low, range_high + 1) if p % 12 in scale_pcs]
|
|
558
|
+
if not pool:
|
|
559
|
+
return {"error": "No scale pitches in given range"}
|
|
560
|
+
|
|
561
|
+
# Consonant intervals (semitones mod 12): P1, m3, M3, P4, P5, m6, M6, P8
|
|
562
|
+
consonant = {0, 3, 4, 5, 7, 8, 9}
|
|
563
|
+
|
|
564
|
+
counter_notes = []
|
|
565
|
+
prev_cp = None
|
|
566
|
+
|
|
567
|
+
for i, n in enumerate(melody):
|
|
568
|
+
mel_pitch = n["pitch"]
|
|
569
|
+
beat = n["start_time"]
|
|
570
|
+
dur = n["duration"] / species
|
|
571
|
+
|
|
572
|
+
for s_idx in range(species):
|
|
573
|
+
scored = []
|
|
574
|
+
for cp in pool:
|
|
575
|
+
iv = abs(cp - mel_pitch) % 12
|
|
576
|
+
if iv not in consonant:
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
score = 0.0
|
|
580
|
+
|
|
581
|
+
# Contrary motion bonus
|
|
582
|
+
if prev_cp is not None and i > 0:
|
|
583
|
+
mel_dir = mel_pitch - melody[i - 1]["pitch"]
|
|
584
|
+
cp_dir = cp - prev_cp
|
|
585
|
+
if (mel_dir > 0 and cp_dir < 0) or (mel_dir < 0 and cp_dir > 0):
|
|
586
|
+
score += 10
|
|
587
|
+
# Penalize parallel perfect intervals
|
|
588
|
+
prev_iv = abs(prev_cp - melody[i - 1]["pitch"]) % 12
|
|
589
|
+
if prev_iv == iv and iv in (0, 7):
|
|
590
|
+
score -= 50
|
|
591
|
+
|
|
592
|
+
# Stepwise motion bonus
|
|
593
|
+
if prev_cp is not None:
|
|
594
|
+
step = abs(cp - prev_cp)
|
|
595
|
+
if step <= 2:
|
|
596
|
+
score += 5
|
|
597
|
+
elif step <= 4:
|
|
598
|
+
score += 2
|
|
599
|
+
elif step > 7:
|
|
600
|
+
score -= 3
|
|
601
|
+
else:
|
|
602
|
+
score += 3
|
|
603
|
+
|
|
604
|
+
score += random.uniform(0, 2)
|
|
605
|
+
scored.append((cp, score))
|
|
606
|
+
|
|
607
|
+
if not scored:
|
|
608
|
+
scored = [(random.choice(pool), 0)]
|
|
609
|
+
|
|
610
|
+
scored.sort(key=lambda x: -x[1])
|
|
611
|
+
chosen = scored[0][0]
|
|
612
|
+
|
|
613
|
+
counter_notes.append({
|
|
614
|
+
"pitch": chosen,
|
|
615
|
+
"start_time": round(beat + s_idx * dur, 4),
|
|
616
|
+
"duration": round(dur, 4),
|
|
617
|
+
"velocity": 80 if s_idx == 0 else 65,
|
|
618
|
+
})
|
|
619
|
+
prev_cp = chosen
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
"key": _key_display(key_info),
|
|
623
|
+
"species": species,
|
|
624
|
+
"melody_notes": len(melody),
|
|
625
|
+
"counter_notes": counter_notes,
|
|
626
|
+
"counter_note_count": len(counter_notes),
|
|
627
|
+
"range": f"{engine.pitch_name(range_low)}-{engine.pitch_name(range_high)}",
|
|
628
|
+
"seed": seed,
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# -- Tool 7: transpose_smart ------------------------------------------------
|
|
633
|
+
|
|
634
|
+
@mcp.tool()
|
|
635
|
+
def transpose_smart(
|
|
636
|
+
ctx: Context,
|
|
637
|
+
track_index: int,
|
|
638
|
+
clip_index: int,
|
|
639
|
+
target_key: str,
|
|
640
|
+
mode: str = "diatonic",
|
|
641
|
+
) -> dict:
|
|
642
|
+
"""Transpose a MIDI clip to a new key with musical intelligence.
|
|
643
|
+
|
|
644
|
+
mode:
|
|
645
|
+
- diatonic: Maps scale degrees (C major -> G major keeps intervals
|
|
646
|
+
relative to the scale). Chromatic notes shift by tonic distance.
|
|
647
|
+
- chromatic: Simple semitone shift (preserves exact intervals).
|
|
648
|
+
|
|
649
|
+
Returns transposed note data ready for add_notes or modify_notes.
|
|
650
|
+
"""
|
|
651
|
+
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
652
|
+
if not notes:
|
|
653
|
+
return {"error": "No notes in clip"}
|
|
654
|
+
|
|
655
|
+
source_key = engine.detect_key(notes)
|
|
656
|
+
|
|
657
|
+
try:
|
|
658
|
+
target = engine.parse_key(target_key)
|
|
659
|
+
except ValueError:
|
|
660
|
+
return {"error": f"Invalid target key: {target_key}"}
|
|
661
|
+
|
|
662
|
+
source_tonic = source_key["tonic"]
|
|
663
|
+
target_tonic = target["tonic"]
|
|
664
|
+
semitone_shift = target_tonic - source_tonic
|
|
665
|
+
|
|
666
|
+
if mode == "chromatic":
|
|
667
|
+
transposed = []
|
|
668
|
+
for n in notes:
|
|
669
|
+
tn = dict(n)
|
|
670
|
+
new_pitch = n["pitch"] + semitone_shift
|
|
671
|
+
tn["pitch"] = max(0, min(127, new_pitch))
|
|
672
|
+
transposed.append(tn)
|
|
673
|
+
else:
|
|
674
|
+
# Diatonic: map scale degrees
|
|
675
|
+
source_mode = source_key["mode"]
|
|
676
|
+
target_mode = target.get("mode", source_mode)
|
|
677
|
+
source_pcs = engine.get_scale_pitches(source_tonic, source_mode)
|
|
678
|
+
target_pcs = engine.get_scale_pitches(target_tonic, target_mode)
|
|
679
|
+
|
|
680
|
+
degree_map = {}
|
|
681
|
+
for i in range(min(len(source_pcs), len(target_pcs))):
|
|
682
|
+
degree_map[source_pcs[i]] = target_pcs[i]
|
|
683
|
+
|
|
684
|
+
transposed = []
|
|
685
|
+
for n in notes:
|
|
686
|
+
tn = dict(n)
|
|
687
|
+
pc = n["pitch"] % 12
|
|
688
|
+
octave = n["pitch"] // 12
|
|
689
|
+
|
|
690
|
+
if pc in degree_map:
|
|
691
|
+
new_pc = degree_map[pc]
|
|
692
|
+
new_pitch = octave * 12 + new_pc
|
|
693
|
+
# Adjust if the shift crossed an octave boundary
|
|
694
|
+
if abs(new_pitch - (n["pitch"] + semitone_shift)) > 6:
|
|
695
|
+
if new_pitch < n["pitch"] + semitone_shift:
|
|
696
|
+
new_pitch += 12
|
|
697
|
+
else:
|
|
698
|
+
new_pitch -= 12
|
|
699
|
+
else:
|
|
700
|
+
new_pitch = n["pitch"] + semitone_shift
|
|
701
|
+
|
|
702
|
+
tn["pitch"] = max(0, min(127, new_pitch))
|
|
703
|
+
transposed.append(tn)
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
"source_key": _key_display(source_key),
|
|
707
|
+
"target_key": f"{engine.NOTE_NAMES[target_tonic]} {target.get('mode', 'major')}",
|
|
708
|
+
"mode": mode,
|
|
709
|
+
"semitone_shift": semitone_shift,
|
|
710
|
+
"note_count": len(transposed),
|
|
711
|
+
"notes": transposed,
|
|
712
|
+
}
|