tempo.py
python
| 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.") |