gabriel / muse public
piano_roll.py python
224 lines 8.3 KB
Raw
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 22 days ago
1 """muse piano-roll — ASCII piano roll visualization of a MIDI track.
2
3 Renders the note grid as a terminal-friendly ASCII art piano roll:
4 time runs left-to-right (columns = half-beats), pitches run bottom-to-top.
5 Consecutive occupied cells for the same note show as "═══" (sustained),
6 the onset cell shows the pitch name truncated to fit.
7
8 Usage::
9
10 muse piano-roll tracks/melody.mid
11 muse piano-roll tracks/melody.mid --commit HEAD~3
12 muse piano-roll tracks/melody.mid --bars 1-8
13 muse piano-roll tracks/melody.mid --resolution 4 # 4 cells per beat
14
15 Output::
16
17 Piano roll: tracks/melody.mid — cb4afaed (bars 1–4, res=2 cells/beat)
18
19 B5 │ │ │
20 A5 │ │ │
21 G5 │ G5══════ G5══════ │ G5══════ │
22 F5 │ │ │
23 E5 │ E5════ E5══│════ │
24 D5 │ │ D5══════ │
25 C5 │ C5══ │ C5══ │
26 B4 │ │ │
27 └────────────────────────┴────────────────────────┘
28 1 2 3 4 1 2 3
29 """
30
31 import argparse
32 import json
33 import logging
34 import pathlib
35 import sys
36
37 from muse.core.errors import ExitCode
38 from muse.core.repo import require_repo
39 from muse.core.refs import read_current_branch
40 from muse.core.commits import resolve_commit_ref
41 from muse.plugins.midi._query import (
42 NoteInfo,
43 load_track,
44 load_track_from_workdir,
45 )
46 from muse.plugins.midi.midi_diff import _pitch_name
47 from muse.core.validation import clamp_int
48
49 logger = logging.getLogger(__name__)
50
51 def _read_branch(root: pathlib.Path) -> str:
52 return read_current_branch(root)
53
54 def _render_piano_roll(
55 notes: list[NoteInfo],
56 tpb: int,
57 bar_start: int,
58 bar_end: int,
59 resolution: int,
60 ) -> list[str]:
61 """Render an ASCII piano roll as a list of strings.
62
63 Args:
64 notes: All notes in the track.
65 tpb: Ticks per beat.
66 bar_start: First bar to show (1-indexed).
67 bar_end: Last bar to show (inclusive).
68 resolution: Grid cells per beat (1=quarter, 2=eighth, 4=sixteenth).
69
70 Returns:
71 Lines of the piano roll grid.
72 """
73 if not notes:
74 return [" (no notes to display)"]
75
76 # Tick range for the selected bars.
77 ticks_per_bar = 4 * max(tpb, 1)
78 tick_start = (bar_start - 1) * ticks_per_bar
79 tick_end = bar_end * ticks_per_bar
80 ticks_per_cell = max(tpb // max(resolution, 1), 1)
81 n_cells = (tick_end - tick_start) // ticks_per_cell
82
83 if n_cells > 120:
84 n_cells = 120 # terminal width guard
85
86 # Pitch range.
87 visible = [n for n in notes if tick_start <= n.start_tick < tick_end]
88 if not visible:
89 return [f" (no notes in bars {bar_start}–{bar_end})"]
90
91 pitch_lo = max(min(n.pitch for n in visible) - 1, 0)
92 pitch_hi = min(max(n.pitch for n in visible) + 2, 127)
93
94 # Build the cell grid: pitch_row × time_col → label string.
95 n_rows = pitch_hi - pitch_lo + 1
96 grid: list[list[str]] = [[" "] * n_cells for _ in range(n_rows)]
97
98 for note in visible:
99 pitch_row = pitch_hi - note.pitch # top = high pitch
100 col_start = (note.start_tick - tick_start) // ticks_per_cell
101 col_end = min(
102 (note.start_tick + note.duration_ticks - tick_start) // ticks_per_cell,
103 n_cells - 1,
104 )
105 if col_start >= n_cells:
106 continue
107 # Onset cell: pitch name.
108 pname = _pitch_name(note.pitch)
109 onset_str = f"{pname:<3}"[:3]
110 grid[pitch_row][col_start] = onset_str
111 # Sustain cells.
112 for col in range(col_start + 1, col_end + 1):
113 grid[pitch_row][col] = "═══"
114
115 # Build bar separator columns.
116 bar_sep_cols: set[int] = set()
117 for b in range(bar_start, bar_end + 1):
118 col = ((b - 1) * ticks_per_bar - tick_start) // ticks_per_cell
119 if 0 <= col < n_cells:
120 bar_sep_cols.add(col)
121
122 # Render rows.
123 lines: list[str] = []
124 pitch_label_width = 4 # e.g. "G#5 "
125 for row_idx, row in enumerate(grid):
126 pitch = pitch_hi - row_idx
127 label = f"{_pitch_name(pitch):<4}"
128 cells = ""
129 for col, cell in enumerate(row):
130 if col in bar_sep_cols:
131 cells += "│"
132 cells += cell
133 lines.append(f" {label} {cells}")
134
135 # Bottom rule.
136 bottom = f" {' ' * pitch_label_width}"
137 for col in range(n_cells):
138 bottom += "│" if col in bar_sep_cols else "─"
139 lines.append(bottom)
140
141 # Beat labels.
142 beat_line = f" {' ' * pitch_label_width}"
143 for col in range(n_cells):
144 tick = tick_start + col * ticks_per_cell
145 beat_in_bar = ((tick % ticks_per_bar) // max(tpb, 1)) + 1
146 is_downbeat = tick % ticks_per_bar == 0
147 if col in bar_sep_cols:
148 beat_line += " "
149 beat_line += f"{beat_in_bar:<3}" if is_downbeat else " "
150 lines.append(beat_line)
151
152 return lines
153
154 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
155 """Register the piano-roll subcommand."""
156 parser = subparsers.add_parser("piano-roll", help="Render an ASCII piano roll of a MIDI track.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
157 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
158 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Render from a historical snapshot instead of the working tree.")
159 parser.add_argument("--bars", "-b", metavar="START-END", default=None, dest="bars_range", help='Bar range to render, e.g. "1-8". Default: first 8 bars.')
160 parser.add_argument("--resolution", "-r", metavar="N", type=int, default=2, help="Grid cells per beat (1=quarter, 2=eighth, 4=sixteenth). Default: 2.")
161 parser.set_defaults(func=run)
162
163 def run(args: argparse.Namespace) -> None:
164 """Render an ASCII piano roll of a MIDI track.
165
166 ``muse piano-roll`` produces a terminal-friendly piano roll view:
167 time runs left-to-right, pitch runs bottom-to-top. Bar lines are
168 shown as vertical separators. Each note onset shows the pitch name;
169 sustained portions show "═══".
170
171 Use ``--bars`` to show a specific bar range. Use ``--resolution``
172 to control grid density (2 = eighth-note resolution, the default).
173
174 This command works on any historical snapshot via ``--commit``, letting
175 you visually compare compositions across commits.
176 """
177 track: str = args.track
178 ref: str | None = args.ref
179 bars_range: str | None = args.bars_range
180 resolution: int = clamp_int(args.resolution, 1, 10000, 'resolution')
181
182 root = require_repo()
183
184 result: tuple[list[NoteInfo], int] | None
185 commit_label = "working tree"
186
187 if ref is not None:
188 branch = _read_branch(root)
189 commit = resolve_commit_ref(root, branch, ref)
190 if commit is None:
191 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
192 raise SystemExit(ExitCode.USER_ERROR)
193 result = load_track(root, commit.commit_id, track)
194 commit_label = commit.commit_id
195 else:
196 result = load_track_from_workdir(root, track)
197
198 if result is None:
199 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
200 raise SystemExit(ExitCode.USER_ERROR)
201
202 note_list, tpb = result
203
204 # Parse bar range.
205 bar_start = 1
206 bar_end = 8
207 if bars_range is not None:
208 parts = bars_range.split("-", 1)
209 try:
210 bar_start = int(parts[0])
211 bar_end = int(parts[1]) if len(parts) > 1 else bar_start + 7
212 except ValueError:
213 print(f"❌ Invalid bar range '{bars_range}'. Use 'START-END' e.g. '1-8'.", file=sys.stderr)
214 raise SystemExit(ExitCode.USER_ERROR)
215
216 print(
217 f"\nPiano roll: {track} — {commit_label} "
218 f"(bars {bar_start}–{bar_end}, res={resolution} cells/beat)"
219 )
220 print("")
221
222 lines = _render_piano_roll(note_list, tpb, bar_start, bar_end, resolution)
223 for line in lines:
224 print(line)
File History 2 commits
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 22 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 74 days ago