scale_detect.py python
112 lines 4.2 KB
Raw
sha256:0e3697f29ae6c62f39818a19861d4db6276465de16481fadf866eb93cbf4aae4 test: validate staging push and MP workflow for aaronrene Human 16 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 from __future__ import annotations
25
26 import argparse
27 import json
28 import logging
29 import pathlib
30 import sys
31
32 from muse.core.errors import ExitCode
33 from muse.core.repo import read_repo_id, require_repo
34 from muse.core.store import read_current_branch, 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
42
43 def _read_branch(root: pathlib.Path) -> str:
44 return read_current_branch(root)
45
46
47 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
48 """Register the scale subcommand."""
49 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)
50 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
51 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
52 parser.add_argument("--top", "-n", metavar="N", type=int, default=3, help="Number of top scale matches to show.")
53 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
54 parser.set_defaults(func=run)
55
56
57 def run(args: argparse.Namespace) -> None:
58 """Detect the scale or mode of a MIDI track by pitch-class analysis.
59
60 ``muse scale`` tests every root × scale combination and ranks them by the
61 fraction of note weight covered by that scale's pitch classes. Supports
62 major, minor, all church modes, pentatonic, blues, whole-tone, diminished,
63 and chromatic scales.
64
65 For agents: combine with ``muse harmony`` to get both the implied chord
66 progression and the underlying scale in one pipeline.
67 """
68 track: str = args.track
69 ref: str | None = args.ref
70 top: int = clamp_int(args.top, 1, 10000, 'top')
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 matches = detect_scale(notes)[: max(1, top)]
98
99 if as_json:
100 print(json.dumps(
101 {"track": track, "commit": commit_label, "matches": list(matches)},
102 indent=2,
103 ))
104 return
105
106 print(f"\nScale analysis: {track} — {commit_label}\n")
107 print(f" {'Rank':>4} {'Root':<5} {'Scale':<20} {'Confidence':>10} {'Out-of-scale':>12}")
108 print(" " + "─" * 57)
109 for i, m in enumerate(matches, 1):
110 print(
111 f" {i:>4} {m['root']:<5} {m['name']:<20} {m['confidence']:>10.3f} {m['out_of_scale_notes']:>12}"
112 )
File History 1 commit
sha256:0e3697f29ae6c62f39818a19861d4db6276465de16481fadf866eb93cbf4aae4 test: validate staging push and MP workflow for aaronrene Human 16 days ago