livepilot 1.6.4 → 1.7.0
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 +43 -7
- package/README.md +21 -6
- 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/_theory_engine.py +366 -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/mcp_server/tools/theory.py +160 -320
- package/package.json +2 -2
- package/plugin/plugin.json +2 -2
- package/plugin/skills/livepilot-core/SKILL.md +46 -8
- package/plugin/skills/livepilot-core/references/overview.md +5 -5
- package/remote_script/LivePilot/__init__.py +2 -2
- package/requirements.txt +3 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Music theory tools
|
|
1
|
+
"""Music theory tools — pure Python, zero dependencies.
|
|
2
2
|
|
|
3
3
|
7 tools for harmonic analysis, chord suggestion, voice leading detection,
|
|
4
4
|
counterpoint generation, scale identification, harmonization, and intelligent
|
|
@@ -7,18 +7,18 @@ transposition — all working directly on live session clip data via get_notes.
|
|
|
7
7
|
Design principle: tools compute from data, the LLM interprets and explains.
|
|
8
8
|
Returns precise musical data (Roman numerals, pitch names, intervals), never
|
|
9
9
|
explanations the LLM already knows from training.
|
|
10
|
-
|
|
11
|
-
Requires: pip install music21 (lazy-imported, never at module level)
|
|
12
10
|
"""
|
|
13
11
|
|
|
14
12
|
from __future__ import annotations
|
|
15
13
|
|
|
14
|
+
import random
|
|
16
15
|
from collections import defaultdict
|
|
17
16
|
from typing import Optional
|
|
18
17
|
|
|
19
18
|
from fastmcp import Context
|
|
20
19
|
|
|
21
20
|
from ..server import mcp
|
|
21
|
+
from . import _theory_engine as engine
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
# -- Shared utilities --------------------------------------------------------
|
|
@@ -36,99 +36,19 @@ def _get_clip_notes(ctx: Context, track_index: int, clip_index: int) -> list[dic
|
|
|
36
36
|
return result.get("notes", [])
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
Accepts: "C", "c", "C major", "A minor", "g minor", "F# major", etc.
|
|
43
|
-
music21's Key() wants: uppercase tonic = major, lowercase = minor.
|
|
44
|
-
"""
|
|
45
|
-
from music21 import key
|
|
46
|
-
hint = key_str.strip()
|
|
47
|
-
if ' ' in hint:
|
|
48
|
-
parts = hint.split()
|
|
49
|
-
tonic = parts[0]
|
|
50
|
-
mode = parts[1].lower() if len(parts) > 1 else 'major'
|
|
51
|
-
if mode == 'minor':
|
|
52
|
-
tonic = tonic[0].lower() + tonic[1:]
|
|
53
|
-
return key.Key(tonic)
|
|
54
|
-
return key.Key(hint)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _notes_to_stream(notes: list[dict], key_hint: str | None = None):
|
|
58
|
-
"""Convert LivePilot note dicts to a music21 Stream.
|
|
59
|
-
|
|
60
|
-
This is the bridge between Ableton's note format and music21's
|
|
61
|
-
analysis engine. Groups simultaneous notes into Chord objects.
|
|
62
|
-
Quantizes start_times to 1/32 note resolution to avoid chordify
|
|
63
|
-
fragmentation from micro-timing variations.
|
|
64
|
-
"""
|
|
65
|
-
from music21 import stream, note, chord, meter
|
|
66
|
-
|
|
67
|
-
s = stream.Part()
|
|
68
|
-
s.append(meter.TimeSignature('4/4'))
|
|
69
|
-
|
|
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."""
|
|
70
41
|
if key_hint:
|
|
71
42
|
try:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
except Exception:
|
|
43
|
+
return engine.parse_key(key_hint)
|
|
44
|
+
except ValueError:
|
|
75
45
|
pass
|
|
46
|
+
return engine.detect_key(notes)
|
|
76
47
|
|
|
77
|
-
# Quantize to 1/32 note (0.125 beats) to group near-simultaneous notes
|
|
78
|
-
QUANT = 0.125
|
|
79
|
-
|
|
80
|
-
time_groups: dict[float, list[dict]] = defaultdict(list)
|
|
81
|
-
for n in notes:
|
|
82
|
-
if n.get("mute", False):
|
|
83
|
-
continue
|
|
84
|
-
q_time = round(n["start_time"] / QUANT) * QUANT
|
|
85
|
-
time_groups[q_time].append(n)
|
|
86
|
-
|
|
87
|
-
for t in sorted(time_groups.keys()):
|
|
88
|
-
group = time_groups[t]
|
|
89
|
-
if len(group) == 1:
|
|
90
|
-
n = group[0]
|
|
91
|
-
m21_note = note.Note(n["pitch"])
|
|
92
|
-
m21_note.quarterLength = max(QUANT, n["duration"])
|
|
93
|
-
m21_note.volume.velocity = n.get("velocity", 100)
|
|
94
|
-
s.insert(t, m21_note)
|
|
95
|
-
else:
|
|
96
|
-
pitches = sorted(set(n["pitch"] for n in group))
|
|
97
|
-
dur = max(n["duration"] for n in group)
|
|
98
|
-
m21_chord = chord.Chord(pitches)
|
|
99
|
-
m21_chord.quarterLength = max(QUANT, dur)
|
|
100
|
-
s.insert(t, m21_chord)
|
|
101
|
-
|
|
102
|
-
return s
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _detect_key(s):
|
|
106
|
-
"""Detect key from a music21 stream. Uses Krumhansl-Schmuckler algorithm."""
|
|
107
|
-
from music21 import key as m21key
|
|
108
|
-
|
|
109
|
-
# Check if key was already set by the user
|
|
110
|
-
existing = list(s.recurse().getElementsByClass(m21key.Key))
|
|
111
|
-
if existing:
|
|
112
|
-
return existing[0]
|
|
113
|
-
|
|
114
|
-
return s.analyze('key')
|
|
115
48
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
"
|
|
119
|
-
from music21 import pitch
|
|
120
|
-
return str(pitch.Pitch(midi_num))
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _require_music21():
|
|
124
|
-
"""Verify music21 is installed, raise clear error if not."""
|
|
125
|
-
try:
|
|
126
|
-
import music21 # noqa: F401
|
|
127
|
-
except ImportError:
|
|
128
|
-
raise ImportError(
|
|
129
|
-
"music21 is required for theory tools. "
|
|
130
|
-
"Install with: pip install 'music21>=9.3'"
|
|
131
|
-
)
|
|
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']}"
|
|
132
52
|
|
|
133
53
|
|
|
134
54
|
# -- Tool 1: analyze_harmony ------------------------------------------------
|
|
@@ -148,54 +68,53 @@ def analyze_harmony(
|
|
|
148
68
|
Returns chord progression with Roman numeral analysis. The tool computes
|
|
149
69
|
the data; interpret the musical meaning yourself.
|
|
150
70
|
"""
|
|
151
|
-
_require_music21()
|
|
152
|
-
from music21 import roman
|
|
153
|
-
|
|
154
71
|
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
155
72
|
if not notes:
|
|
156
73
|
return {"error": "No notes in clip", "suggestion": "Add notes first"}
|
|
157
74
|
|
|
158
|
-
|
|
159
|
-
|
|
75
|
+
key_info = _detect_or_parse_key(notes, key_hint=key)
|
|
76
|
+
tonic = key_info["tonic"]
|
|
77
|
+
mode = key_info["mode"]
|
|
160
78
|
|
|
161
|
-
|
|
79
|
+
chord_groups = engine.chordify(notes)
|
|
162
80
|
chords = []
|
|
163
81
|
|
|
164
|
-
for
|
|
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
|
+
|
|
165
89
|
entry = {
|
|
166
|
-
"beat":
|
|
167
|
-
"duration":
|
|
168
|
-
"pitches": [
|
|
169
|
-
"midi_pitches":
|
|
170
|
-
"chord_name":
|
|
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,
|
|
171
100
|
}
|
|
172
|
-
try:
|
|
173
|
-
rn = roman.romanNumeralFromChord(c, detected_key)
|
|
174
|
-
entry["roman_numeral"] = rn.romanNumeral
|
|
175
|
-
entry["figure"] = rn.figure
|
|
176
|
-
entry["quality"] = rn.quality
|
|
177
|
-
entry["inversion"] = rn.inversion()
|
|
178
|
-
entry["scale_degree"] = rn.scaleDegree
|
|
179
|
-
except Exception:
|
|
180
|
-
entry["roman_numeral"] = "?"
|
|
181
|
-
entry["figure"] = "?"
|
|
182
|
-
|
|
183
101
|
chords.append(entry)
|
|
184
102
|
|
|
185
103
|
progression = " - ".join(c.get("figure", "?") for c in chords[:24])
|
|
186
104
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
]
|
|
194
113
|
|
|
195
114
|
return {
|
|
196
115
|
"track_index": track_index,
|
|
197
116
|
"clip_index": clip_index,
|
|
198
|
-
**
|
|
117
|
+
**key_result,
|
|
199
118
|
"chord_count": len(chords),
|
|
200
119
|
"progression": progression,
|
|
201
120
|
"chords": chords[:32],
|
|
@@ -220,40 +139,33 @@ def suggest_next_chord(
|
|
|
220
139
|
|
|
221
140
|
Returns concrete chord suggestions with pitches ready for add_notes.
|
|
222
141
|
"""
|
|
223
|
-
_require_music21()
|
|
224
|
-
from music21 import roman
|
|
225
|
-
|
|
226
142
|
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
227
143
|
if not notes:
|
|
228
144
|
return {"error": "No notes in clip"}
|
|
229
145
|
|
|
230
|
-
|
|
231
|
-
|
|
146
|
+
key_info = _detect_or_parse_key(notes, key_hint=key)
|
|
147
|
+
tonic = key_info["tonic"]
|
|
148
|
+
mode = key_info["mode"]
|
|
232
149
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
chord_list = list(chordified.recurse().getElementsByClass('Chord'))
|
|
236
|
-
if not chord_list:
|
|
150
|
+
chord_groups = engine.chordify(notes)
|
|
151
|
+
if not chord_groups:
|
|
237
152
|
return {"error": "No chords detected in clip"}
|
|
238
153
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
last_figure = last_rn.romanNumeral
|
|
244
|
-
except Exception:
|
|
245
|
-
last_rn = None
|
|
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"]
|
|
246
158
|
|
|
247
159
|
# Progression maps by style
|
|
248
160
|
_progressions = {
|
|
249
161
|
"common_practice": {
|
|
250
162
|
"I": ["IV", "V", "vi", "ii"],
|
|
251
|
-
"ii": ["V", "
|
|
163
|
+
"ii": ["V", "vii\u00b0", "IV"],
|
|
252
164
|
"iii": ["vi", "IV", "ii"],
|
|
253
165
|
"IV": ["V", "I", "ii"],
|
|
254
166
|
"V": ["I", "vi", "IV"],
|
|
255
167
|
"vi": ["ii", "IV", "V", "I"],
|
|
256
|
-
"
|
|
168
|
+
"vii\u00b0": ["I", "iii"],
|
|
257
169
|
},
|
|
258
170
|
"jazz": {
|
|
259
171
|
"I": ["IV7", "ii7", "vi7", "bVII7"],
|
|
@@ -283,7 +195,6 @@ def suggest_next_chord(
|
|
|
283
195
|
# Match the last chord to the closest key in the map
|
|
284
196
|
candidates = style_map.get(last_figure)
|
|
285
197
|
if not candidates:
|
|
286
|
-
# Try uppercase/lowercase variants
|
|
287
198
|
for k in style_map:
|
|
288
199
|
if k.upper() == last_figure.upper():
|
|
289
200
|
candidates = style_map[k]
|
|
@@ -294,21 +205,21 @@ def suggest_next_chord(
|
|
|
294
205
|
# Build concrete suggestions with MIDI pitches
|
|
295
206
|
suggestions = []
|
|
296
207
|
for fig in candidates:
|
|
297
|
-
|
|
298
|
-
|
|
208
|
+
result = engine.roman_figure_to_pitches(fig, tonic, mode)
|
|
209
|
+
if "error" not in result:
|
|
299
210
|
suggestions.append({
|
|
300
211
|
"figure": fig,
|
|
301
|
-
"chord_name":
|
|
302
|
-
"pitches": [
|
|
303
|
-
"midi_pitches": [
|
|
304
|
-
"quality":
|
|
212
|
+
"chord_name": engine.chord_name(result["midi_pitches"]),
|
|
213
|
+
"pitches": result["pitches"],
|
|
214
|
+
"midi_pitches": result["midi_pitches"],
|
|
215
|
+
"quality": result["quality"],
|
|
305
216
|
})
|
|
306
|
-
|
|
217
|
+
else:
|
|
307
218
|
suggestions.append({"figure": fig, "chord_name": fig})
|
|
308
219
|
|
|
309
220
|
return {
|
|
310
|
-
"key":
|
|
311
|
-
"last_chord":
|
|
221
|
+
"key": _key_display(key_info),
|
|
222
|
+
"last_chord": last_figure,
|
|
312
223
|
"style": style,
|
|
313
224
|
"suggestions": suggestions,
|
|
314
225
|
}
|
|
@@ -330,21 +241,16 @@ def detect_theory_issues(
|
|
|
330
241
|
strict=False: Only clear errors (parallels, out-of-key).
|
|
331
242
|
strict=True: Also flag style issues (large leaps, missing resolution).
|
|
332
243
|
|
|
333
|
-
Uses music21's VoiceLeadingQuartet for accurate parallel detection.
|
|
334
244
|
Returns ranked issues with beat positions.
|
|
335
245
|
"""
|
|
336
|
-
_require_music21()
|
|
337
|
-
from music21 import roman, voiceLeading, note as m21note
|
|
338
|
-
|
|
339
246
|
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
340
247
|
if not notes:
|
|
341
248
|
return {"error": "No notes in clip"}
|
|
342
249
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
)
|
|
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))
|
|
348
254
|
|
|
349
255
|
issues = []
|
|
350
256
|
|
|
@@ -352,86 +258,53 @@ def detect_theory_issues(
|
|
|
352
258
|
for n in notes:
|
|
353
259
|
if n.get("mute", False):
|
|
354
260
|
continue
|
|
355
|
-
if n["pitch"] % 12 not in
|
|
261
|
+
if n["pitch"] % 12 not in scale_pcs:
|
|
356
262
|
issues.append({
|
|
357
263
|
"type": "out_of_key",
|
|
358
264
|
"severity": "warning",
|
|
359
265
|
"beat": round(n["start_time"], 3),
|
|
360
|
-
"detail": f"{
|
|
266
|
+
"detail": f"{engine.pitch_name(n['pitch'])} not in {_key_display(key_info)}",
|
|
361
267
|
})
|
|
362
268
|
|
|
363
|
-
# 2. Parallel fifths/octaves
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
+
})
|
|
375
295
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
)
|
|
382
|
-
if vlq.parallelFifth():
|
|
383
|
-
issues.append({
|
|
384
|
-
"type": "parallel_fifths",
|
|
385
|
-
"severity": "error",
|
|
386
|
-
"beat": round(float(curr_c.offset), 3),
|
|
387
|
-
"detail": "Parallel fifths in outer voices",
|
|
388
|
-
})
|
|
389
|
-
if vlq.parallelOctave():
|
|
390
|
-
issues.append({
|
|
391
|
-
"type": "parallel_octaves",
|
|
392
|
-
"severity": "error",
|
|
393
|
-
"beat": round(float(curr_c.offset), 3),
|
|
394
|
-
"detail": "Parallel octaves in outer voices",
|
|
395
|
-
})
|
|
396
|
-
if vlq.voiceCrossing():
|
|
397
|
-
issues.append({
|
|
398
|
-
"type": "voice_crossing",
|
|
399
|
-
"severity": "warning",
|
|
400
|
-
"beat": round(float(curr_c.offset), 3),
|
|
401
|
-
"detail": "Voice crossing detected",
|
|
402
|
-
})
|
|
403
|
-
if strict and vlq.hiddenFifth():
|
|
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'):
|
|
404
302
|
issues.append({
|
|
405
|
-
"type": "
|
|
303
|
+
"type": "unresolved_dominant",
|
|
406
304
|
"severity": "info",
|
|
407
|
-
"beat": round(
|
|
408
|
-
"detail": "
|
|
305
|
+
"beat": round(chord_groups[i]["beat"], 3),
|
|
306
|
+
"detail": f"{rn['figure']} resolves to {next_rn['figure']} instead of tonic",
|
|
409
307
|
})
|
|
410
|
-
except Exception:
|
|
411
|
-
pass
|
|
412
|
-
|
|
413
|
-
# 3. Unresolved dominant (strict mode)
|
|
414
|
-
if strict:
|
|
415
|
-
for i in range(len(chord_list) - 1):
|
|
416
|
-
try:
|
|
417
|
-
rn = roman.romanNumeralFromChord(chord_list[i], detected_key)
|
|
418
|
-
next_rn = roman.romanNumeralFromChord(
|
|
419
|
-
chord_list[i + 1], detected_key
|
|
420
|
-
)
|
|
421
|
-
if rn.romanNumeral in ('V', 'V7') and next_rn.romanNumeral not in (
|
|
422
|
-
'I', 'i', 'vi', 'VI'
|
|
423
|
-
):
|
|
424
|
-
issues.append({
|
|
425
|
-
"type": "unresolved_dominant",
|
|
426
|
-
"severity": "info",
|
|
427
|
-
"beat": round(float(chord_list[i].offset), 3),
|
|
428
|
-
"detail": (
|
|
429
|
-
f"{rn.figure} resolves to {next_rn.figure} "
|
|
430
|
-
f"instead of tonic"
|
|
431
|
-
),
|
|
432
|
-
})
|
|
433
|
-
except Exception:
|
|
434
|
-
pass
|
|
435
308
|
|
|
436
309
|
# 4. Large leaps without resolution (strict mode)
|
|
437
310
|
if strict:
|
|
@@ -453,7 +326,7 @@ def detect_theory_issues(
|
|
|
453
326
|
issues.sort(key=lambda x: (severity_order.get(x["severity"], 3), x.get("beat", 0)))
|
|
454
327
|
|
|
455
328
|
return {
|
|
456
|
-
"key":
|
|
329
|
+
"key": _key_display(key_info),
|
|
457
330
|
"strict_mode": strict,
|
|
458
331
|
"issue_count": len(issues),
|
|
459
332
|
"errors": sum(1 for i in issues if i["severity"] == "error"),
|
|
@@ -472,41 +345,31 @@ def identify_scale(
|
|
|
472
345
|
) -> dict:
|
|
473
346
|
"""Identify the scale/mode of a MIDI clip beyond basic major/minor.
|
|
474
347
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
Lydian, Mixolydian) and exotic scales.
|
|
348
|
+
Uses Krumhansl-Schmuckler algorithm with 7 mode profiles (major, minor,
|
|
349
|
+
dorian, phrygian, lydian, mixolydian, locrian).
|
|
478
350
|
|
|
479
351
|
Returns ranked key matches with confidence scores.
|
|
480
352
|
"""
|
|
481
|
-
_require_music21()
|
|
482
|
-
|
|
483
353
|
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
484
354
|
if not notes:
|
|
485
355
|
return {"error": "No notes in clip"}
|
|
486
356
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
# music21's key analysis returns the best match and alternatives
|
|
490
|
-
detected = s.analyze('key')
|
|
357
|
+
detected = engine.detect_key(notes, mode_detection=True)
|
|
491
358
|
|
|
492
359
|
results = [{
|
|
493
|
-
"key":
|
|
494
|
-
"confidence":
|
|
495
|
-
|
|
496
|
-
"
|
|
497
|
-
"tonic": str(detected.tonic),
|
|
360
|
+
"key": f"{detected['tonic_name']} {detected['mode']}",
|
|
361
|
+
"confidence": detected["confidence"],
|
|
362
|
+
"mode": detected["mode"],
|
|
363
|
+
"tonic": detected["tonic_name"],
|
|
498
364
|
}]
|
|
499
365
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
"mode": alt.mode,
|
|
508
|
-
"tonic": str(alt.tonic),
|
|
509
|
-
})
|
|
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
|
+
})
|
|
510
373
|
|
|
511
374
|
# Pitch class usage for context
|
|
512
375
|
pitch_classes = defaultdict(float)
|
|
@@ -514,9 +377,8 @@ def identify_scale(
|
|
|
514
377
|
if not n.get("mute", False):
|
|
515
378
|
pitch_classes[n["pitch"] % 12] += n["duration"]
|
|
516
379
|
|
|
517
|
-
note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
|
518
380
|
pc_usage = {
|
|
519
|
-
|
|
381
|
+
engine.NOTE_NAMES[pc]: round(dur, 3)
|
|
520
382
|
for pc, dur in sorted(pitch_classes.items())
|
|
521
383
|
}
|
|
522
384
|
|
|
@@ -548,21 +410,18 @@ def harmonize_melody(
|
|
|
548
410
|
|
|
549
411
|
Processing time: 2-5s.
|
|
550
412
|
"""
|
|
551
|
-
_require_music21()
|
|
552
|
-
from music21 import roman
|
|
553
|
-
|
|
554
413
|
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
555
414
|
if not notes:
|
|
556
415
|
return {"error": "No notes in clip"}
|
|
557
416
|
|
|
558
|
-
# Use only non-muted, sorted by time
|
|
559
417
|
melody = sorted(
|
|
560
418
|
[n for n in notes if not n.get("mute", False)],
|
|
561
419
|
key=lambda n: n["start_time"],
|
|
562
420
|
)
|
|
563
421
|
|
|
564
|
-
|
|
565
|
-
|
|
422
|
+
key_info = _detect_or_parse_key(melody, key_hint=key)
|
|
423
|
+
tonic = key_info["tonic"]
|
|
424
|
+
mode = key_info["mode"]
|
|
566
425
|
|
|
567
426
|
result_voices = {"soprano": [], "bass": []}
|
|
568
427
|
if voices == 4:
|
|
@@ -572,38 +431,35 @@ def harmonize_melody(
|
|
|
572
431
|
prev_bass_midi = None
|
|
573
432
|
|
|
574
433
|
for n in melody:
|
|
575
|
-
from music21 import pitch as m21pitch
|
|
576
434
|
melody_pitch = n["pitch"]
|
|
577
435
|
beat = n["start_time"]
|
|
578
436
|
dur = n["duration"]
|
|
579
437
|
mel_pc = melody_pitch % 12
|
|
580
438
|
|
|
581
439
|
# Find the best diatonic chord containing this pitch
|
|
582
|
-
|
|
583
|
-
for degree in [
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
chord_midis = sorted([p.midi for p in best_rn.pitches])
|
|
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
|
+
])
|
|
598
455
|
|
|
599
456
|
# Bass: root in low octave, smooth motion preferred
|
|
600
|
-
bass =
|
|
601
|
-
|
|
457
|
+
bass = 36 + best_chord["root_pc"]
|
|
458
|
+
if bass > 52:
|
|
602
459
|
bass -= 12
|
|
603
|
-
|
|
460
|
+
if bass < 36:
|
|
604
461
|
bass += 12
|
|
605
462
|
if prev_bass_midi is not None:
|
|
606
|
-
# Try octave that's closest to previous bass
|
|
607
463
|
options = [bass, bass - 12, bass + 12]
|
|
608
464
|
options = [b for b in options if 33 <= b <= 55]
|
|
609
465
|
if options:
|
|
@@ -650,7 +506,7 @@ def harmonize_melody(
|
|
|
650
506
|
})
|
|
651
507
|
|
|
652
508
|
result = {
|
|
653
|
-
"key":
|
|
509
|
+
"key": _key_display(key_info),
|
|
654
510
|
"voices": voices,
|
|
655
511
|
"melody_notes": len(melody),
|
|
656
512
|
}
|
|
@@ -683,8 +539,6 @@ def generate_countermelody(
|
|
|
683
539
|
Returns note data ready for add_notes on a new track.
|
|
684
540
|
Processing time: 2-5s.
|
|
685
541
|
"""
|
|
686
|
-
_require_music21()
|
|
687
|
-
import random
|
|
688
542
|
random.seed(seed)
|
|
689
543
|
|
|
690
544
|
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
@@ -696,15 +550,11 @@ def generate_countermelody(
|
|
|
696
550
|
key=lambda n: n["start_time"],
|
|
697
551
|
)
|
|
698
552
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
scale_pcs = [p.midi % 12 for p in detected_key.getScale().getPitches()]
|
|
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"]))
|
|
702
555
|
|
|
703
556
|
# Build pool of scale pitches in range
|
|
704
|
-
pool = []
|
|
705
|
-
for p in range(range_low, range_high + 1):
|
|
706
|
-
if p % 12 in scale_pcs:
|
|
707
|
-
pool.append(p)
|
|
557
|
+
pool = [p for p in range(range_low, range_high + 1) if p % 12 in scale_pcs]
|
|
708
558
|
if not pool:
|
|
709
559
|
return {"error": "No scale pitches in given range"}
|
|
710
560
|
|
|
@@ -720,7 +570,6 @@ def generate_countermelody(
|
|
|
720
570
|
dur = n["duration"] / species
|
|
721
571
|
|
|
722
572
|
for s_idx in range(species):
|
|
723
|
-
# Score candidates
|
|
724
573
|
scored = []
|
|
725
574
|
for cp in pool:
|
|
726
575
|
iv = abs(cp - mel_pitch) % 12
|
|
@@ -736,10 +585,9 @@ def generate_countermelody(
|
|
|
736
585
|
if (mel_dir > 0 and cp_dir < 0) or (mel_dir < 0 and cp_dir > 0):
|
|
737
586
|
score += 10
|
|
738
587
|
# Penalize parallel perfect intervals
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
score -= 50 # Hard penalty for parallel P5/P8
|
|
588
|
+
prev_iv = abs(prev_cp - melody[i - 1]["pitch"]) % 12
|
|
589
|
+
if prev_iv == iv and iv in (0, 7):
|
|
590
|
+
score -= 50
|
|
743
591
|
|
|
744
592
|
# Stepwise motion bonus
|
|
745
593
|
if prev_cp is not None:
|
|
@@ -753,12 +601,10 @@ def generate_countermelody(
|
|
|
753
601
|
else:
|
|
754
602
|
score += 3
|
|
755
603
|
|
|
756
|
-
# Small random variation for musicality
|
|
757
604
|
score += random.uniform(0, 2)
|
|
758
605
|
scored.append((cp, score))
|
|
759
606
|
|
|
760
607
|
if not scored:
|
|
761
|
-
# Fallback: pick any pool note
|
|
762
608
|
scored = [(random.choice(pool), 0)]
|
|
763
609
|
|
|
764
610
|
scored.sort(key=lambda x: -x[1])
|
|
@@ -773,12 +619,12 @@ def generate_countermelody(
|
|
|
773
619
|
prev_cp = chosen
|
|
774
620
|
|
|
775
621
|
return {
|
|
776
|
-
"key":
|
|
622
|
+
"key": _key_display(key_info),
|
|
777
623
|
"species": species,
|
|
778
624
|
"melody_notes": len(melody),
|
|
779
625
|
"counter_notes": counter_notes,
|
|
780
626
|
"counter_note_count": len(counter_notes),
|
|
781
|
-
"range": f"{
|
|
627
|
+
"range": f"{engine.pitch_name(range_low)}-{engine.pitch_name(range_high)}",
|
|
782
628
|
"seed": seed,
|
|
783
629
|
}
|
|
784
630
|
|
|
@@ -802,24 +648,20 @@ def transpose_smart(
|
|
|
802
648
|
|
|
803
649
|
Returns transposed note data ready for add_notes or modify_notes.
|
|
804
650
|
"""
|
|
805
|
-
_require_music21()
|
|
806
|
-
from music21 import pitch as m21pitch
|
|
807
|
-
|
|
808
651
|
notes = _get_clip_notes(ctx, track_index, clip_index)
|
|
809
652
|
if not notes:
|
|
810
653
|
return {"error": "No notes in clip"}
|
|
811
654
|
|
|
812
|
-
|
|
813
|
-
source_key = _detect_key(s)
|
|
655
|
+
source_key = engine.detect_key(notes)
|
|
814
656
|
|
|
815
657
|
try:
|
|
816
|
-
target =
|
|
817
|
-
except
|
|
658
|
+
target = engine.parse_key(target_key)
|
|
659
|
+
except ValueError:
|
|
818
660
|
return {"error": f"Invalid target key: {target_key}"}
|
|
819
661
|
|
|
820
|
-
source_tonic =
|
|
821
|
-
target_tonic =
|
|
822
|
-
semitone_shift = target_tonic
|
|
662
|
+
source_tonic = source_key["tonic"]
|
|
663
|
+
target_tonic = target["tonic"]
|
|
664
|
+
semitone_shift = target_tonic - source_tonic
|
|
823
665
|
|
|
824
666
|
if mode == "chromatic":
|
|
825
667
|
transposed = []
|
|
@@ -830,10 +672,10 @@ def transpose_smart(
|
|
|
830
672
|
transposed.append(tn)
|
|
831
673
|
else:
|
|
832
674
|
# Diatonic: map scale degrees
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
source_pcs =
|
|
836
|
-
target_pcs =
|
|
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)
|
|
837
679
|
|
|
838
680
|
degree_map = {}
|
|
839
681
|
for i in range(min(len(source_pcs), len(target_pcs))):
|
|
@@ -847,7 +689,6 @@ def transpose_smart(
|
|
|
847
689
|
|
|
848
690
|
if pc in degree_map:
|
|
849
691
|
new_pc = degree_map[pc]
|
|
850
|
-
# Calculate octave adjustment from tonic shift
|
|
851
692
|
new_pitch = octave * 12 + new_pc
|
|
852
693
|
# Adjust if the shift crossed an octave boundary
|
|
853
694
|
if abs(new_pitch - (n["pitch"] + semitone_shift)) > 6:
|
|
@@ -856,15 +697,14 @@ def transpose_smart(
|
|
|
856
697
|
else:
|
|
857
698
|
new_pitch -= 12
|
|
858
699
|
else:
|
|
859
|
-
# Chromatic note: shift by tonic distance
|
|
860
700
|
new_pitch = n["pitch"] + semitone_shift
|
|
861
701
|
|
|
862
702
|
tn["pitch"] = max(0, min(127, new_pitch))
|
|
863
703
|
transposed.append(tn)
|
|
864
704
|
|
|
865
705
|
return {
|
|
866
|
-
"source_key":
|
|
867
|
-
"target_key":
|
|
706
|
+
"source_key": _key_display(source_key),
|
|
707
|
+
"target_key": f"{engine.NOTE_NAMES[target_tonic]} {target.get('mode', 'major')}",
|
|
868
708
|
"mode": mode,
|
|
869
709
|
"semitone_shift": semitone_shift,
|
|
870
710
|
"note_count": len(transposed),
|