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