notes.py
python
| 1 | """muse notes — musical notation view of a MIDI track. |
| 2 | |
| 3 | Shows every note in a MIDI file as structured musical data: pitch name, |
| 4 | beat position, bar number, duration, velocity, and MIDI channel. |
| 5 | |
| 6 | Unlike ``git show`` which gives you a binary blob diff, ``muse notes`` |
| 7 | gives you the actual musical content — readable, sorted, historical. |
| 8 | |
| 9 | Usage:: |
| 10 | |
| 11 | muse notes tracks/melody.mid |
| 12 | muse notes tracks/bass.mid --commit HEAD~3 |
| 13 | muse notes tracks/drums.mid --bar 4 # only notes in bar 4 |
| 14 | muse notes tracks/melody.mid --channel 0 # only channel 0 |
| 15 | muse notes tracks/melody.mid --json |
| 16 | |
| 17 | Output:: |
| 18 | |
| 19 | tracks/melody.mid — 23 notes — commit cb4afaed |
| 20 | Key signature (estimated): G major |
| 21 | |
| 22 | Bar Beat Pitch Vel Dur(beats) Channel |
| 23 | ───────────────────────────────────────────────── |
| 24 | 1 1.00 G4 80 1.00 ch 0 |
| 25 | 1 2.00 B4 75 0.50 ch 0 |
| 26 | 1 2.50 D5 72 0.50 ch 0 |
| 27 | 1 3.00 G4 80 1.00 ch 0 |
| 28 | 2 1.00 A4 78 1.00 ch 0 |
| 29 | ... |
| 30 | |
| 31 | 23 note(s) across 8 bar(s) |
| 32 | """ |
| 33 | |
| 34 | from __future__ import annotations |
| 35 | |
| 36 | import argparse |
| 37 | import json |
| 38 | import logging |
| 39 | import pathlib |
| 40 | import sys |
| 41 | |
| 42 | from muse.core.errors import ExitCode |
| 43 | from muse.core.repo import require_repo |
| 44 | from muse.core.store import get_head_commit_id, read_current_branch, resolve_commit_ref |
| 45 | from muse.plugins.midi._query import ( |
| 46 | NoteInfo, |
| 47 | key_signature_guess, |
| 48 | load_track, |
| 49 | load_track_from_workdir, |
| 50 | ) |
| 51 | |
| 52 | logger = logging.getLogger(__name__) |
| 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 | return read_current_branch(root) |
| 61 | |
| 62 | |
| 63 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 64 | """Register the notes subcommand.""" |
| 65 | parser = subparsers.add_parser("notes", help="Show every note in a MIDI track as structured musical data.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 66 | parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.") |
| 67 | parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Read from a historical commit instead of the working tree.") |
| 68 | parser.add_argument("--bar", "-b", metavar="N", type=int, default=None, dest="bar_filter", help="Only show notes in bar N (1-indexed, assumes 4/4 time).") |
| 69 | parser.add_argument("--channel", "-C", metavar="N", type=int, default=None, dest="channel_filter", help="Only show notes on MIDI channel N (0-based).") |
| 70 | parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.") |
| 71 | parser.set_defaults(func=run) |
| 72 | |
| 73 | |
| 74 | def run(args: argparse.Namespace) -> None: |
| 75 | """Show every note in a MIDI track as structured musical data. |
| 76 | |
| 77 | ``muse notes`` parses the MIDI file and displays all notes with pitch |
| 78 | name, beat position, bar number, duration, velocity, and channel. |
| 79 | |
| 80 | Use ``--commit`` to inspect a historical snapshot. Use ``--bar`` to |
| 81 | focus on a single bar. Use ``--json`` for pipeline integration. |
| 82 | |
| 83 | Unlike ``git show`` which gives you a raw binary diff, ``muse notes`` |
| 84 | gives you the actual musical content at any point in history — sorted |
| 85 | by time, readable as music notation. |
| 86 | """ |
| 87 | track: str = args.track |
| 88 | ref: str | None = args.ref |
| 89 | bar_filter: int | None = args.bar_filter |
| 90 | channel_filter: int | None = args.channel_filter |
| 91 | as_json: bool = args.as_json |
| 92 | |
| 93 | root = require_repo() |
| 94 | |
| 95 | result: tuple[list[NoteInfo], int] | None |
| 96 | commit_label = "working tree" |
| 97 | |
| 98 | if ref is not None: |
| 99 | repo_id = _read_repo_id(root) |
| 100 | branch = _read_branch(root) |
| 101 | commit = resolve_commit_ref(root, repo_id, branch, ref) |
| 102 | if commit is None: |
| 103 | print(f"❌ Commit '{ref}' not found.", file=sys.stderr) |
| 104 | raise SystemExit(ExitCode.USER_ERROR) |
| 105 | result = load_track(root, commit.commit_id, track) |
| 106 | commit_label = commit.commit_id[:8] |
| 107 | else: |
| 108 | result = load_track_from_workdir(root, track) |
| 109 | |
| 110 | if result is None: |
| 111 | print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr) |
| 112 | raise SystemExit(ExitCode.USER_ERROR) |
| 113 | |
| 114 | note_list, tpb = result |
| 115 | |
| 116 | # Apply filters. |
| 117 | if bar_filter is not None: |
| 118 | note_list = [n for n in note_list if n.bar == bar_filter] |
| 119 | if channel_filter is not None: |
| 120 | note_list = [n for n in note_list if n.channel == channel_filter] |
| 121 | |
| 122 | if as_json: |
| 123 | out: list[dict[str, str | int | float]] = [ |
| 124 | { |
| 125 | "pitch": n.pitch, |
| 126 | "pitch_name": n.pitch_name, |
| 127 | "velocity": n.velocity, |
| 128 | "start_tick": n.start_tick, |
| 129 | "duration_ticks": n.duration_ticks, |
| 130 | "beat": round(n.beat, 4), |
| 131 | "beat_duration": round(n.beat_duration, 4), |
| 132 | "bar": n.bar, |
| 133 | "beat_in_bar": round(n.beat_in_bar, 2), |
| 134 | "channel": n.channel, |
| 135 | } |
| 136 | for n in note_list |
| 137 | ] |
| 138 | print(json.dumps({"track": track, "commit": commit_label, "notes": out}, indent=2)) |
| 139 | return |
| 140 | |
| 141 | bars_seen: set[int] = {n.bar for n in note_list} |
| 142 | |
| 143 | key = key_signature_guess(note_list) if not bar_filter and not channel_filter else "" |
| 144 | key_line = f"\nKey signature (estimated): {key}" if key else "" |
| 145 | |
| 146 | print(f"\n{track} — {len(note_list)} notes — {commit_label}{key_line}") |
| 147 | print("") |
| 148 | print(f" {'Bar':>4} {'Beat':>5} {'Pitch':<6} {'Vel':>3} {'Dur':>10} Channel") |
| 149 | print(" " + "─" * 50) |
| 150 | |
| 151 | for note in note_list: |
| 152 | print( |
| 153 | f" {note.bar:>4} {note.beat_in_bar:>5.2f} {note.pitch_name:<6} " |
| 154 | f"{note.velocity:>3} {note.beat_duration:>10.2f} ch {note.channel}" |
| 155 | ) |
| 156 | |
| 157 | print(f"\n{len(note_list)} note(s) across {len(bars_seen)} bar(s)") |