gabriel / muse public
find_phrase.py python
165 lines 5.6 KB
630bfa59 feat(midi): add 20 new semantic porcelain commands (#120) Gabriel Cardona <cgcardona@gmail.com> 4d 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 from __future__ import annotations
27
28 import json
29 import logging
30 import pathlib
31 from typing import TypedDict
32
33 import typer
34
35 from muse.core.errors import ExitCode
36 from muse.core.repo import require_repo
37 from muse.core.store import resolve_commit_ref
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 app = typer.Typer()
48
49
50 class PhraseMatch(TypedDict):
51 """A commit that contains the searched phrase."""
52
53 score: float
54 commit_id: str
55 author: str
56 message: str
57
58
59 def _read_repo_id(root: pathlib.Path) -> str:
60 import json as _json
61
62 return str(_json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
63
64
65 def _read_branch(root: pathlib.Path) -> str:
66 return (root / ".muse" / "HEAD").read_text().strip().removeprefix("refs/heads/").strip()
67
68
69 @app.callback(invoke_without_command=True)
70 def find_phrase(
71 ctx: typer.Context,
72 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to the .mid file to search in."),
73 query: str = typer.Option(
74 ..., "--query", "-q", metavar="QUERY_MIDI",
75 help="Path to a short .mid file containing the phrase to search for.",
76 ),
77 ref: str | None = typer.Option(
78 None, "--commit", "-c", metavar="REF",
79 help="Start the history walk from this commit (default: HEAD).",
80 ),
81 depth: int = typer.Option(
82 50, "--depth", "-d", metavar="N",
83 help="Maximum commits to scan (default 50).",
84 ),
85 min_score: float = typer.Option(
86 0.5, "--min-score", "-s", metavar="S",
87 help="Minimum similarity score to report (default 0.5).",
88 ),
89 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
90 ) -> None:
91 """Search for a melodic phrase across MIDI commit history.
92
93 ``muse find-phrase`` computes pitch-class histogram and interval-fingerprint
94 similarity between a query MIDI file and every historical snapshot of a
95 track. Use it to answer: "At which commit did this motif first appear?"
96 or "Which branches contain this theme?"
97
98 For agents: pipe the output (``--json``) into a decision loop to select the
99 commit with the highest match score as the merge base for a cherry-pick.
100 """
101 root = require_repo()
102
103 # Load query phrase
104 query_result = load_track_from_workdir(root, query)
105 if query_result is None:
106 typer.echo(f"❌ Query file '{query}' not found or not a valid MIDI file.", err=True)
107 raise typer.Exit(code=ExitCode.USER_ERROR)
108 query_notes, _qtpb = query_result
109
110 if not query_notes:
111 typer.echo(f" (query file '{query}' contains no notes — cannot search)")
112 return
113
114 repo_id = _read_repo_id(root)
115 branch = _read_branch(root)
116 start_ref = ref or "HEAD"
117 start_commit = resolve_commit_ref(root, repo_id, branch, start_ref)
118 if start_commit is None:
119 typer.echo(f"❌ Commit '{start_ref}' not found.", err=True)
120 raise typer.Exit(code=ExitCode.USER_ERROR)
121
122 history = walk_commits_for_track(root, start_commit.commit_id, track, max_commits=depth)
123
124 if not as_json:
125 typer.echo(f"\nPhrase search: {track} (query: {query})")
126 typer.echo(f"Scanning {len(history)} commits…\n")
127
128 matches: list[PhraseMatch] = []
129 for commit, manifest in history:
130 if manifest is None or track not in manifest:
131 continue
132 result = load_track(root, commit.commit_id, track)
133 if result is None:
134 continue
135 candidate_notes: list[NoteInfo] = result[0]
136 if not candidate_notes:
137 continue
138 score = phrase_similarity(query_notes, candidate_notes)
139 if score >= min_score:
140 matches.append(PhraseMatch(
141 score=score,
142 commit_id=commit.commit_id[:8],
143 author=commit.author or "unknown",
144 message=(commit.message or "").splitlines()[0][:60],
145 ))
146
147 matches.sort(key=lambda m: -m["score"])
148
149 if as_json:
150 typer.echo(json.dumps(
151 {"track": track, "query": query, "matches": list(matches)},
152 indent=2,
153 ))
154 return
155
156 if not matches:
157 typer.echo(f" (no commits with score ≥ {min_score} found)")
158 return
159
160 typer.echo(f" {'Score':>7} {'Commit':<10} {'Author':<28} Message")
161 typer.echo(" " + "─" * 74)
162 for m in matches:
163 typer.echo(
164 f" {m['score']:>7.3f} {m['commit_id']:<10} {m['author']:<28} {m['message']}"
165 )