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