gabriel / muse public
motif_detect.py python
120 lines 4.6 KB
Raw
sha256:502e812e12319e30302d07d165425ceeb21687ad30e8af3a439b1f6de36c64d6 fix: update stale tag tests to use label for free-form anno… Sonnet 4.6 19 days ago
1 """muse motif — recurring melodic pattern detection for a MIDI track.
2
3 Finds repeated interval sequences (motifs) in a melodic line. In a swarm
4 of agents each writing a section, motif detection ensures that a unifying
5 melodic idea recurs coherently — or surfaces when it has been accidentally
6 dropped.
7
8 Usage::
9
10 muse motif tracks/melody.mid
11 muse motif tracks/lead.mid --min-length 4 --min-occurrences 3
12 muse motif tracks/violin.mid --commit HEAD~2
13 muse motif tracks/piano.mid --json
14
15 Output::
16
17 Motif analysis: tracks/melody.mid — working tree
18 Found 3 motifs
19
20 Motif 0 [+2 +2 -3] 3× first: D4 bars: 1, 5, 13
21 Motif 1 [+4 -2 -2 +1] 2× first: G3 bars: 3, 11
22 Motif 2 [-1 -1 +3] 2× first: A4 bars: 7, 15
23 """
24
25 import argparse
26 import json
27 import logging
28 import pathlib
29 import sys
30
31 from muse.core.errors import ExitCode
32 from muse.core.repo import require_repo
33 from muse.core.refs import read_current_branch
34 from muse.core.commits import resolve_commit_ref
35 from muse.plugins.midi._analysis import find_motifs
36 from muse.plugins.midi._query import load_track, load_track_from_workdir
37 from muse.core.validation import clamp_int
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 motif subcommand."""
46 parser = subparsers.add_parser("motif", help="Find recurring melodic patterns (motifs) 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("--min-length", "-l", metavar="N", type=int, default=3, help="Minimum motif length in notes.")
50 parser.add_argument("--min-occurrences", "-o", metavar="N", type=int, default=2, dest="min_occ", help="Minimum number of recurrences.")
51 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
52 parser.set_defaults(func=run)
53
54 def run(args: argparse.Namespace) -> None:
55 """Find recurring melodic patterns (motifs) in a MIDI track.
56
57 ``muse motif`` scans the interval sequence between consecutive notes and
58 finds the most frequently recurring sub-sequences. It ignores transposition
59 — only the interval pattern (the shape) matters, not the starting pitch.
60
61 For agents:
62 - Use ``--min-length 4`` for tighter, more distinctive motifs.
63 - Use ``--commit`` to check whether a motif introduced in a previous commit
64 is still present after a merge.
65 - Combine with ``muse note-log`` to track where a motif first appeared.
66 """
67 track: str = args.track
68 ref: str | None = args.ref
69 min_length: int = clamp_int(args.min_length, 1, 1000, 'min_length')
70 min_occ: int = clamp_int(args.min_occ, 1, 100000, 'min_occ')
71 as_json: bool = args.as_json
72
73 root = require_repo()
74 commit_label = "working tree"
75
76 if ref is not None:
77 branch = _read_branch(root)
78 commit = resolve_commit_ref(root, 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
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 motifs = find_motifs(notes, min_length=min_length, min_occurrences=min_occ)
97
98 if as_json:
99 print(json.dumps(
100 {"track": track, "commit": commit_label, "motifs": list(motifs)},
101 ))
102 return
103
104 print(f"\nMotif analysis: {track} — {commit_label}")
105 if not motifs:
106 print(
107 f" (no motifs found with length ≥ {min_length} and occurrences ≥ {min_occ})"
108 )
109 return
110
111 print(f"Found {len(motifs)} motif{'s' if len(motifs) != 1 else ''}\n")
112 for m in motifs:
113 intervals_str = " ".join(f"{iv:+d}" for iv in m["interval_pattern"])
114 bars_str = ", ".join(str(b) for b in m["bars"])
115 print(
116 f" Motif {m['id']} [{intervals_str}]"
117 f" {m['occurrences']}×"
118 f" first: {m['first_pitch']}"
119 f" bars: {bars_str}"
120 )
File History 1 commit
sha256:502e812e12319e30302d07d165425ceeb21687ad30e8af3a439b1f6de36c64d6 fix: update stale tag tests to use label for free-form anno… Sonnet 4.6 19 days ago