piano_roll.py
python
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