voice_leading.py
python
| 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) |