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