gabriel / muse public
contour.py python
115 lines 4.1 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
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 from __future__ import annotations
27
28 import argparse
29 import json
30 import logging
31 import pathlib
32 import sys
33
34 from muse.core.errors import ExitCode
35 from muse.core.repo import read_repo_id, require_repo
36 from muse.core.store import read_current_branch, resolve_commit_ref
37 from muse.plugins.midi._analysis import analyze_contour
38 from muse.plugins.midi._query import load_track, load_track_from_workdir
39
40 logger = logging.getLogger(__name__)
41
42
43
44 def _read_branch(root: pathlib.Path) -> str:
45 return read_current_branch(root)
46
47
48 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
49 """Register the contour subcommand."""
50 parser = subparsers.add_parser("contour", help="Analyse the melodic contour (shape) of a MIDI track.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
51 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
52 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
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 """Analyse the melodic contour (shape) of a MIDI track.
59
60 ``muse contour`` classifies the overall pitch trajectory — ascending,
61 descending, arch, valley, wave, or flat — and reports pitch range,
62 interval sequence, and directional complexity.
63
64 For agents: contour is a fast structural fingerprint. Use it to detect
65 when a branch has inadvertently flattened or inverted a melody, or to
66 verify that a transposition preserved the intended shape.
67 """
68 track: str = args.track
69 ref: str | None = args.ref
70 as_json: bool = args.as_json
71
72 root = require_repo()
73 commit_label = "working tree"
74
75 if ref is not None:
76 repo_id = read_repo_id(root)
77 branch = _read_branch(root)
78 commit = resolve_commit_ref(root, repo_id, branch, ref)
79 if commit is None:
80 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
81 raise SystemExit(ExitCode.USER_ERROR)
82 result = load_track(root, commit.commit_id, track)
83 commit_label = commit.commit_id[:8]
84 else:
85 result = load_track_from_workdir(root, track)
86
87 if result is None:
88 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
89 raise SystemExit(ExitCode.USER_ERROR)
90
91 notes, _tpb = result
92 if not notes:
93 print(f" (no notes found in '{track}')")
94 return
95
96 analysis = analyze_contour(notes)
97
98 if as_json:
99 print(json.dumps({"track": track, "commit": commit_label, **analysis}, indent=2))
100 return
101
102 print(f"\nMelodic contour: {track} — {commit_label}")
103 print(f"Shape: {analysis['shape']}")
104 print(
105 f"Pitch range: {analysis['lowest_pitch']} – {analysis['highest_pitch']}"
106 f" ({analysis['range_semitones']} semitones)"
107 )
108 print(f"Direction changes: {analysis['direction_changes']}")
109 print(f"Avg interval size: {analysis['avg_interval_size']} semitones")
110
111 intervals = analysis["intervals"]
112 if intervals:
113 print("\nInterval sequence (semitones):")
114 parts = [f"{iv:+d}" for iv in intervals]
115 print(" " + " ".join(parts[:32]) + (" …" if len(intervals) > 32 else ""))
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago