gabriel / muse public
invert.py python
174 lines 6.6 KB
6acddccb fix: restore structured --help output for all CLI commands Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """muse invert — melodic inversion (flip intervals around a pivot pitch).
2
3 Reflects every interval in the melody around a pivot pitch. If the melody
4 goes up 2 semitones, the inversion goes down 2 semitones. A classic
5 contrapuntal transformation — Bach used it in every fugue. Agents exploring
6 the musical space around a theme can generate invertible counterpoint
7 automatically.
8
9 Usage::
10
11 muse invert tracks/melody.mid
12 muse invert tracks/melody.mid --pivot C4
13 muse invert tracks/melody.mid --pivot 60 --dry-run
14
15 Pivot defaults to the first note of the track.
16
17 Output::
18
19 ✅ Inverted tracks/melody.mid (pivot: C4 / MIDI 60)
20 23 notes transformed (D4 → B3, E4 → A3, …)
21 New range: G2–C5 (was C4–A5)
22 Run `muse status` to review, then `muse commit`
23 """
24
25 from __future__ import annotations
26
27 import argparse
28 import logging
29 import pathlib
30 import sys
31
32 from muse.core.errors import ExitCode
33 from muse.core.validation import contain_path
34 from muse.core.repo import require_repo
35 from muse.plugins.midi._query import NoteInfo, load_track_from_workdir, notes_to_midi_bytes
36 from muse.plugins.midi.midi_diff import _pitch_name
37
38 logger = logging.getLogger(__name__)
39
40 _MIDI_MIN = 0
41 _MIDI_MAX = 127
42
43 _NOTE_NAMES: dict[str, int] = {
44 "C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11,
45 }
46
47
48 def _parse_pivot(pivot_str: str) -> int | None:
49 """Parse a pivot like 'C4', 'A#3', or '60' into a MIDI number."""
50 pivot_str = pivot_str.strip()
51 if pivot_str.isdigit():
52 return int(pivot_str)
53 if not pivot_str:
54 return None
55 note_letter = pivot_str[0].upper()
56 if note_letter not in _NOTE_NAMES:
57 return None
58 rest = pivot_str[1:]
59 sharp = rest.startswith("#")
60 if sharp:
61 rest = rest[1:]
62 if not rest.lstrip("-").isdigit():
63 return None
64 octave = int(rest)
65 return _NOTE_NAMES[note_letter] + (1 if sharp else 0) + (octave + 1) * 12
66
67
68 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
69 """Register the invert subcommand."""
70 parser = subparsers.add_parser("invert", help="Apply melodic inversion: reflect all intervals around a pivot pitch.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
71 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
72 parser.add_argument("--pivot", "-p", metavar="PITCH", default=None, help="Pivot pitch as note name (C4, A#3) or MIDI number (0–127). Defaults to the first note.")
73 parser.add_argument("--clamp", action="store_true", help="Clamp out-of-range pitches to 0–127.")
74 parser.add_argument("--dry-run", "-n", action="store_true", help="Preview without writing.")
75 parser.set_defaults(func=run)
76
77
78 def run(args: argparse.Namespace) -> None:
79 """Apply melodic inversion: reflect all intervals around a pivot pitch.
80
81 ``muse invert`` transforms the melody so that upward intervals become
82 downward and vice versa, mirrored around *--pivot*. Timing, velocity,
83 and duration are preserved exactly.
84
85 In counterpoint and fugue, the inverted subject can be combined with
86 the original to create invertible counterpoint. In agent workflows,
87 use this to auto-generate contrast material from an existing melody.
88 """
89 track: str = args.track
90 pivot: str | None = args.pivot
91 clamp: bool = args.clamp
92 dry_run: bool = args.dry_run
93
94 root = require_repo()
95 result = load_track_from_workdir(root, track)
96 if result is None:
97 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
98 raise SystemExit(ExitCode.USER_ERROR)
99
100 notes, tpb = result
101 if not notes:
102 print(f" (track '{track}' contains no notes — nothing to invert)")
103 return
104
105 # Determine pivot pitch
106 if pivot is not None:
107 pivot_midi = _parse_pivot(pivot)
108 if pivot_midi is None:
109 print(f"❌ Cannot parse pivot '{pivot}'. Use C4, A#3, or a MIDI number.", file=sys.stderr)
110 raise SystemExit(ExitCode.USER_ERROR)
111 if not 0 <= pivot_midi <= 127:
112 print(f"❌ Pivot MIDI value {pivot_midi} is out of range [0, 127].", file=sys.stderr)
113 raise SystemExit(ExitCode.USER_ERROR)
114 else:
115 pivot_midi = sorted(notes, key=lambda n: n.start_tick)[0].pitch
116
117 inverted_pitches = [2 * pivot_midi - n.pitch for n in notes]
118 out_of_range = [p for p in inverted_pitches if p < _MIDI_MIN or p > _MIDI_MAX]
119 if out_of_range and not clamp:
120 print(
121 f"❌ Inversion around MIDI {pivot_midi} produces out-of-range pitches "
122 f"({min(out_of_range)}–{max(out_of_range)}). Use --clamp.",
123 file=sys.stderr,
124 )
125 raise SystemExit(ExitCode.USER_ERROR)
126
127 inverted: list[NoteInfo] = [
128 NoteInfo(
129 pitch=max(_MIDI_MIN, min(_MIDI_MAX, 2 * pivot_midi - n.pitch)),
130 velocity=n.velocity,
131 start_tick=n.start_tick,
132 duration_ticks=n.duration_ticks,
133 channel=n.channel,
134 ticks_per_beat=n.ticks_per_beat,
135 )
136 for n in notes
137 ]
138
139 old_lo = min(n.pitch for n in notes)
140 old_hi = max(n.pitch for n in notes)
141 new_lo = min(n.pitch for n in inverted)
142 new_hi = max(n.pitch for n in inverted)
143
144 sorted_orig = sorted(notes, key=lambda n: n.start_tick)
145 sorted_inv = sorted(inverted, key=lambda n: n.start_tick)
146 sample_pairs = [
147 f"{_pitch_name(sorted_orig[i].pitch)} → {_pitch_name(sorted_inv[i].pitch)}"
148 for i in range(min(3, len(sorted_orig)))
149 ]
150
151 if dry_run:
152 print(f"\n[dry-run] Would invert {track} (pivot: {_pitch_name(pivot_midi)} / MIDI {pivot_midi})")
153 print(f" Notes: {len(notes)}")
154 print(f" Transforms: {', '.join(sample_pairs)}, …")
155 print(f" New range: {_pitch_name(new_lo)}–{_pitch_name(new_hi)} "
156 f"(was {_pitch_name(old_lo)}–{_pitch_name(old_hi)})")
157 print(" No changes written (--dry-run).")
158 return
159
160 midi_bytes = notes_to_midi_bytes(inverted, tpb)
161 workdir = root
162 try:
163 work_path = contain_path(workdir, track)
164 except ValueError as exc:
165 print(f"❌ Invalid track path: {exc}")
166 raise SystemExit(ExitCode.USER_ERROR)
167 work_path.parent.mkdir(parents=True, exist_ok=True)
168 work_path.write_bytes(midi_bytes)
169
170 print(f"\n✅ Inverted {track} (pivot: {_pitch_name(pivot_midi)} / MIDI {pivot_midi})")
171 print(f" {len(inverted)} notes transformed ({', '.join(sample_pairs)}, …)")
172 print(f" New range: {_pitch_name(new_lo)}–{_pitch_name(new_hi)}"
173 f" (was {_pitch_name(old_lo)}–{_pitch_name(old_hi)})")
174 print(" Run `muse status` to review, then `muse commit`")