cgcardona / muse public
tempo.py python
107 lines 3.6 KB
630bfa59 feat(midi): add 20 new semantic porcelain commands (#120) Gabriel Cardona <cgcardona@gmail.com> 14h 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 json
27 import logging
28 import pathlib
29
30 import typer
31
32 from muse.core.errors import ExitCode
33 from muse.core.repo import require_repo
34 from muse.core.store import 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 app = typer.Typer()
40
41
42 def _read_repo_id(root: pathlib.Path) -> str:
43 import json as _json
44
45 return str(_json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
46
47
48 def _read_branch(root: pathlib.Path) -> str:
49 return (root / ".muse" / "HEAD").read_text().strip().removeprefix("refs/heads/").strip()
50
51
52 @app.callback(invoke_without_command=True)
53 def tempo(
54 ctx: typer.Context,
55 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
56 ref: str | None = typer.Option(
57 None, "--commit", "-c", metavar="REF",
58 help="Analyse a historical snapshot instead of the working tree.",
59 ),
60 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
61 ) -> None:
62 """Estimate the BPM of a MIDI track from inter-onset intervals.
63
64 ``muse tempo`` uses IOI voting to estimate the underlying beat duration
65 and converts it to BPM. Confidence is rated high/medium/low based on
66 how consistently notes cluster around a common beat subdivision.
67
68 For agents: use this to verify that time-stretch transformations
69 produced the expected tempo, or to detect BPM drift between branches.
70 """
71 root = require_repo()
72 commit_label = "working tree"
73
74 if ref is not None:
75 repo_id = _read_repo_id(root)
76 branch = _read_branch(root)
77 commit = resolve_commit_ref(root, repo_id, branch, ref)
78 if commit is None:
79 typer.echo(f"❌ Commit '{ref}' not found.", err=True)
80 raise typer.Exit(code=ExitCode.USER_ERROR)
81 result = load_track(root, commit.commit_id, track)
82 commit_label = commit.commit_id[:8]
83 else:
84 result = load_track_from_workdir(root, track)
85
86 if result is None:
87 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
88 raise typer.Exit(code=ExitCode.USER_ERROR)
89
90 notes, _tpb = result
91 if not notes:
92 typer.echo(f" (no notes found in '{track}')")
93 return
94
95 est = estimate_tempo(notes)
96
97 if as_json:
98 typer.echo(json.dumps({"track": track, "commit": commit_label, **est}, indent=2))
99 return
100
101 typer.echo(f"\nTempo analysis: {track} — {commit_label}")
102 typer.echo(f"Estimated BPM: {est['estimated_bpm']}")
103 typer.echo(f"Ticks per beat: {est['ticks_per_beat']}")
104 typer.echo(f"Confidence: {est['confidence']} ({est['method']} method)")
105 typer.echo("")
106 typer.echo("Note: BPM is estimated from inter-onset intervals.")
107 typer.echo("For authoritative BPM, embed a MIDI tempo event at tick 0.")