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