gabriel / muse public
tempo.py python
110 lines 3.8 KB
00373ad0 feat: migrate CLI from typer to argparse (POSIX-compliant, order-independent) Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """muse tempo — estimate and report the tempo of a MIDI track.
2
3 Estimates BPM from inter-onset intervals and reports the ticks-per-beat
4 metadata. For agent workflows that need to match tempo across branches or
5 verify that time-stretching operations preserved the rhythmic grid.
6
7 Usage::
8
9 muse tempo tracks/drums.mid
10 muse tempo tracks/bass.mid --commit HEAD~2
11 muse tempo tracks/melody.mid --json
12
13 Output::
14
15 Tempo analysis: tracks/drums.mid — working tree
16 Estimated BPM: 120.0
17 Ticks per beat: 480
18 Confidence: high (ioi_voting method)
19
20 Note: BPM is estimated from inter-onset intervals.
21 For authoritative BPM, embed a MIDI tempo event at tick 0.
22 """
23
24 from __future__ import annotations
25
26 import argparse
27 import json
28 import logging
29 import pathlib
30 import sys
31
32 from muse.core.errors import ExitCode
33 from muse.core.repo import require_repo
34 from muse.core.store import read_current_branch, resolve_commit_ref
35 from muse.plugins.midi._analysis import estimate_tempo
36 from muse.plugins.midi._query import load_track, load_track_from_workdir
37
38 logger = logging.getLogger(__name__)
39
40
41 def _read_repo_id(root: pathlib.Path) -> str:
42 import json as _json
43
44 return str(_json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
45
46
47 def _read_branch(root: pathlib.Path) -> str:
48 return read_current_branch(root)
49
50
51 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
52 """Register the tempo subcommand."""
53 parser = subparsers.add_parser("tempo", help="Estimate the BPM of a MIDI track from inter-onset intervals.", description=__doc__)
54 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
55 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
56 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
57 parser.set_defaults(func=run)
58
59
60 def run(args: argparse.Namespace) -> None:
61 """Estimate the BPM of a MIDI track from inter-onset intervals.
62
63 ``muse tempo`` uses IOI voting to estimate the underlying beat duration
64 and converts it to BPM. Confidence is rated high/medium/low based on
65 how consistently notes cluster around a common beat subdivision.
66
67 For agents: use this to verify that time-stretch transformations
68 produced the expected tempo, or to detect BPM drift between branches.
69 """
70 track: str = args.track
71 ref: str | None = args.ref
72 as_json: bool = args.as_json
73
74 root = require_repo()
75 commit_label = "working tree"
76
77 if ref is not None:
78 repo_id = _read_repo_id(root)
79 branch = _read_branch(root)
80 commit = resolve_commit_ref(root, repo_id, branch, ref)
81 if commit is None:
82 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
83 raise SystemExit(ExitCode.USER_ERROR)
84 result = load_track(root, commit.commit_id, track)
85 commit_label = commit.commit_id[:8]
86 else:
87 result = load_track_from_workdir(root, track)
88
89 if result is None:
90 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
91 raise SystemExit(ExitCode.USER_ERROR)
92
93 notes, _tpb = result
94 if not notes:
95 print(f" (no notes found in '{track}')")
96 return
97
98 est = estimate_tempo(notes)
99
100 if as_json:
101 print(json.dumps({"track": track, "commit": commit_label, **est}, indent=2))
102 return
103
104 print(f"\nTempo analysis: {track} — {commit_label}")
105 print(f"Estimated BPM: {est['estimated_bpm']}")
106 print(f"Ticks per beat: {est['ticks_per_beat']}")
107 print(f"Confidence: {est['confidence']} ({est['method']} method)")
108 print("")
109 print("Note: BPM is estimated from inter-onset intervals.")
110 print("For authoritative BPM, embed a MIDI tempo event at tick 0.")