cgcardona / muse public
transpose.py python
156 lines 5.6 KB
e6786943 feat: upgrade to Python 3.14, drop from __future__ import annotations Gabriel Cardona <cgcardona@gmail.com> 1d 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 json
27 import logging
28 import pathlib
29
30 import typer
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
41 logger = logging.getLogger(__name__)
42
43 app = typer.Typer()
44
45 _MIDI_MIN = 0
46 _MIDI_MAX = 127
47
48
49 @app.callback(invoke_without_command=True)
50 def transpose(
51 ctx: typer.Context,
52 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
53 semitones: int = typer.Option(
54 ..., "--semitones", "-s", metavar="N",
55 help="Number of semitones to shift (positive = up, negative = down).",
56 ),
57 dry_run: bool = typer.Option(
58 False, "--dry-run", "-n",
59 help="Preview what would change without writing to disk.",
60 ),
61 clamp: bool = typer.Option(
62 False, "--clamp",
63 help="Clamp pitches to 0–127 instead of failing on out-of-range notes.",
64 ),
65 ) -> None:
66 """Transpose all notes in a MIDI track by N semitones.
67
68 ``muse transpose`` reads the MIDI file from the working tree, shifts
69 every note's pitch by *--semitones*, and writes the result back.
70 Timing and velocity are preserved exactly.
71
72 After transposing, run ``muse status`` to see the structured delta
73 (note-level insertions and deletions), then ``muse commit`` to record
74 the transposition with full musical attribution.
75
76 For AI agents: this is the equivalent of ``muse patch`` for music —
77 a single command that applies a well-defined musical transformation
78 without touching anything else.
79
80 Use ``--dry-run`` to preview the operation without writing.
81 Use ``--clamp`` to clip pitches to the valid MIDI range (0–127)
82 instead of raising an error.
83 """
84 root = require_repo()
85
86 result = load_track_from_workdir(root, track)
87 if result is None:
88 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
89 raise typer.Exit(code=ExitCode.USER_ERROR)
90
91 original_notes, tpb = result
92
93 if not original_notes:
94 typer.echo(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 typer.echo(
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 err=True,
108 )
109 raise typer.Exit(code=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 typer.echo(f"\n[dry-run] Would transpose {track} {sign}{semitones} semitones")
137 typer.echo(f" Notes: {len(original_notes)}")
138 typer.echo(f" Shifts: {', '.join(sample_pairs)}, …")
139 typer.echo(f" Pitch range: {_pitch_name(new_lo)}–{_pitch_name(new_hi)} "
140 f"(was {_pitch_name(old_lo)}–{_pitch_name(old_hi)})")
141 typer.echo(" 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 / "muse-work" / track
148 if not work_path.parent.exists():
149 work_path = root / track
150 work_path.write_bytes(midi_bytes)
151
152 typer.echo(f"\n✅ Transposed {track} {sign}{semitones} semitones")
153 typer.echo(f" {len(transposed)} notes shifted ({', '.join(sample_pairs)}, …)")
154 typer.echo(f" Pitch range: {_pitch_name(new_lo)}–{_pitch_name(new_hi)}"
155 f" (was {_pitch_name(old_lo)}–{_pitch_name(old_hi)})")
156 typer.echo(" Run `muse status` to review, then `muse commit`")