gabriel / muse public
midi_query.py python
146 lines 5.1 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 12 days ago
1 """``muse midi-query`` — MIDI DSL query over commit history.
2
3 Evaluates a predicate expression against the note content of all MIDI tracks
4 across the commit history and returns matching bars with chord annotations,
5 agent provenance, and note tables.
6
7 Usage::
8
9 muse midi-query "note.pitch_class == 'Eb' and bar == 12"
10 muse midi-query "note.velocity > 100" --track piano.mid
11 muse midi-query "agent_id == 'counterpoint-bot'" --from HEAD~10
12 muse midi-query "harmony.quality == 'dim'" --json
13
14 Grammar::
15
16 query = or_expr
17 or_expr = and_expr ( 'or' and_expr )*
18 and_expr = not_expr ( 'and' not_expr )*
19 not_expr = 'not' not_expr | atom
20 atom = '(' query ')' | FIELD OP VALUE
21 FIELD = note.pitch | note.pitch_class | note.velocity |
22 note.channel | note.duration | bar | track |
23 harmony.chord | harmony.quality |
24 author | agent_id | model_id | toolchain_id
25 OP = == | != | > | < | >= | <=
26
27 See ``muse/plugins/midi/_midi_query.py`` for the full grammar reference.
28 """
29
30 import argparse
31 import json
32 import logging
33 import pathlib
34 import sys
35
36 from muse.core.repo import require_repo
37 from muse.core.refs import (
38 get_head_commit_id,
39 read_current_branch,
40 )
41 from muse.core.commits import read_commit
42 from muse.plugins.midi._midi_query import run_query
43 from muse.core.validation import clamp_int
44
45 logger = logging.getLogger(__name__)
46
47 def _read_branch(root: pathlib.Path) -> str:
48 return read_current_branch(root)
49
50 def _resolve_head(root: pathlib.Path, alias: str | None = None) -> str | None:
51 """Resolve ``None``, ``HEAD``, or ``HEAD~N`` to a concrete commit ID."""
52 branch = _read_branch(root)
53 commit_id = get_head_commit_id(root, branch)
54 if commit_id is None:
55 return None
56 if alias is None or alias == "HEAD":
57 return commit_id
58
59 # Handle HEAD~N.
60 parts = alias.split("~")
61 if len(parts) != 2:
62 return alias
63 try:
64 steps = int(parts[1])
65 except ValueError:
66 return alias
67
68 current: str | None = commit_id
69 for _ in range(steps):
70 if current is None:
71 break
72 commit = read_commit(root, current)
73 if commit is None:
74 break
75 current = commit.parent_commit_id
76
77 return current or alias
78
79 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
80 """Register the midi-query subcommand."""
81 parser = subparsers.add_parser("query", help="Query the MIDI note history using a MIDI DSL predicate.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
82 parser.add_argument("query_expr", metavar="QUERY", help=(
83 "Music query DSL expression. Examples: "
84 "\"note.pitch_class == 'Eb'\", "
85 "\"harmony.quality == 'dim' and bar == 8\", "
86 "\"agent_id == 'my-bot' and note.velocity > 80\""
87 ))
88 parser.add_argument("--track", "-t", metavar="PATH", default=None, help="Restrict search to a single MIDI file path.")
89 parser.add_argument("--from", "-f", metavar="COMMIT", default=None, dest="start", help="Start commit (default: HEAD).")
90 parser.add_argument("--to", metavar="COMMIT", default=None, dest="stop", help="Stop before this commit (exclusive).")
91 parser.add_argument("--max-results", "-n", metavar="N", type=int, default=100, help="Maximum number of matches to return.")
92 parser.add_argument("--json", action="store_true", dest="as_json", help="Output machine-readable JSON instead of formatted text.")
93 parser.set_defaults(func=run)
94
95 def run(args: argparse.Namespace) -> None:
96 """Query the MIDI note history using a MIDI DSL predicate."""
97 query_expr: str = args.query_expr
98 track: str | None = args.track
99 start: str | None = args.start
100 stop: str | None = args.stop
101 max_results: int = clamp_int(args.max_results, 1, 10000, 'max_results')
102 as_json: bool = args.as_json
103
104 root = require_repo()
105
106 start_id = _resolve_head(root, start)
107 if start_id is None:
108 print("❌ No commits in this repository.", file=sys.stderr)
109 raise SystemExit(1)
110
111 try:
112 matches = run_query(
113 query_expr,
114 root,
115 start_id,
116 track_filter=track,
117 from_commit_id=stop,
118 max_results=max_results,
119 )
120 except ValueError as exc:
121 print(f"❌ Query parse error: {exc}", file=sys.stderr)
122 raise SystemExit(1)
123
124 if not matches:
125 print("No matches found.")
126 return
127
128 if as_json:
129 sys.stdout.write(f"{json.dumps(matches)}\n")
130 return
131
132 for m in matches:
133 print(
134 f"commit {m['commit_short']} {m['committed_at'][:19]} "
135 f"author={m['author']} agent={m['agent_id'] or '—'}"
136 )
137 print(f" track={m['track']} bar={m['bar']} chord={m['chord'] or '—'}")
138 for n in m["notes"]:
139 print(
140 f" {n['pitch_class']:3} (MIDI {n['pitch']:3}) "
141 f"vel={n['velocity']:3} ch={n['channel']} "
142 f"beat={n['beat']:.2f} dur={n['duration_beats']:.2f}"
143 )
144 print("")
145
146 print(f"— {len(matches)} match{'es' if len(matches) != 1 else ''} —")
File History 10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 11 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 14 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 23 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 33 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 35 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 49 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 56 days ago