density.py python
128 lines 4.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days 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 from __future__ import annotations
26
27 import argparse
28 import json
29 import logging
30 import pathlib
31 import sys
32
33 from muse.core.errors import ExitCode
34 from muse.core.repo import read_repo_id, require_repo
35 from muse.core.store import read_current_branch, resolve_commit_ref
36 from muse.plugins.midi._analysis import analyze_density
37 from muse.plugins.midi._query import load_track, load_track_from_workdir
38
39 logger = logging.getLogger(__name__)
40
41 _BAR_WIDTH = 32
42
43
44
45 def _read_branch(root: pathlib.Path) -> str:
46 return read_current_branch(root)
47
48
49 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
50 """Register the density subcommand."""
51 parser = subparsers.add_parser("density", help="Show note density (notes per beat) per bar of a MIDI track.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
52 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
53 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
54 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
55 parser.set_defaults(func=run)
56
57
58 def run(args: argparse.Namespace) -> None:
59 """Show note density (notes per beat) per bar of a MIDI track.
60
61 ``muse density`` reveals the textural arc of a composition: which bars are
62 dense, which are sparse. Agents orchestrating multi-part arrangements use
63 this to avoid over-crowding any single section, and to verify that section
64 transitions (verse → chorus) are properly contrast-shaped.
65
66 Git cannot do this. Muse stores notes as structured data, so density is
67 computable at any historical snapshot with no manual inspection.
68 """
69 track: str = args.track
70 ref: str | None = args.ref
71 as_json: bool = args.as_json
72
73 root = require_repo()
74 commit_label = "working tree"
75
76 if ref is not None:
77 repo_id = read_repo_id(root)
78 branch = _read_branch(root)
79 commit = resolve_commit_ref(root, repo_id, branch, ref)
80 if commit is None:
81 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
82 raise SystemExit(ExitCode.USER_ERROR)
83 result = load_track(root, commit.commit_id, track)
84 commit_label = commit.commit_id[:8]
85 else:
86 result = load_track_from_workdir(root, track)
87
88 if result is None:
89 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
90 raise SystemExit(ExitCode.USER_ERROR)
91
92 notes, _tpb = result
93 if not notes:
94 print(f" (no notes found in '{track}')")
95 return
96
97 bars = analyze_density(notes)
98
99 if as_json:
100 print(json.dumps(
101 {"track": track, "commit": commit_label, "bars": list(bars)},
102 indent=2,
103 ))
104 return
105
106 if not bars:
107 print(" (no bars detected)")
108 return
109
110 peak_bar = max(bars, key=lambda b: b["notes_per_beat"])
111 avg_npb = sum(b["notes_per_beat"] for b in bars) / len(bars)
112
113 print(f"\nNote density: {track} — {commit_label}")
114 print(
115 f"Bars: {len(bars)} · "
116 f"Peak: bar {peak_bar['bar']} ({peak_bar['notes_per_beat']} notes/beat) · "
117 f"Avg: {avg_npb:.1f}"
118 )
119 print("")
120
121 max_npb = max(b["notes_per_beat"] for b in bars) or 1.0
122 for b in bars:
123 fill = int(b["notes_per_beat"] / max_npb * _BAR_WIDTH)
124 print(
125 f" bar {b['bar']:>3} {'█' * fill:<{_BAR_WIDTH}}"
126 f" {b['notes_per_beat']:>5.2f} notes/beat"
127 f" ({b['note_count']:>3} notes)"
128 )
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