gabriel / muse public
scale_detect.py python
106 lines 4.2 KB
Raw
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50 docs: add symlog (#53) and reflog (#54) follow-up issue files Sonnet 4.6 20 days ago
1 """muse scale — detect the scale or mode of a MIDI track.
2
3 Goes far beyond key signature: detects pentatonic, blues, modal, whole-tone,
4 diminished, and chromatic scales by pitch-class frequency analysis. Essential
5 for agent pipelines that need to harmonically reason about or transform a track.
6
7 Usage::
8
9 muse scale tracks/melody.mid
10 muse scale tracks/lead.mid --commit HEAD~2 --top 3
11 muse scale tracks/bass.mid --json
12
13 Output::
14
15 Scale analysis: tracks/melody.mid — working tree
16
17 Rank Root Scale Confidence Out-of-scale
18 ───────────────────────────────────────────────────────
19 1 D dorian 0.964 0
20 2 A natural minor 0.946 1
21 3 G major 0.929 2
22 """
23
24 import argparse
25 import json
26 import logging
27 import pathlib
28 import sys
29
30 from muse.core.types import short_id
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 detect_scale
36 from muse.plugins.midi._query import load_track, load_track_from_workdir
37 from muse.core.validation import clamp_int
38
39 logger = logging.getLogger(__name__)
40
41 def _read_branch(root: pathlib.Path) -> str:
42 return read_current_branch(root)
43
44 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
45 """Register the scale subcommand."""
46 parser = subparsers.add_parser("scale", help="Detect the scale or mode of a MIDI track by pitch-class analysis.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
47 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
48 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
49 parser.add_argument("--top", "-n", metavar="N", type=int, default=3, help="Number of top scale matches to show.")
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 """Detect the scale or mode of a MIDI track by pitch-class analysis.
55
56 ``muse scale`` tests every root × scale combination and ranks them by the
57 fraction of note weight covered by that scale's pitch classes. Supports
58 major, minor, all church modes, pentatonic, blues, whole-tone, diminished,
59 and chromatic scales.
60
61 For agents: combine with ``muse harmony`` to get both the implied chord
62 progression and the underlying scale in one pipeline.
63 """
64 track: str = args.track
65 ref: str | None = args.ref
66 top: int = clamp_int(args.top, 1, 10000, 'top')
67 as_json: bool = args.as_json
68
69 root = require_repo()
70 commit_label = "working tree"
71
72 if ref is not None:
73 branch = _read_branch(root)
74 commit = resolve_commit_ref(root, branch, ref)
75 if commit is None:
76 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
77 raise SystemExit(ExitCode.USER_ERROR)
78 result = load_track(root, commit.commit_id, track)
79 commit_label = commit.commit_id
80 else:
81 result = load_track_from_workdir(root, track)
82
83 if result is None:
84 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
85 raise SystemExit(ExitCode.USER_ERROR)
86
87 notes, _tpb = result
88 if not notes:
89 print(f" (no notes found in '{track}')")
90 return
91
92 matches = detect_scale(notes)[: max(1, top)]
93
94 if as_json:
95 print(json.dumps(
96 {"track": track, "commit": commit_label, "matches": list(matches)},
97 ))
98 return
99
100 print(f"\nScale analysis: {track} — {commit_label}\n")
101 print(f" {'Rank':>4} {'Root':<5} {'Scale':<20} {'Confidence':>10} {'Out-of-scale':>12}")
102 print(f" {'─' * 57}")
103 for i, m in enumerate(matches, 1):
104 print(
105 f" {i:>4} {m['root']:<5} {m['name']:<20} {m['confidence']:>10.3f} {m['out_of_scale_notes']:>12}"
106 )
File History 1 commit
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50 docs: add symlog (#53) and reflog (#54) follow-up issue files Sonnet 4.6 20 days ago