midi_query.py
python
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