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