cgcardona / muse public
transpose.py python
158 lines 5.6 KB
bda49bdb feat: redesign .museignore as TOML with domain-scoped sections (#100) 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 from __future__ import annotations
27
28 import json
29 import logging
30 import pathlib
31
32 import typer
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
43 logger = logging.getLogger(__name__)
44
45 app = typer.Typer()
46
47 _MIDI_MIN = 0
48 _MIDI_MAX = 127
49
50
51 @app.callback(invoke_without_command=True)
52 def transpose(
53 ctx: typer.Context,
54 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
55 semitones: int = typer.Option(
56 ..., "--semitones", "-s", metavar="N",
57 help="Number of semitones to shift (positive = up, negative = down).",
58 ),
59 dry_run: bool = typer.Option(
60 False, "--dry-run", "-n",
61 help="Preview what would change without writing to disk.",
62 ),
63 clamp: bool = typer.Option(
64 False, "--clamp",
65 help="Clamp pitches to 0–127 instead of failing on out-of-range notes.",
66 ),
67 ) -> None:
68 """Transpose all notes in a MIDI track by N semitones.
69
70 ``muse transpose`` reads the MIDI file from the working tree, shifts
71 every note's pitch by *--semitones*, and writes the result back.
72 Timing and velocity are preserved exactly.
73
74 After transposing, run ``muse status`` to see the structured delta
75 (note-level insertions and deletions), then ``muse commit`` to record
76 the transposition with full musical attribution.
77
78 For AI agents: this is the equivalent of ``muse patch`` for music —
79 a single command that applies a well-defined musical transformation
80 without touching anything else.
81
82 Use ``--dry-run`` to preview the operation without writing.
83 Use ``--clamp`` to clip pitches to the valid MIDI range (0–127)
84 instead of raising an error.
85 """
86 root = require_repo()
87
88 result = load_track_from_workdir(root, track)
89 if result is None:
90 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
91 raise typer.Exit(code=ExitCode.USER_ERROR)
92
93 original_notes, tpb = result
94
95 if not original_notes:
96 typer.echo(f" (track '{track}' contains no notes — nothing to transpose)")
97 return
98
99 # Validate pitch range.
100 new_pitches = [n.pitch + semitones for n in original_notes]
101 out_of_range = [p for p in new_pitches if p < _MIDI_MIN or p > _MIDI_MAX]
102 if out_of_range and not clamp:
103 lo = min(out_of_range)
104 hi = max(out_of_range)
105 typer.echo(
106 f"❌ Transposing by {semitones:+d} semitones would produce "
107 f"out-of-range MIDI pitches ({lo}–{hi}). "
108 f"Use --clamp to clip to 0–127.",
109 err=True,
110 )
111 raise typer.Exit(code=ExitCode.USER_ERROR)
112
113 # Build transposed notes.
114 transposed: list[NoteInfo] = []
115 for note in original_notes:
116 new_pitch = max(_MIDI_MIN, min(_MIDI_MAX, note.pitch + semitones))
117 transposed.append(NoteInfo(
118 pitch=new_pitch,
119 velocity=note.velocity,
120 start_tick=note.start_tick,
121 duration_ticks=note.duration_ticks,
122 channel=note.channel,
123 ticks_per_beat=note.ticks_per_beat,
124 ))
125
126 old_lo = min(n.pitch for n in original_notes)
127 old_hi = max(n.pitch for n in original_notes)
128 new_lo = min(n.pitch for n in transposed)
129 new_hi = max(n.pitch for n in transposed)
130
131 sign = "+" if semitones >= 0 else ""
132 sample_pairs = [
133 f"{_pitch_name(original_notes[i].pitch)} → {_pitch_name(transposed[i].pitch)}"
134 for i in range(min(3, len(original_notes)))
135 ]
136
137 if dry_run:
138 typer.echo(f"\n[dry-run] Would transpose {track} {sign}{semitones} semitones")
139 typer.echo(f" Notes: {len(original_notes)}")
140 typer.echo(f" Shifts: {', '.join(sample_pairs)}, …")
141 typer.echo(f" Pitch range: {_pitch_name(new_lo)}–{_pitch_name(new_hi)} "
142 f"(was {_pitch_name(old_lo)}–{_pitch_name(old_hi)})")
143 typer.echo(" No changes written (--dry-run).")
144 return
145
146 midi_bytes = notes_to_midi_bytes(transposed, tpb)
147
148 # Write back to the working tree.
149 work_path = root / "muse-work" / track
150 if not work_path.parent.exists():
151 work_path = root / track
152 work_path.write_bytes(midi_bytes)
153
154 typer.echo(f"\n✅ Transposed {track} {sign}{semitones} semitones")
155 typer.echo(f" {len(transposed)} notes shifted ({', '.join(sample_pairs)}, …)")
156 typer.echo(f" Pitch range: {_pitch_name(new_lo)}–{_pitch_name(new_hi)}"
157 f" (was {_pitch_name(old_lo)}–{_pitch_name(old_hi)})")
158 typer.echo(" Run `muse status` to review, then `muse commit`")