cgcardona / muse public
notes.py python
159 lines 5.5 KB
9ee9c39c refactor: rename music→midi domain, strip all 5-dim backward compat Gabriel Cardona <gabriel@tellurstori.com> 1d 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 from __future__ import annotations
34
35 import json
36 import logging
37 import pathlib
38
39 import typer
40
41 from muse.core.errors import ExitCode
42 from muse.core.repo import require_repo
43 from muse.core.store import get_head_commit_id, resolve_commit_ref
44 from muse.plugins.midi._query import (
45 NoteInfo,
46 key_signature_guess,
47 load_track,
48 load_track_from_workdir,
49 )
50
51 logger = logging.getLogger(__name__)
52
53 app = typer.Typer()
54
55
56 def _read_repo_id(root: pathlib.Path) -> str:
57 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
58
59
60 def _read_branch(root: pathlib.Path) -> str:
61 head_ref = (root / ".muse" / "HEAD").read_text().strip()
62 return head_ref.removeprefix("refs/heads/").strip()
63
64
65 @app.callback(invoke_without_command=True)
66 def notes(
67 ctx: typer.Context,
68 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
69 ref: str | None = typer.Option(
70 None, "--commit", "-c", metavar="REF",
71 help="Read from a historical commit instead of the working tree.",
72 ),
73 bar_filter: int | None = typer.Option(
74 None, "--bar", "-b", metavar="N",
75 help="Only show notes in bar N (1-indexed, assumes 4/4 time).",
76 ),
77 channel_filter: int | None = typer.Option(
78 None, "--channel", "-C", metavar="N",
79 help="Only show notes on MIDI channel N (0-based).",
80 ),
81 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
82 ) -> None:
83 """Show every note in a MIDI track as structured musical data.
84
85 ``muse notes`` parses the MIDI file and displays all notes with pitch
86 name, beat position, bar number, duration, velocity, and channel.
87
88 Use ``--commit`` to inspect a historical snapshot. Use ``--bar`` to
89 focus on a single bar. Use ``--json`` for pipeline integration.
90
91 Unlike ``git show`` which gives you a raw binary diff, ``muse notes``
92 gives you the actual musical content at any point in history — sorted
93 by time, readable as music notation.
94 """
95 root = require_repo()
96
97 result: tuple[list[NoteInfo], int] | None
98 commit_label = "working tree"
99
100 if ref is not None:
101 repo_id = _read_repo_id(root)
102 branch = _read_branch(root)
103 commit = resolve_commit_ref(root, repo_id, branch, ref)
104 if commit is None:
105 typer.echo(f"❌ Commit '{ref}' not found.", err=True)
106 raise typer.Exit(code=ExitCode.USER_ERROR)
107 result = load_track(root, commit.commit_id, track)
108 commit_label = commit.commit_id[:8]
109 else:
110 result = load_track_from_workdir(root, track)
111
112 if result is None:
113 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
114 raise typer.Exit(code=ExitCode.USER_ERROR)
115
116 note_list, tpb = result
117
118 # Apply filters.
119 if bar_filter is not None:
120 note_list = [n for n in note_list if n.bar == bar_filter]
121 if channel_filter is not None:
122 note_list = [n for n in note_list if n.channel == channel_filter]
123
124 if as_json:
125 out: list[dict[str, str | int | float]] = [
126 {
127 "pitch": n.pitch,
128 "pitch_name": n.pitch_name,
129 "velocity": n.velocity,
130 "start_tick": n.start_tick,
131 "duration_ticks": n.duration_ticks,
132 "beat": round(n.beat, 4),
133 "beat_duration": round(n.beat_duration, 4),
134 "bar": n.bar,
135 "beat_in_bar": round(n.beat_in_bar, 2),
136 "channel": n.channel,
137 }
138 for n in note_list
139 ]
140 typer.echo(json.dumps({"track": track, "commit": commit_label, "notes": out}, indent=2))
141 return
142
143 bars_seen: set[int] = {n.bar for n in note_list}
144
145 key = key_signature_guess(note_list) if not bar_filter and not channel_filter else ""
146 key_line = f"\nKey signature (estimated): {key}" if key else ""
147
148 typer.echo(f"\n{track} — {len(note_list)} notes — {commit_label}{key_line}")
149 typer.echo("")
150 typer.echo(f" {'Bar':>4} {'Beat':>5} {'Pitch':<6} {'Vel':>3} {'Dur':>10} Channel")
151 typer.echo(" " + "─" * 50)
152
153 for note in note_list:
154 typer.echo(
155 f" {note.bar:>4} {note.beat_in_bar:>5.2f} {note.pitch_name:<6} "
156 f"{note.velocity:>3} {note.beat_duration:>10.2f} ch {note.channel}"
157 )
158
159 typer.echo(f"\n{len(note_list)} note(s) across {len(bars_seen)} bar(s)")