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