cgcardona / muse public
piano_roll.py python
238 lines 8.2 KB
26a36470 feat(music): 9 new semantic commands — version control that understands music 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 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)