voice_leading.py python
122 lines 4.5 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """muse voice-leading — check for voice-leading violations in a MIDI track.
2
3 Detects parallel fifths, parallel octaves, and large leaps in the top voice —
4 the classic rules of contrapuntal writing. Agents that auto-harmonise or
5 fill in inner voices can use this as an automated lint step before committing.
6
7 Usage::
8
9 muse voice-leading tracks/chords.mid
10 muse voice-leading tracks/strings.mid --commit HEAD~1
11 muse voice-leading tracks/piano.mid --json
12
13 Output::
14
15 Voice-leading check: tracks/chords.mid — working tree
16 ⚠️ 3 issues found
17
18 Bar Type Description
19 ──────────────────────────────────────────────────────
20 5 parallel_fifths voices 0–1: parallel perfect fifths
21 9 large_leap top voice: leap of 10 semitones
22 13 parallel_octaves voices 1–2: parallel octaves
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 check_voice_leading
37 from muse.plugins.midi._query import load_track, load_track_from_workdir
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 voice-leading subcommand."""
49 parser = subparsers.add_parser("voice-leading", help="Detect parallel fifths, octaves, and large leaps in a MIDI track.", 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("--strict", action="store_true", help="Exit with error code if any issues are found (for CI use).")
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 parallel fifths, octaves, and large leaps in a MIDI track.
59
60 ``muse voice-leading`` applies classical counterpoint rules to the
61 bar-by-bar note set. It flags parallel fifths/octaves between any pair
62 of voices and large melodic leaps (> a sixth) in the highest voice.
63
64 For CI integration, use ``--strict`` to fail the pipeline when issues
65 are present — preventing agents from committing harmonically problematic
66 voice leading without review.
67 """
68 track: str = args.track
69 ref: str | None = args.ref
70 strict: bool = args.strict
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 issues = check_voice_leading(notes)
98
99 if as_json:
100 print(json.dumps(
101 {"track": track, "commit": commit_label, "issues": list(issues)},
102 indent=2,
103 ))
104 if strict and issues:
105 raise SystemExit(ExitCode.USER_ERROR)
106 return
107
108 print(f"\nVoice-leading check: {track} — {commit_label}")
109 if not issues:
110 print("✅ No voice-leading issues found.")
111 return
112
113 print(f"⚠️ {len(issues)} issue{'s' if len(issues) != 1 else ''} found\n")
114 print(f" {'Bar':>4} {'Type':<22} Description")
115 print(" " + "─" * 58)
116 for issue in issues:
117 print(
118 f" {issue['bar']:>4} {issue['issue_type']:<22} {issue['description']}"
119 )
120
121 if strict:
122 raise SystemExit(ExitCode.USER_ERROR)
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago