gabriel / muse public
voice_leading.py python
116 lines 4.5 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 12 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 import argparse
26 import json
27 import logging
28 import pathlib
29 import sys
30
31 from muse.core.types import short_id
32 from muse.core.errors import ExitCode
33 from muse.core.repo import require_repo
34 from muse.core.refs import read_current_branch
35 from muse.core.commits import 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 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 voice-leading subcommand."""
46 parser = subparsers.add_parser("voice-leading", help="Detect parallel fifths, octaves, and large leaps in a MIDI track.", 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("--strict", action="store_true", help="Exit with error code if any issues are found (for CI use).")
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 parallel fifths, octaves, and large leaps in a MIDI track.
55
56 ``muse voice-leading`` applies classical counterpoint rules to the
57 bar-by-bar note set. It flags parallel fifths/octaves between any pair
58 of voices and large melodic leaps (> a sixth) in the highest voice.
59
60 For CI integration, use ``--strict`` to fail the pipeline when issues
61 are present — preventing agents from committing harmonically problematic
62 voice leading without review.
63 """
64 track: str = args.track
65 ref: str | None = args.ref
66 strict: bool = args.strict
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 = short_id(commit.commit_id, strip=True)
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 issues = check_voice_leading(notes)
93
94 if as_json:
95 print(json.dumps(
96 {"track": track, "commit": commit_label, "issues": list(issues)},
97 ))
98 if strict and issues:
99 raise SystemExit(ExitCode.USER_ERROR)
100 return
101
102 print(f"\nVoice-leading check: {track} — {commit_label}")
103 if not issues:
104 print("✅ No voice-leading issues found.")
105 return
106
107 print(f"⚠️ {len(issues)} issue{'s' if len(issues) != 1 else ''} found\n")
108 print(f" {'Bar':>4} {'Type':<22} Description")
109 print(f" {'─' * 58}")
110 for issue in issues:
111 print(
112 f" {issue['bar']:>4} {issue['issue_type']:<22} {issue['description']}"
113 )
114
115 if strict:
116 raise SystemExit(ExitCode.USER_ERROR)
File History 10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 11 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 14 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 23 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 33 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 35 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 49 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 56 days ago