cgcardona / muse public
voice_leading.py python
126 lines 4.3 KB
630bfa59 feat(midi): add 20 new semantic porcelain commands (#120) Gabriel Cardona <cgcardona@gmail.com> 14h 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 json
28 import logging
29 import pathlib
30
31 import typer
32
33 from muse.core.errors import ExitCode
34 from muse.core.repo import require_repo
35 from muse.core.store 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 app = typer.Typer()
41
42
43 def _read_repo_id(root: pathlib.Path) -> str:
44 import json as _json
45
46 return str(_json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
47
48
49 def _read_branch(root: pathlib.Path) -> str:
50 return (root / ".muse" / "HEAD").read_text().strip().removeprefix("refs/heads/").strip()
51
52
53 @app.callback(invoke_without_command=True)
54 def voice_leading(
55 ctx: typer.Context,
56 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
57 ref: str | None = typer.Option(
58 None, "--commit", "-c", metavar="REF",
59 help="Analyse a historical snapshot instead of the working tree.",
60 ),
61 strict: bool = typer.Option(
62 False, "--strict",
63 help="Exit with error code if any issues are found (for CI use).",
64 ),
65 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
66 ) -> None:
67 """Detect parallel fifths, octaves, and large leaps in a MIDI track.
68
69 ``muse voice-leading`` applies classical counterpoint rules to the
70 bar-by-bar note set. It flags parallel fifths/octaves between any pair
71 of voices and large melodic leaps (> a sixth) in the highest voice.
72
73 For CI integration, use ``--strict`` to fail the pipeline when issues
74 are present — preventing agents from committing harmonically problematic
75 voice leading without review.
76 """
77 root = require_repo()
78 commit_label = "working tree"
79
80 if ref is not None:
81 repo_id = _read_repo_id(root)
82 branch = _read_branch(root)
83 commit = resolve_commit_ref(root, repo_id, branch, ref)
84 if commit is None:
85 typer.echo(f"❌ Commit '{ref}' not found.", err=True)
86 raise typer.Exit(code=ExitCode.USER_ERROR)
87 result = load_track(root, commit.commit_id, track)
88 commit_label = commit.commit_id[:8]
89 else:
90 result = load_track_from_workdir(root, track)
91
92 if result is None:
93 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
94 raise typer.Exit(code=ExitCode.USER_ERROR)
95
96 notes, _tpb = result
97 if not notes:
98 typer.echo(f" (no notes found in '{track}')")
99 return
100
101 issues = check_voice_leading(notes)
102
103 if as_json:
104 typer.echo(json.dumps(
105 {"track": track, "commit": commit_label, "issues": list(issues)},
106 indent=2,
107 ))
108 if strict and issues:
109 raise typer.Exit(code=ExitCode.USER_ERROR)
110 return
111
112 typer.echo(f"\nVoice-leading check: {track} — {commit_label}")
113 if not issues:
114 typer.echo("✅ No voice-leading issues found.")
115 return
116
117 typer.echo(f"⚠️ {len(issues)} issue{'s' if len(issues) != 1 else ''} found\n")
118 typer.echo(f" {'Bar':>4} {'Type':<22} Description")
119 typer.echo(" " + "─" * 58)
120 for issue in issues:
121 typer.echo(
122 f" {issue['bar']:>4} {issue['issue_type']:<22} {issue['description']}"
123 )
124
125 if strict:
126 raise typer.Exit(code=ExitCode.USER_ERROR)