gabriel / muse public
rhythm.py python
143 lines 4.5 KB
f7645c07 feat(store): self-describing HEAD format with typed read/write API (#163) Gabriel Cardona <cgcardona@gmail.com> 3d ago
1 """muse rhythm — rhythmic analysis of a MIDI track.
2
3 Quantifies syncopation, quantisation accuracy, swing ratio, and dominant note
4 length. In a world of agent swarms, rhythm is the temporal contract between
5 parts — this command makes it inspectable and diffable across commits.
6
7 Usage::
8
9 muse rhythm tracks/drums.mid
10 muse rhythm tracks/melody.mid --commit HEAD~3
11 muse rhythm tracks/bass.mid --json
12
13 Output::
14
15 Rhythmic analysis: tracks/drums.mid — working tree
16 Notes: 64 · Bars: 8 · Notes/bar avg: 8.0
17 Dominant subdivision: sixteenth
18 Quantisation score: 0.94 (very tight)
19 Syncopation score: 0.31 (moderate)
20 Swing ratio: 1.42 (moderate swing)
21 """
22
23 from __future__ import annotations
24
25 import json
26 import logging
27 import pathlib
28
29 import typer
30
31 from muse.core.errors import ExitCode
32 from muse.core.repo import require_repo
33 from muse.core.store import read_current_branch, resolve_commit_ref
34 from muse.plugins.midi._analysis import RhythmAnalysis, analyze_rhythm
35 from muse.plugins.midi._query import load_track, load_track_from_workdir
36
37 logger = logging.getLogger(__name__)
38 app = typer.Typer()
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 _quant_label(score: float) -> str:
52 if score >= 0.95:
53 return "very tight"
54 if score >= 0.80:
55 return "tight"
56 if score >= 0.60:
57 return "moderate"
58 return "loose / human"
59
60
61 def _synco_label(score: float) -> str:
62 if score < 0.10:
63 return "straight"
64 if score < 0.30:
65 return "mild"
66 if score < 0.55:
67 return "moderate"
68 return "highly syncopated"
69
70
71 def _swing_label(ratio: float) -> str:
72 if ratio < 1.10:
73 return "straight"
74 if ratio < 1.30:
75 return "light swing"
76 if ratio < 1.60:
77 return "moderate swing"
78 return "heavy swing"
79
80
81 @app.callback(invoke_without_command=True)
82 def rhythm(
83 ctx: typer.Context,
84 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
85 ref: str | None = typer.Option(
86 None, "--commit", "-c", metavar="REF",
87 help="Analyse a historical snapshot instead of the working tree.",
88 ),
89 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
90 ) -> None:
91 """Quantify syncopation, swing, and quantisation accuracy in a MIDI track.
92
93 ``muse rhythm`` gives agents and composers a numerical fingerprint of a
94 track's rhythmic character — how quantised is it, how much does it swing,
95 how syncopated? These metrics are invisible in Git; Muse computes them
96 from structured note data at any point in history.
97
98 Use ``--json`` for agent-readable output to drive automated rhythmic
99 quality gates or style-matching pipelines.
100 """
101 root = require_repo()
102 commit_label = "working tree"
103
104 if ref is not None:
105 repo_id = _read_repo_id(root)
106 branch = _read_branch(root)
107 commit = resolve_commit_ref(root, repo_id, branch, ref)
108 if commit is None:
109 typer.echo(f"❌ Commit '{ref}' not found.", err=True)
110 raise typer.Exit(code=ExitCode.USER_ERROR)
111 result = load_track(root, commit.commit_id, track)
112 commit_label = commit.commit_id[:8]
113 else:
114 result = load_track_from_workdir(root, track)
115
116 if result is None:
117 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
118 raise typer.Exit(code=ExitCode.USER_ERROR)
119
120 notes, _tpb = result
121 if not notes:
122 typer.echo(f" (no notes found in '{track}')")
123 return
124
125 analysis: RhythmAnalysis = analyze_rhythm(notes)
126
127 if as_json:
128 typer.echo(json.dumps({"track": track, "commit": commit_label, **analysis}, indent=2))
129 return
130
131 typer.echo(f"\nRhythmic analysis: {track} — {commit_label}")
132 typer.echo(
133 f"Notes: {analysis['total_notes']} · "
134 f"Bars: {analysis['bars']} · "
135 f"Notes/bar avg: {analysis['notes_per_bar_avg']}"
136 )
137 typer.echo(f"Dominant subdivision: {analysis['dominant_subdivision']}")
138 qs = analysis["quantization_score"]
139 ss = analysis["syncopation_score"]
140 sw = analysis["swing_ratio"]
141 typer.echo(f"Quantisation score: {qs:.3f} ({_quant_label(qs)})")
142 typer.echo(f"Syncopation score: {ss:.3f} ({_synco_label(ss)})")
143 typer.echo(f"Swing ratio: {sw:.3f} ({_swing_label(sw)})")