gabriel / muse public
motif_detect.py python
126 lines 4.3 KB
f7645c07 feat(store): self-describing HEAD format with typed read/write API (#163) Gabriel Cardona <cgcardona@gmail.com> 3d 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 json
28 import logging
29 import pathlib
30
31 import typer
32
33 from muse.core.errors import ExitCode
34 from muse.core.repo import 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
39 logger = logging.getLogger(__name__)
40 app = typer.Typer()
41
42
43 def _read_repo_id(root: pathlib.Path) -> str:
44 import json as _json
45
46 return str(_json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
47
48
49 def _read_branch(root: pathlib.Path) -> str:
50 return read_current_branch(root)
51
52
53 @app.callback(invoke_without_command=True)
54 def motif(
55 ctx: typer.Context,
56 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
57 ref: str | None = typer.Option(
58 None, "--commit", "-c", metavar="REF",
59 help="Analyse a historical snapshot instead of the working tree.",
60 ),
61 min_length: int = typer.Option(3, "--min-length", "-l", metavar="N", help="Minimum motif length in notes."),
62 min_occ: int = typer.Option(2, "--min-occurrences", "-o", metavar="N", help="Minimum number of recurrences."),
63 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
64 ) -> None:
65 """Find recurring melodic patterns (motifs) in a MIDI track.
66
67 ``muse motif`` scans the interval sequence between consecutive notes and
68 finds the most frequently recurring sub-sequences. It ignores transposition
69 — only the interval pattern (the shape) matters, not the starting pitch.
70
71 For agents:
72 - Use ``--min-length 4`` for tighter, more distinctive motifs.
73 - Use ``--commit`` to check whether a motif introduced in a previous commit
74 is still present after a merge.
75 - Combine with ``muse note-log`` to track where a motif first appeared.
76 """
77 root = require_repo()
78 commit_label = "working tree"
79
80 if ref is not None:
81 repo_id = _read_repo_id(root)
82 branch = _read_branch(root)
83 commit = resolve_commit_ref(root, repo_id, branch, ref)
84 if commit is None:
85 typer.echo(f"❌ Commit '{ref}' not found.", err=True)
86 raise typer.Exit(code=ExitCode.USER_ERROR)
87 result = load_track(root, commit.commit_id, track)
88 commit_label = commit.commit_id[:8]
89 else:
90 result = load_track_from_workdir(root, track)
91
92 if result is None:
93 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
94 raise typer.Exit(code=ExitCode.USER_ERROR)
95
96 notes, _tpb = result
97 if not notes:
98 typer.echo(f" (no notes found in '{track}')")
99 return
100
101 motifs = find_motifs(notes, min_length=min_length, min_occurrences=min_occ)
102
103 if as_json:
104 typer.echo(json.dumps(
105 {"track": track, "commit": commit_label, "motifs": list(motifs)},
106 indent=2,
107 ))
108 return
109
110 typer.echo(f"\nMotif analysis: {track} — {commit_label}")
111 if not motifs:
112 typer.echo(
113 f" (no motifs found with length ≥ {min_length} and occurrences ≥ {min_occ})"
114 )
115 return
116
117 typer.echo(f"Found {len(motifs)} motif{'s' if len(motifs) != 1 else ''}\n")
118 for m in motifs:
119 intervals_str = " ".join(f"{iv:+d}" for iv in m["interval_pattern"])
120 bars_str = ", ".join(str(b) for b in m["bars"])
121 typer.echo(
122 f" Motif {m['id']} [{intervals_str}]"
123 f" {m['occurrences']}×"
124 f" first: {m['first_pitch']}"
125 f" bars: {bars_str}"
126 )