gabriel / muse public
contour.py python
109 lines 4.0 KB
Raw
1 """muse contour — melodic contour analysis for a MIDI track.
2
3 Classifies the overall melodic shape (arch, ascending, wave, …), computes
4 the pitch range, counts direction changes, and shows the full interval
5 sequence. Agents use contour to compare melodic variation across branches
6 without listening to audio.
7
8 Usage::
9
10 muse contour tracks/melody.mid
11 muse contour tracks/lead.mid --commit HEAD~1
12 muse contour tracks/violin.mid --json
13
14 Output::
15
16 Melodic contour: tracks/melody.mid — working tree
17 Shape: arch
18 Pitch range: E3 – C6 (32 semitones)
19 Direction changes: 7
20 Avg interval size: 2.14 semitones
21
22 Interval sequence (semitones):
23 +2 +2 +3 +2 -1 -2 -2 +4 -3 -2 -1 +1 ...
24 """
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 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 analyze_contour
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 contour subcommand."""
46 parser = subparsers.add_parser("contour", help="Analyse the melodic contour (shape) of 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("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
50 parser.set_defaults(func=run)
51
52 def run(args: argparse.Namespace) -> None:
53 """Analyse the melodic contour (shape) of a MIDI track.
54
55 ``muse contour`` classifies the overall pitch trajectory — ascending,
56 descending, arch, valley, wave, or flat — and reports pitch range,
57 interval sequence, and directional complexity.
58
59 For agents: contour is a fast structural fingerprint. Use it to detect
60 when a branch has inadvertently flattened or inverted a melody, or to
61 verify that a transposition preserved the intended shape.
62 """
63 track: str = args.track
64 ref: str | None = args.ref
65 as_json: bool = args.as_json
66
67 root = require_repo()
68 commit_label = "working tree"
69
70 if ref is not None:
71 branch = _read_branch(root)
72 commit = resolve_commit_ref(root, branch, ref)
73 if commit is None:
74 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
75 raise SystemExit(ExitCode.USER_ERROR)
76 result = load_track(root, commit.commit_id, track)
77 commit_label = commit.commit_id
78 else:
79 result = load_track_from_workdir(root, track)
80
81 if result is None:
82 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
83 raise SystemExit(ExitCode.USER_ERROR)
84
85 notes, _tpb = result
86 if not notes:
87 print(f" (no notes found in '{track}')")
88 return
89
90 analysis = analyze_contour(notes)
91
92 if as_json:
93 print(json.dumps({"track": track, "commit": commit_label, **analysis}))
94 return
95
96 print(f"\nMelodic contour: {track} — {commit_label}")
97 print(f"Shape: {analysis['shape']}")
98 print(
99 f"Pitch range: {analysis['lowest_pitch']} – {analysis['highest_pitch']}"
100 f" ({analysis['range_semitones']} semitones)"
101 )
102 print(f"Direction changes: {analysis['direction_changes']}")
103 print(f"Avg interval size: {analysis['avg_interval_size']} semitones")
104
105 intervals = analysis["intervals"]
106 if intervals:
107 print("\nInterval sequence (semitones):")
108 parts = [f"{iv:+d}" for iv in intervals]
109 print(f" {' '.join(parts[:32])}{' …' if len(intervals) > 32 else ''}")
File History 1 commit