gabriel / muse public
density.py python
121 lines 4.3 KB
Raw
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e chore: remove blob-debug test marker file Sonnet 4.6 17 hours ago
1 """muse density — note density analysis per bar for a MIDI track.
2
3 Shows how many notes per beat fall in each bar, revealing texture changes:
4 sparse verses, dense choruses, quiet codas. A swarm of agents editing
5 different sections can use this to reason about arrangement density without
6 audio playback.
7
8 Usage::
9
10 muse density tracks/piano.mid
11 muse density tracks/melody.mid --commit HEAD~4
12 muse density tracks/rhythm.mid --json
13
14 Output::
15
16 Note density: tracks/piano.mid — working tree
17 Bars: 16 · Peak: bar 9 (4.25 notes/beat) · Avg: 2.1
18
19 bar 1 ████████ 2.00 notes/beat ( 8 notes)
20 bar 2 ██████████████ 3.50 notes/beat (14 notes)
21 bar 3 ████ 1.00 notes/beat ( 4 notes)
22 ...
23 """
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 require_repo
33 from muse.core.refs import read_current_branch
34 from muse.core.commits import resolve_commit_ref
35 from muse.plugins.midi._analysis import analyze_density
36 from muse.plugins.midi._query import load_track, load_track_from_workdir
37
38 logger = logging.getLogger(__name__)
39
40 _BAR_WIDTH = 32
41
42 def _read_branch(root: pathlib.Path) -> str:
43 return read_current_branch(root)
44
45 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
46 """Register the density subcommand."""
47 parser = subparsers.add_parser("density", help="Show note density (notes per beat) per bar of a MIDI track.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
48 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
49 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
50 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
51 parser.set_defaults(func=run)
52
53 def run(args: argparse.Namespace) -> None:
54 """Show note density (notes per beat) per bar of a MIDI track.
55
56 ``muse density`` reveals the textural arc of a composition: which bars are
57 dense, which are sparse. Agents orchestrating multi-part arrangements use
58 this to avoid over-crowding any single section, and to verify that section
59 transitions (verse → chorus) are properly contrast-shaped.
60
61 Git cannot do this. Muse stores notes as structured data, so density is
62 computable at any historical snapshot with no manual inspection.
63 """
64 track: str = args.track
65 ref: str | None = args.ref
66 as_json: bool = args.as_json
67
68 root = require_repo()
69 commit_label = "working tree"
70
71 if ref is not None:
72 branch = _read_branch(root)
73 commit = resolve_commit_ref(root, branch, ref)
74 if commit is None:
75 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
76 raise SystemExit(ExitCode.USER_ERROR)
77 result = load_track(root, commit.commit_id, track)
78 commit_label = commit.commit_id
79 else:
80 result = load_track_from_workdir(root, track)
81
82 if result is None:
83 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
84 raise SystemExit(ExitCode.USER_ERROR)
85
86 notes, _tpb = result
87 if not notes:
88 print(f" (no notes found in '{track}')")
89 return
90
91 bars = analyze_density(notes)
92
93 if as_json:
94 print(json.dumps(
95 {"track": track, "commit": commit_label, "bars": list(bars)},
96 ))
97 return
98
99 if not bars:
100 print(" (no bars detected)")
101 return
102
103 peak_bar = max(bars, key=lambda b: b["notes_per_beat"])
104 avg_npb = sum(b["notes_per_beat"] for b in bars) / len(bars)
105
106 print(f"\nNote density: {track} — {commit_label}")
107 print(
108 f"Bars: {len(bars)} · "
109 f"Peak: bar {peak_bar['bar']} ({peak_bar['notes_per_beat']} notes/beat) · "
110 f"Avg: {avg_npb:.1f}"
111 )
112 print("")
113
114 max_npb = max(b["notes_per_beat"] for b in bars) or 1.0
115 for b in bars:
116 fill = int(b["notes_per_beat"] / max_npb * _BAR_WIDTH)
117 print(
118 f" bar {b['bar']:>3} {'█' * fill:<{_BAR_WIDTH}}"
119 f" {b['notes_per_beat']:>5.2f} notes/beat"
120 f" ({b['note_count']:>3} notes)"
121 )
File History 7 commits
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e chore: remove blob-debug test marker file Sonnet 4.6 17 hours ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 8 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 9 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 23 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago