gabriel / muse public
transpose.py python
152 lines 6.0 KB
Raw
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd feat: add muse reflog expire subcommand and reflog.expire-d… Sonnet 4.6 patch 71 days ago
1 """muse transpose — transpose a MIDI track by N semitones.
2
3 Reads the MIDI file from the working tree, shifts every note's pitch by
4 the specified number of semitones, and writes the result back in-place.
5
6 This is a surgical agent command: the content hash changes (Muse treats the
7 transposed version as a distinct composition), but every note's timing and
8 velocity are preserved exactly. Run ``muse status`` and ``muse commit`` to
9 record the transposition in the structured delta.
10
11 Usage::
12
13 muse transpose tracks/melody.mid --semitones 2 # up a major second
14 muse transpose tracks/bass.mid --semitones -7 # down a fifth
15 muse transpose tracks/piano.mid --semitones 12 # up an octave
16 muse transpose tracks/melody.mid --semitones 5 --dry-run
17
18 Output::
19
20 ✅ Transposed tracks/melody.mid +2 semitones
21 23 notes shifted (C4 → D4, G5 → A5, …)
22 Pitch range: C3–A5 (was A2–G5)
23 Run `muse status` to review, then `muse commit`
24 """
25
26 import argparse
27 import json
28 import logging
29 import pathlib
30 import sys
31
32 from muse.core.errors import ExitCode
33 from muse.core.repo import require_repo
34 from muse.plugins.midi._query import (
35 NoteInfo,
36 load_track_from_workdir,
37 notes_to_midi_bytes,
38 )
39 from muse.plugins.midi.midi_diff import _pitch_name
40 from muse.core.validation import clamp_int
41
42 logger = logging.getLogger(__name__)
43
44 _MIDI_MIN = 0
45 _MIDI_MAX = 127
46
47 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
48 """Register the transpose subcommand."""
49 parser = subparsers.add_parser("transpose", help="Transpose all notes in a MIDI track by N semitones.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
50 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
51 parser.add_argument("--semitones", "-s", metavar="N", type=int, required=True, help="Number of semitones to shift (positive = up, negative = down).")
52 parser.add_argument("--dry-run", "-n", action="store_true", help="Preview what would change without writing to disk.")
53 parser.add_argument("--clamp", action="store_true", help="Clamp pitches to 0–127 instead of failing on out-of-range notes.")
54 parser.set_defaults(func=run)
55
56 def run(args: argparse.Namespace) -> None:
57 """Transpose all notes in a MIDI track by N semitones.
58
59 ``muse transpose`` reads the MIDI file from the working tree, shifts
60 every note's pitch by *--semitones*, and writes the result back.
61 Timing and velocity are preserved exactly.
62
63 After transposing, run ``muse status`` to see the structured delta
64 (note-level insertions and deletions), then ``muse commit`` to record
65 the transposition with full musical attribution.
66
67 For AI agents: this is the equivalent of ``muse patch`` for music —
68 a single command that applies a well-defined musical transformation
69 without touching anything else.
70
71 Use ``--dry-run`` to preview the operation without writing.
72 Use ``--clamp`` to clip pitches to the valid MIDI range (0–127)
73 instead of raising an error.
74 """
75 track: str = args.track
76 semitones: int = clamp_int(args.semitones, -127, 127, 'semitones')
77 dry_run: bool = args.dry_run
78 clamp: bool = args.clamp
79
80 root = require_repo()
81
82 result = load_track_from_workdir(root, track)
83 if result is None:
84 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
85 raise SystemExit(ExitCode.USER_ERROR)
86
87 original_notes, tpb = result
88
89 if not original_notes:
90 print(f" (track '{track}' contains no notes — nothing to transpose)")
91 return
92
93 # Validate pitch range.
94 new_pitches = [n.pitch + semitones for n in original_notes]
95 out_of_range = [p for p in new_pitches if p < _MIDI_MIN or p > _MIDI_MAX]
96 if out_of_range and not clamp:
97 lo = min(out_of_range)
98 hi = max(out_of_range)
99 print(
100 f"❌ Transposing by {semitones:+d} semitones would produce "
101 f"out-of-range MIDI pitches ({lo}–{hi}). "
102 f"Use --clamp to clip to 0–127.",
103 file=sys.stderr,
104 )
105 raise SystemExit(ExitCode.USER_ERROR)
106
107 # Build transposed notes.
108 transposed: list[NoteInfo] = []
109 for note in original_notes:
110 new_pitch = max(_MIDI_MIN, min(_MIDI_MAX, note.pitch + semitones))
111 transposed.append(NoteInfo(
112 pitch=new_pitch,
113 velocity=note.velocity,
114 start_tick=note.start_tick,
115 duration_ticks=note.duration_ticks,
116 channel=note.channel,
117 ticks_per_beat=note.ticks_per_beat,
118 ))
119
120 old_lo = min(n.pitch for n in original_notes)
121 old_hi = max(n.pitch for n in original_notes)
122 new_lo = min(n.pitch for n in transposed)
123 new_hi = max(n.pitch for n in transposed)
124
125 sign = "+" if semitones >= 0 else ""
126 sample_pairs = [
127 f"{_pitch_name(original_notes[i].pitch)} → {_pitch_name(transposed[i].pitch)}"
128 for i in range(min(3, len(original_notes)))
129 ]
130
131 if dry_run:
132 print(f"\n[dry-run] Would transpose {track} {sign}{semitones} semitones")
133 print(f" Notes: {len(original_notes)}")
134 print(f" Shifts: {', '.join(sample_pairs)}, …")
135 print(f" Pitch range: {_pitch_name(new_lo)}–{_pitch_name(new_hi)} "
136 f"(was {_pitch_name(old_lo)}–{_pitch_name(old_hi)})")
137 print(" No changes written (--dry-run).")
138 return
139
140 midi_bytes = notes_to_midi_bytes(transposed, tpb)
141
142 # Write back to the working tree.
143 work_path = root / track
144 if not work_path.parent.exists():
145 work_path = root / track
146 work_path.write_bytes(midi_bytes)
147
148 print(f"\n✅ Transposed {track} {sign}{semitones} semitones")
149 print(f" {len(transposed)} notes shifted ({', '.join(sample_pairs)}, …)")
150 print(f" Pitch range: {_pitch_name(new_lo)}–{_pitch_name(new_hi)}"
151 f" (was {_pitch_name(old_lo)}–{_pitch_name(old_hi)})")
152 print(" Run `muse status` to review, then `muse commit`")
File History 1 commit
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd feat: add muse reflog expire subcommand and reflog.expire-d… Sonnet 4.6 patch 71 days ago