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