motif_detect.py python
127 lines 4.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 34 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 from __future__ import annotations
26
27 import argparse
28 import json
29 import logging
30 import pathlib
31 import sys
32
33 from muse.core.errors import ExitCode
34 from muse.core.repo import read_repo_id, require_repo
35 from muse.core.store import read_current_branch, resolve_commit_ref
36 from muse.plugins.midi._analysis import find_motifs
37 from muse.plugins.midi._query import load_track, load_track_from_workdir
38 from muse.core.validation import clamp_int
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 motif subcommand."""
50 parser = subparsers.add_parser("motif", help="Find recurring melodic patterns (motifs) in 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("--min-length", "-l", metavar="N", type=int, default=3, help="Minimum motif length in notes.")
54 parser.add_argument("--min-occurrences", "-o", metavar="N", type=int, default=2, dest="min_occ", help="Minimum number of recurrences.")
55 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
56 parser.set_defaults(func=run)
57
58
59 def run(args: argparse.Namespace) -> None:
60 """Find recurring melodic patterns (motifs) in a MIDI track.
61
62 ``muse motif`` scans the interval sequence between consecutive notes and
63 finds the most frequently recurring sub-sequences. It ignores transposition
64 — only the interval pattern (the shape) matters, not the starting pitch.
65
66 For agents:
67 - Use ``--min-length 4`` for tighter, more distinctive motifs.
68 - Use ``--commit`` to check whether a motif introduced in a previous commit
69 is still present after a merge.
70 - Combine with ``muse note-log`` to track where a motif first appeared.
71 """
72 track: str = args.track
73 ref: str | None = args.ref
74 min_length: int = clamp_int(args.min_length, 1, 1000, 'min_length')
75 min_occ: int = clamp_int(args.min_occ, 1, 100000, 'min_occ')
76 as_json: bool = args.as_json
77
78 root = require_repo()
79 commit_label = "working tree"
80
81 if ref is not None:
82 repo_id = read_repo_id(root)
83 branch = _read_branch(root)
84 commit = resolve_commit_ref(root, repo_id, branch, ref)
85 if commit is None:
86 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
87 raise SystemExit(ExitCode.USER_ERROR)
88 result = load_track(root, commit.commit_id, track)
89 commit_label = commit.commit_id[:8]
90 else:
91 result = load_track_from_workdir(root, track)
92
93 if result is None:
94 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
95 raise SystemExit(ExitCode.USER_ERROR)
96
97 notes, _tpb = result
98 if not notes:
99 print(f" (no notes found in '{track}')")
100 return
101
102 motifs = find_motifs(notes, min_length=min_length, min_occurrences=min_occ)
103
104 if as_json:
105 print(json.dumps(
106 {"track": track, "commit": commit_label, "motifs": list(motifs)},
107 indent=2,
108 ))
109 return
110
111 print(f"\nMotif analysis: {track} — {commit_label}")
112 if not motifs:
113 print(
114 f" (no motifs found with length ≥ {min_length} and occurrences ≥ {min_occ})"
115 )
116 return
117
118 print(f"Found {len(motifs)} motif{'s' if len(motifs) != 1 else ''}\n")
119 for m in motifs:
120 intervals_str = " ".join(f"{iv:+d}" for iv in m["interval_pattern"])
121 bars_str = ", ".join(str(b) for b in m["bars"])
122 print(
123 f" Motif {m['id']} [{intervals_str}]"
124 f" {m['occurrences']}×"
125 f" first: {m['first_pitch']}"
126 f" bars: {bars_str}"
127 )
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 34 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 34 days ago