invert.py
python
| 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`") |