gabriel / muse public
rhythm.py python
132 lines 4.6 KB
Raw
sha256:b6cae4448122b2cc690d913be26f7e0a539f11855b8d288bd48be43eb532b5b2 refactor: migrate all source callers off muse.core.store re… Sonnet 4.6 minor ⚠ breaking 29 days 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 import argparse
24 import json
25 import logging
26 import pathlib
27 import sys
28
29 from muse.core.errors import ExitCode
30 from muse.core.repo import require_repo
31 from muse.core.refs import read_current_branch
32 from muse.core.commits import resolve_commit_ref
33 from muse.plugins.midi._analysis import RhythmAnalysis, analyze_rhythm
34 from muse.plugins.midi._query import load_track, load_track_from_workdir
35
36 logger = logging.getLogger(__name__)
37
38 def _read_branch(root: pathlib.Path) -> str:
39 return read_current_branch(root)
40
41 def _quant_label(score: float) -> str:
42 if score >= 0.95:
43 return "very tight"
44 if score >= 0.80:
45 return "tight"
46 if score >= 0.60:
47 return "moderate"
48 return "loose / human"
49
50 def _synco_label(score: float) -> str:
51 if score < 0.10:
52 return "straight"
53 if score < 0.30:
54 return "mild"
55 if score < 0.55:
56 return "moderate"
57 return "highly syncopated"
58
59 def _swing_label(ratio: float) -> str:
60 if ratio < 1.10:
61 return "straight"
62 if ratio < 1.30:
63 return "light swing"
64 if ratio < 1.60:
65 return "moderate swing"
66 return "heavy swing"
67
68 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
69 """Register the rhythm subcommand."""
70 parser = subparsers.add_parser("rhythm", help="Quantify syncopation, swing, and quantisation accuracy in a MIDI track.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
71 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
72 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
73 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
74 parser.set_defaults(func=run)
75
76 def run(args: argparse.Namespace) -> None:
77 """Quantify syncopation, swing, and quantisation accuracy in a MIDI track.
78
79 ``muse rhythm`` gives agents and composers a numerical fingerprint of a
80 track's rhythmic character — how quantised is it, how much does it swing,
81 how syncopated? These metrics are invisible in Git; Muse computes them
82 from structured note data at any point in history.
83
84 Use ``--json`` for agent-readable output to drive automated rhythmic
85 quality gates or style-matching pipelines.
86 """
87 track: str = args.track
88 ref: str | None = args.ref
89 as_json: bool = args.as_json
90
91 root = require_repo()
92 commit_label = "working tree"
93
94 if ref is not None:
95 branch = _read_branch(root)
96 commit = resolve_commit_ref(root, branch, ref)
97 if commit is None:
98 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
99 raise SystemExit(ExitCode.USER_ERROR)
100 result = load_track(root, commit.commit_id, track)
101 commit_label = commit.commit_id
102 else:
103 result = load_track_from_workdir(root, track)
104
105 if result is None:
106 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
107 raise SystemExit(ExitCode.USER_ERROR)
108
109 notes, _tpb = result
110 if not notes:
111 print(f" (no notes found in '{track}')")
112 return
113
114 analysis: RhythmAnalysis = analyze_rhythm(notes)
115
116 if as_json:
117 print(json.dumps({"track": track, "commit": commit_label, **analysis}))
118 return
119
120 print(f"\nRhythmic analysis: {track} — {commit_label}")
121 print(
122 f"Notes: {analysis['total_notes']} · "
123 f"Bars: {analysis['bars']} · "
124 f"Notes/bar avg: {analysis['notes_per_bar_avg']}"
125 )
126 print(f"Dominant subdivision: {analysis['dominant_subdivision']}")
127 qs = analysis["quantization_score"]
128 ss = analysis["syncopation_score"]
129 sw = analysis["swing_ratio"]
130 print(f"Quantisation score: {qs:.3f} ({_quant_label(qs)})")
131 print(f"Syncopation score: {ss:.3f} ({_synco_label(ss)})")
132 print(f"Swing ratio: {sw:.3f} ({_swing_label(sw)})")
File History 1 commit
sha256:b6cae4448122b2cc690d913be26f7e0a539f11855b8d288bd48be43eb532b5b2 refactor: migrate all source callers off muse.core.store re… Sonnet 4.6 minor 29 days ago