transpose.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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 from __future__ import annotations
27
28 import argparse
29 import json
30 import logging
31 import pathlib
32 import sys
33
34 from muse.core.errors import ExitCode
35 from muse.core.repo import require_repo
36 from muse.plugins.midi._query import (
37 NoteInfo,
38 load_track_from_workdir,
39 notes_to_midi_bytes,
40 )
41 from muse.plugins.midi.midi_diff import _pitch_name
42 from muse.core.validation import clamp_int
43
44 logger = logging.getLogger(__name__)
45
46 _MIDI_MIN = 0
47 _MIDI_MAX = 127
48
49
50 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
51 """Register the transpose subcommand."""
52 parser = subparsers.add_parser("transpose", help="Transpose all notes in a MIDI track by N semitones.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
53 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
54 parser.add_argument("--semitones", "-s", metavar="N", type=int, required=True, help="Number of semitones to shift (positive = up, negative = down).")
55 parser.add_argument("--dry-run", "-n", action="store_true", help="Preview what would change without writing to disk.")
56 parser.add_argument("--clamp", action="store_true", help="Clamp pitches to 0–127 instead of failing on out-of-range notes.")
57 parser.set_defaults(func=run)
58
59
60 def run(args: argparse.Namespace) -> None:
61 """Transpose all notes in a MIDI track by N semitones.
62
63 ``muse transpose`` reads the MIDI file from the working tree, shifts
64 every note's pitch by *--semitones*, and writes the result back.
65 Timing and velocity are preserved exactly.
66
67 After transposing, run ``muse status`` to see the structured delta
68 (note-level insertions and deletions), then ``muse commit`` to record
69 the transposition with full musical attribution.
70
71 For AI agents: this is the equivalent of ``muse patch`` for music β€”
72 a single command that applies a well-defined musical transformation
73 without touching anything else.
74
75 Use ``--dry-run`` to preview the operation without writing.
76 Use ``--clamp`` to clip pitches to the valid MIDI range (0–127)
77 instead of raising an error.
78 """
79 track: str = args.track
80 semitones: int = clamp_int(args.semitones, -127, 127, 'semitones')
81 dry_run: bool = args.dry_run
82 clamp: bool = args.clamp
83
84 root = require_repo()
85
86 result = load_track_from_workdir(root, track)
87 if result is None:
88 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
89 raise SystemExit(ExitCode.USER_ERROR)
90
91 original_notes, tpb = result
92
93 if not original_notes:
94 print(f" (track '{track}' contains no notes β€” nothing to transpose)")
95 return
96
97 # Validate pitch range.
98 new_pitches = [n.pitch + semitones for n in original_notes]
99 out_of_range = [p for p in new_pitches if p < _MIDI_MIN or p > _MIDI_MAX]
100 if out_of_range and not clamp:
101 lo = min(out_of_range)
102 hi = max(out_of_range)
103 print(
104 f"❌ Transposing by {semitones:+d} semitones would produce "
105 f"out-of-range MIDI pitches ({lo}–{hi}). "
106 f"Use --clamp to clip to 0–127.",
107 file=sys.stderr,
108 )
109 raise SystemExit(ExitCode.USER_ERROR)
110
111 # Build transposed notes.
112 transposed: list[NoteInfo] = []
113 for note in original_notes:
114 new_pitch = max(_MIDI_MIN, min(_MIDI_MAX, note.pitch + semitones))
115 transposed.append(NoteInfo(
116 pitch=new_pitch,
117 velocity=note.velocity,
118 start_tick=note.start_tick,
119 duration_ticks=note.duration_ticks,
120 channel=note.channel,
121 ticks_per_beat=note.ticks_per_beat,
122 ))
123
124 old_lo = min(n.pitch for n in original_notes)
125 old_hi = max(n.pitch for n in original_notes)
126 new_lo = min(n.pitch for n in transposed)
127 new_hi = max(n.pitch for n in transposed)
128
129 sign = "+" if semitones >= 0 else ""
130 sample_pairs = [
131 f"{_pitch_name(original_notes[i].pitch)} β†’ {_pitch_name(transposed[i].pitch)}"
132 for i in range(min(3, len(original_notes)))
133 ]
134
135 if dry_run:
136 print(f"\n[dry-run] Would transpose {track} {sign}{semitones} semitones")
137 print(f" Notes: {len(original_notes)}")
138 print(f" Shifts: {', '.join(sample_pairs)}, …")
139 print(f" Pitch range: {_pitch_name(new_lo)}–{_pitch_name(new_hi)} "
140 f"(was {_pitch_name(old_lo)}–{_pitch_name(old_hi)})")
141 print(" No changes written (--dry-run).")
142 return
143
144 midi_bytes = notes_to_midi_bytes(transposed, tpb)
145
146 # Write back to the working tree.
147 work_path = root / track
148 if not work_path.parent.exists():
149 work_path = root / track
150 work_path.write_bytes(midi_bytes)
151
152 print(f"\nβœ… Transposed {track} {sign}{semitones} semitones")
153 print(f" {len(transposed)} notes shifted ({', '.join(sample_pairs)}, …)")
154 print(f" Pitch range: {_pitch_name(new_lo)}–{_pitch_name(new_hi)}"
155 f" (was {_pitch_name(old_lo)}–{_pitch_name(old_hi)})")
156 print(" Run `muse status` to review, then `muse commit`")