gabriel / muse public
find_phrase.py python
156 lines 6.3 KB
Raw
sha256:e6465e8a9b7fa8e6223ed4a3576e96c568c913ae2caeb9c31f15e7a81b250b40 docs: add | jq convention to --json section of agent-guide Sonnet 4.6 1 day ago
1 """muse find-phrase — search for a melodic phrase across commit history.
2
3 Scans every commit that contains a MIDI track and computes a similarity score
4 between the query phrase (a short .mid file or a bar range of the track) and
5 each historical snapshot. Returns the commits where the phrase appears most
6 strongly.
7
8 Usage::
9
10 muse find-phrase tracks/melody.mid --query query/motif.mid
11 muse find-phrase tracks/melody.mid --query query/motif.mid --min-score 0.7
12 muse find-phrase tracks/melody.mid --query query/motif.mid --depth 100 --json
13
14 Output::
15
16 Phrase search: tracks/melody.mid (query: query/motif.mid)
17 Scanning 24 commits…
18
19 Score Commit Author Message
20 ──────────────────────────────────────────────────────────────────
21 0.934 cb4afaed agent-melody-composer feat: add intro melody
22 0.871 9f3a12e7 agent-harmoniser feat: harmonise verse
23 0.612 1b2c3d4e agent-arranger refactor: restructure bridge
24 """
25
26 import argparse
27 import json
28 import logging
29 import pathlib
30 import sys
31 from typing import TypedDict
32
33 from muse.core.errors import ExitCode
34 from muse.core.repo import require_repo
35 from muse.core.refs import read_current_branch
36 from muse.core.commits import resolve_commit_ref
37 from muse.core.validation import clamp_int, sanitize_display
38 from muse.plugins.midi._analysis import phrase_similarity
39 from muse.plugins.midi._query import (
40 NoteInfo,
41 load_track,
42 load_track_from_workdir,
43 walk_commits_for_track,
44 )
45
46 logger = logging.getLogger(__name__)
47
48 class PhraseMatch(TypedDict):
49 """A commit that contains the searched phrase."""
50
51 score: float
52 commit_id: str
53 author: str
54 message: str
55
56 def _read_branch(root: pathlib.Path) -> str:
57 return read_current_branch(root)
58
59 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
60 """Register the find-phrase subcommand."""
61 parser = subparsers.add_parser("find-phrase", help="Search for a melodic phrase across MIDI commit history.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
62 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to the .mid file to search in.")
63 parser.add_argument("--query", "-q", metavar="QUERY_MIDI", required=True, help="Path to a short .mid file containing the phrase to search for.")
64 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Start the history walk from this commit (default: HEAD).")
65 parser.add_argument("--depth", "-d", metavar="N", type=int, default=50, help="Maximum commits to scan (default 50).")
66 parser.add_argument("--min-score", "-s", metavar="S", type=float, default=0.5, dest="min_score", help="Minimum similarity score to report (default 0.5).")
67 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
68 parser.set_defaults(func=run)
69
70 def run(args: argparse.Namespace) -> None:
71 """Search for a melodic phrase across MIDI commit history.
72
73 ``muse find-phrase`` computes pitch-class histogram and interval-fingerprint
74 similarity between a query MIDI file and every historical snapshot of a
75 track. Use it to answer: "At which commit did this motif first appear?"
76 or "Which branches contain this theme?"
77
78 For agents: pipe the output (``--json``) into a decision loop to select the
79 commit with the highest match score as the merge base for a cherry-pick.
80 """
81 track: str = args.track
82 query: str = args.query
83 ref: str | None = args.ref
84 depth: int = clamp_int(args.depth, 1, 50, 'depth')
85 min_score: float = args.min_score
86 as_json: bool = args.as_json
87
88 if depth < 1 or depth > 10_000:
89 print(f"❌ --depth must be between 1 and 10,000 (got {depth}).", file=sys.stderr)
90 raise SystemExit(ExitCode.USER_ERROR)
91 if not 0.0 <= min_score <= 1.0:
92 print(f"❌ --min-score must be between 0.0 and 1.0 (got {min_score}).", file=sys.stderr)
93 raise SystemExit(ExitCode.USER_ERROR)
94 root = require_repo()
95
96 # Load query phrase
97 query_result = load_track_from_workdir(root, query)
98 if query_result is None:
99 print(f"❌ Query file '{query}' not found or not a valid MIDI file.", file=sys.stderr)
100 raise SystemExit(ExitCode.USER_ERROR)
101 query_notes, _qtpb = query_result
102
103 if not query_notes:
104 print(f" (query file '{query}' contains no notes — cannot search)")
105 return
106
107 branch = _read_branch(root)
108 start_ref = ref or "HEAD"
109 start_commit = resolve_commit_ref(root, branch, start_ref)
110 if start_commit is None:
111 print(f"❌ Commit '{start_ref}' not found.", file=sys.stderr)
112 raise SystemExit(ExitCode.USER_ERROR)
113
114 history = walk_commits_for_track(root, start_commit.commit_id, track, max_commits=depth)
115
116 if not as_json:
117 print(f"\nPhrase search: {track} (query: {query})")
118 print(f"Scanning {len(history)} commits…\n")
119
120 matches: list[PhraseMatch] = []
121 for commit, manifest in history:
122 if manifest is None or track not in manifest:
123 continue
124 result = load_track(root, commit.commit_id, track)
125 if result is None:
126 continue
127 candidate_notes: list[NoteInfo] = result[0]
128 if not candidate_notes:
129 continue
130 score = phrase_similarity(query_notes, candidate_notes)
131 if score >= min_score:
132 matches.append(PhraseMatch(
133 score=score,
134 commit_id=commit.commit_id,
135 author=sanitize_display(commit.author or "unknown"),
136 message=sanitize_display((commit.message or "").splitlines()[0][:60]),
137 ))
138
139 matches.sort(key=lambda m: -m["score"])
140
141 if as_json:
142 print(json.dumps(
143 {"track": track, "query": query, "matches": list(matches)},
144 ))
145 return
146
147 if not matches:
148 print(f" (no commits with score ≥ {min_score} found)")
149 return
150
151 print(f" {'Score':>7} {'Commit':<10} {'Author':<28} Message")
152 print(f" {'─' * 74}")
153 for m in matches:
154 print(
155 f" {m['score']:>7.3f} {m['commit_id']:<10} {m['author']:<28} {m['message']}"
156 )
File History 1 commit
sha256:e6465e8a9b7fa8e6223ed4a3576e96c568c913ae2caeb9c31f15e7a81b250b40 docs: add | jq convention to --json section of agent-guide Sonnet 4.6 1 day ago