scale_detect.py file-level

at task/mp- · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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 )