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.
@@ -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
+ }