gabriel / muse public
notes.py python
151 lines 5.7 KB
Raw
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 22 hours ago
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 import argparse
35 import json
36 import logging
37 import pathlib
38 import sys
39
40 from muse.core.errors import ExitCode
41 from muse.core.repo import require_repo
42 from muse.core.refs import (
43 get_head_commit_id,
44 read_current_branch,
45 )
46 from muse.core.commits import resolve_commit_ref
47 from muse.plugins.midi._query import (
48 NoteInfo,
49 key_signature_guess,
50 load_track,
51 load_track_from_workdir,
52 )
53
54 logger = logging.getLogger(__name__)
55
56 def _read_branch(root: pathlib.Path) -> str:
57 return read_current_branch(root)
58
59 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
60 """Register the notes subcommand."""
61 parser = subparsers.add_parser("notes", help="Show every note in a MIDI track as structured musical data.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
62 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
63 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Read from a historical commit instead of the working tree.")
64 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).")
65 parser.add_argument("--channel", "-C", metavar="N", type=int, default=None, dest="channel_filter", help="Only show notes on MIDI channel N (0-based).")
66 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
67 parser.set_defaults(func=run)
68
69 def run(args: argparse.Namespace) -> None:
70 """Show every note in a MIDI track as structured musical data.
71
72 ``muse notes`` parses the MIDI file and displays all notes with pitch
73 name, beat position, bar number, duration, velocity, and channel.
74
75 Use ``--commit`` to inspect a historical snapshot. Use ``--bar`` to
76 focus on a single bar. Use ``--json`` for pipeline integration.
77
78 Unlike ``git show`` which gives you a raw binary diff, ``muse notes``
79 gives you the actual musical content at any point in history — sorted
80 by time, readable as music notation.
81 """
82 track: str = args.track
83 ref: str | None = args.ref
84 bar_filter: int | None = args.bar_filter
85 channel_filter: int | None = args.channel_filter
86 as_json: bool = args.as_json
87
88 root = require_repo()
89
90 result: tuple[list[NoteInfo], int] | None
91 commit_label = "working tree"
92
93 if ref is not None:
94 branch = _read_branch(root)
95 commit = resolve_commit_ref(root, branch, ref)
96 if commit is None:
97 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
98 raise SystemExit(ExitCode.USER_ERROR)
99 result = load_track(root, commit.commit_id, track)
100 commit_label = commit.commit_id
101 else:
102 result = load_track_from_workdir(root, track)
103
104 if result is None:
105 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
106 raise SystemExit(ExitCode.USER_ERROR)
107
108 note_list, tpb = result
109
110 # Apply filters.
111 if bar_filter is not None:
112 note_list = [n for n in note_list if n.bar == bar_filter]
113 if channel_filter is not None:
114 note_list = [n for n in note_list if n.channel == channel_filter]
115
116 if as_json:
117 out = [
118 {
119 "pitch": n.pitch,
120 "pitch_name": n.pitch_name,
121 "velocity": n.velocity,
122 "start_tick": n.start_tick,
123 "duration_ticks": n.duration_ticks,
124 "beat": round(n.beat, 4),
125 "beat_duration": round(n.beat_duration, 4),
126 "bar": n.bar,
127 "beat_in_bar": round(n.beat_in_bar, 2),
128 "channel": n.channel,
129 }
130 for n in note_list
131 ]
132 print(json.dumps({"track": track, "commit": commit_label, "notes": out}))
133 return
134
135 bars_seen: set[int] = {n.bar for n in note_list}
136
137 key = key_signature_guess(note_list) if not bar_filter and not channel_filter else ""
138 key_line = f"\nKey signature (estimated): {key}" if key else ""
139
140 print(f"\n{track} — {len(note_list)} notes — {commit_label}{key_line}")
141 print("")
142 print(f" {'Bar':>4} {'Beat':>5} {'Pitch':<6} {'Vel':>3} {'Dur':>10} Channel")
143 print(f" {'─' * 50}")
144
145 for note in note_list:
146 print(
147 f" {note.bar:>4} {note.beat_in_bar:>5.2f} {note.pitch_name:<6} "
148 f"{note.velocity:>3} {note.beat_duration:>10.2f} ch {note.channel}"
149 )
150
151 print(f"\n{len(note_list)} note(s) across {len(bars_seen)} bar(s)")
File History 1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 22 hours ago