cgcardona / muse public
muse_motif.py python
655 lines 21.7 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Muse Motif Engine — identify, track, and compare recurring melodic motifs.
2
3 A *motif* is a short melodic or rhythmic idea — a sequence of pitches and/or
4 durations — that reappears and transforms throughout a composition. This
5 module implements the core analysis engine used by ``muse motif find``,
6 ``muse motif track``, and ``muse motif diff``.
7
8 Design
9 ------
10 - Melodic identity is encoded as **interval sequences** (signed semitone
11 differences between consecutive pitches) so that transpositions of the same
12 motif hash to the same fingerprint.
13 - Rhythmic identity is encoded as **relative duration ratios** normalised
14 against the shortest note in the sequence so that augmented / diminished
15 versions are detectable.
16 - A motif fingerprint is the concatenation of its interval sequence, allowing
17 fast set-based matching across commits.
18 - Transformation detection (inversion, retrograde, augmentation, diminution)
19 compares the found fingerprint against the query fingerprint's variants.
20
21 Boundary rules
22 --------------
23 - Must NOT import StateStore, executor, MCP tools, or route handlers.
24 - May import ``muse_cli.{db, models}``.
25 - Pure helpers (fingerprint computation, transformation detection) are
26 synchronous and fully testable without a DB.
27 """
28 from __future__ import annotations
29
30 import logging
31 from dataclasses import dataclass
32 from enum import Enum
33 from typing import Optional
34
35 logger = logging.getLogger(__name__)
36
37 # ---------------------------------------------------------------------------
38 # Primitive types
39 # ---------------------------------------------------------------------------
40
41 #: A melodic motif expressed as a sequence of semitone intervals.
42 #: e.g. [2, 2, -1, 2] for a 4-note motif with those ascending/descending steps.
43 IntervalSequence = tuple[int, ...]
44
45 #: A rhythmic motif expressed as relative duration ratios (float).
46 #: e.g. (1.0, 2.0, 1.0) means short–long–short relative durations.
47 RhythmSequence = tuple[float, ...]
48
49
50 # ---------------------------------------------------------------------------
51 # Transformation vocabulary
52 # ---------------------------------------------------------------------------
53
54
55 class MotifTransformation(str, Enum):
56 """Detected relationship between a found motif and the query motif.
57
58 - ``EXACT`` — identical interval sequence (possibly transposed).
59 - ``INVERSION`` — each interval negated (melodic mirror).
60 - ``RETROGRADE`` — interval sequence reversed.
61 - ``RETRO_INV`` — retrograde + inversion combined.
62 - ``AUGMENTED`` — same intervals; note durations scaled up.
63 - ``DIMINISHED`` — same intervals; note durations scaled down.
64 - ``APPROXIMATE`` — similar contour but not an exact variant.
65 """
66
67 EXACT = "exact"
68 INVERSION = "inversion"
69 RETROGRADE = "retrograde"
70 RETRO_INV = "retro_inv"
71 AUGMENTED = "augmented"
72 DIMINISHED = "diminished"
73 APPROXIMATE = "approximate"
74
75
76 # ---------------------------------------------------------------------------
77 # Named result types (public API contract)
78 # ---------------------------------------------------------------------------
79
80
81 @dataclass(frozen=True)
82 class MotifOccurrence:
83 """A single occurrence of a motif within a commit or pattern search.
84
85 Attributes:
86 commit_id: Short commit SHA where the motif was found.
87 track: Track name (e.g. ``"melody"``, ``"bass"``).
88 section: Named section the occurrence falls in (optional).
89 start_position: Position index of the first note of the motif.
90 transformation: Relationship to the query motif.
91 pitch_sequence: Literal pitch values at this occurrence (MIDI note numbers).
92 interval_fingerprint: Normalised interval sequence used for matching.
93 """
94
95 commit_id: str
96 track: str
97 section: Optional[str]
98 start_position: int
99 transformation: MotifTransformation
100 pitch_sequence: tuple[int, ...]
101 interval_fingerprint: IntervalSequence
102
103
104 @dataclass(frozen=True)
105 class MotifFindResult:
106 """Results from ``muse motif find`` — recurring patterns in a single commit.
107
108 Attributes:
109 commit_id: Short commit SHA analysed.
110 branch: Branch name.
111 min_length: Minimum motif length requested.
112 motifs: Detected recurring motif groups, sorted by occurrence count desc.
113 total_found: Total number of distinct recurring motifs identified.
114 source: ``"stub"`` until full MIDI analysis is wired; ``"live"`` thereafter.
115 """
116
117 commit_id: str
118 branch: str
119 min_length: int
120 motifs: tuple[MotifGroup, ...]
121 total_found: int
122 source: str
123
124
125 @dataclass(frozen=True)
126 class MotifGroup:
127 """A single recurring motif and all its occurrences in the scanned commit.
128
129 Attributes:
130 fingerprint: Normalised interval sequence (the motif's identity).
131 count: Number of times the motif appears.
132 occurrences: All detected occurrences.
133 label: Human-readable contour label (e.g. ``"ascending-step"``,
134 ``"arch"``, ``"descending-leap"``).
135 """
136
137 fingerprint: IntervalSequence
138 count: int
139 occurrences: tuple[MotifOccurrence, ...]
140 label: str
141
142
143 @dataclass(frozen=True)
144 class MotifTrackResult:
145 """Results from ``muse motif track`` — appearances of a pattern across history.
146
147 Attributes:
148 pattern: The query pattern (as a space-separated pitch string or
149 interval fingerprint).
150 fingerprint: Normalised interval sequence derived from the pattern.
151 occurrences: All commits where the motif (or a transformation) was found.
152 total_commits_scanned: How many commits were searched.
153 source: ``"stub"`` or ``"live"``.
154 """
155
156 pattern: str
157 fingerprint: IntervalSequence
158 occurrences: tuple[MotifOccurrence, ...]
159 total_commits_scanned: int
160 source: str
161
162
163 @dataclass(frozen=True)
164 class MotifDiffEntry:
165 """One side of a motif diff comparison.
166
167 Attributes:
168 commit_id: Short commit SHA.
169 fingerprint: Interval sequence at this commit.
170 label: Contour label.
171 pitch_sequence: Literal pitches (if available).
172 """
173
174 commit_id: str
175 fingerprint: IntervalSequence
176 label: str
177 pitch_sequence: tuple[int, ...]
178
179
180 @dataclass(frozen=True)
181 class MotifDiffResult:
182 """Results from ``muse motif diff`` — how a motif transformed between commits.
183
184 Attributes:
185 commit_a: Analysis of the motif at the first commit.
186 commit_b: Analysis of the motif at the second commit.
187 transformation: How the motif changed from commit A to commit B.
188 description: Human-readable description of the transformation.
189 source: ``"stub"`` or ``"live"``.
190 """
191
192 commit_a: MotifDiffEntry
193 commit_b: MotifDiffEntry
194 transformation: MotifTransformation
195 description: str
196 source: str
197
198
199 @dataclass(frozen=True)
200 class SavedMotif:
201 """A named motif stored in ``.muse/motifs/``.
202
203 Attributes:
204 name: User-assigned motif name (e.g. ``"main-theme"``).
205 fingerprint: Stored interval fingerprint.
206 created_at: ISO-8601 timestamp when the motif was named.
207 description: Optional free-text annotation.
208 """
209
210 name: str
211 fingerprint: IntervalSequence
212 created_at: str
213 description: Optional[str]
214
215
216 @dataclass(frozen=True)
217 class MotifListResult:
218 """Results from ``muse motif list`` — all named motifs in the repository.
219
220 Attributes:
221 motifs: All saved named motifs.
222 source: ``"stub"`` or ``"live"``.
223 """
224
225 motifs: tuple[SavedMotif, ...]
226 source: str
227
228
229 # ---------------------------------------------------------------------------
230 # Pure fingerprint helpers
231 # ---------------------------------------------------------------------------
232
233
234 def pitches_to_intervals(pitches: tuple[int, ...]) -> IntervalSequence:
235 """Convert a sequence of MIDI pitch numbers to a signed semitone interval sequence.
236
237 The interval representation is transposition-invariant — the same motif
238 at different pitch levels produces identical fingerprints.
239
240 Args:
241 pitches: Sequence of MIDI note numbers (0–127), length ≥ 2.
242
243 Returns:
244 Tuple of signed semitone differences, length = ``len(pitches) - 1``.
245 Returns an empty tuple for inputs shorter than 2 notes.
246 """
247 if len(pitches) < 2:
248 return ()
249 return tuple(pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1))
250
251
252 def invert_intervals(intervals: IntervalSequence) -> IntervalSequence:
253 """Return the melodic inversion of an interval sequence (negate each step).
254
255 Inversion mirrors the motif around its starting pitch so that what went
256 up now goes down by the same interval.
257
258 Args:
259 intervals: Normalised interval sequence from :func:`pitches_to_intervals`.
260
261 Returns:
262 Interval sequence with each element negated.
263 """
264 return tuple(-i for i in intervals)
265
266
267 def retrograde_intervals(intervals: IntervalSequence) -> IntervalSequence:
268 """Return the retrograde (reverse) of an interval sequence.
269
270 Note: reversing the intervals gives the same pitches played backward,
271 which is not the same as reversing the pitch list directly.
272
273 Args:
274 intervals: Normalised interval sequence.
275
276 Returns:
277 Reversed interval sequence, with each element negated (retrograde
278 of pitches is the negation of reversed intervals).
279 """
280 return tuple(-i for i in reversed(intervals))
281
282
283 def detect_transformation(
284 query: IntervalSequence,
285 candidate: IntervalSequence,
286 ) -> Optional[MotifTransformation]:
287 """Determine the transformation relationship between a query and candidate motif.
288
289 Checks for exact match (transposition), inversion, retrograde, and the
290 combined retrograde-inversion.
291
292 Args:
293 query: The reference interval sequence.
294 candidate: The interval sequence to test.
295
296 Returns:
297 The :class:`MotifTransformation` if a relationship is detected, or
298 ``None`` if no recognised transformation applies.
299 """
300 if candidate == query:
301 return MotifTransformation.EXACT
302 if candidate == invert_intervals(query):
303 return MotifTransformation.INVERSION
304 if candidate == retrograde_intervals(query):
305 return MotifTransformation.RETROGRADE
306 if candidate == invert_intervals(retrograde_intervals(query)):
307 return MotifTransformation.RETRO_INV
308 return None
309
310
311 def contour_label(intervals: IntervalSequence) -> str:
312 """Assign a human-readable contour label to an interval sequence.
313
314 Labels encode the overall melodic direction and whether movement is
315 predominantly stepwise (≤2 semitones) or leap-based (>2 semitones).
316
317 Args:
318 intervals: Normalised interval sequence. Empty sequences return ``"static"``.
319
320 Returns:
321 One of: ``"ascending-step"``, ``"ascending-leap"``,
322 ``"descending-step"``, ``"descending-leap"``, ``"arch"``,
323 ``"valley"``, ``"oscillating"``, or ``"static"``.
324 """
325 if not intervals:
326 return "static"
327 net = sum(intervals)
328 max_step = max(abs(i) for i in intervals)
329 direction_changes = sum(
330 1
331 for j in range(len(intervals) - 1)
332 if (intervals[j] > 0) != (intervals[j + 1] > 0)
333 )
334 if direction_changes >= len(intervals) // 2:
335 return "oscillating"
336 ups = sum(1 for i in intervals if i > 0)
337 downs = sum(1 for i in intervals if i < 0)
338 if ups > 0 and downs > 0:
339 if ups > downs:
340 return "arch"
341 if downs > ups:
342 return "valley"
343 # Equal ups and downs: arch if motion starts upward, valley if downward.
344 return "arch" if intervals[0] > 0 else "valley"
345 stepwise = max_step <= 2
346 if net > 0:
347 return "ascending-step" if stepwise else "ascending-leap"
348 if net < 0:
349 return "descending-step" if stepwise else "descending-leap"
350 return "static"
351
352
353 def parse_pitch_string(pattern: str) -> tuple[int, ...]:
354 """Parse a space-separated pitch-name or MIDI-number string into pitch values.
355
356 Supports:
357 - MIDI integers: ``"60 62 64 67"``
358 - Note names: ``"C D E G"`` (middle octave assumed, sharps as ``C#``/``Cs``)
359
360 Args:
361 pattern: Space-separated pitch tokens.
362
363 Returns:
364 Tuple of MIDI note numbers (0–127).
365
366 Raises:
367 ValueError: If any token cannot be parsed as a MIDI number or note name.
368 """
369 _NOTE_MAP: dict[str, int] = {
370 "C": 60, "C#": 61, "CS": 61, "DB": 61,
371 "D": 62, "D#": 63, "DS": 63, "EB": 63,
372 "E": 64, "F": 65, "F#": 66, "FS": 66, "GB": 66,
373 "G": 67, "G#": 68, "GS": 68, "AB": 68,
374 "A": 69, "A#": 70, "AS": 70, "BB": 70,
375 "B": 71,
376 }
377 result: list[int] = []
378 for token in pattern.strip().split():
379 upper = token.upper().replace("-", "")
380 if upper in _NOTE_MAP:
381 result.append(_NOTE_MAP[upper])
382 else:
383 try:
384 midi = int(token)
385 if not 0 <= midi <= 127:
386 raise ValueError(f"MIDI pitch {midi} out of range [0, 127]")
387 result.append(midi)
388 except ValueError as exc:
389 raise ValueError(f"Cannot parse pitch token {token!r}") from exc
390 return tuple(result)
391
392
393 # ---------------------------------------------------------------------------
394 # Stub data helpers
395 # ---------------------------------------------------------------------------
396
397 _STUB_MOTIFS: list[tuple[IntervalSequence, str, int]] = [
398 ((2, 2, -1, 2), "ascending-step", 3),
399 ((-2, -2, 1, -2), "descending-step", 2),
400 ((4, -2, 3), "arch", 2),
401 ]
402
403
404 def _stub_motif_groups(
405 commit_id: str,
406 track: Optional[str],
407 min_length: int,
408 ) -> tuple[MotifGroup, ...]:
409 """Return placeholder MotifGroup entries for stub mode.
410
411 Args:
412 commit_id: Short commit SHA to embed in occurrences.
413 track: Track filter (if provided, used as the occurrence track name).
414 min_length: Minimum motif length filter (intervals of length ≥ min_length - 1).
415
416 Returns:
417 Tuple of :class:`MotifGroup` objects filtered to min_length.
418 """
419 groups: list[MotifGroup] = []
420 for fp, label, count in _STUB_MOTIFS:
421 if len(fp) + 1 < min_length:
422 continue
423 track_name = track or "melody"
424 pitches = _intervals_to_pitches(fp, start=60)
425 occurrence = MotifOccurrence(
426 commit_id=commit_id,
427 track=track_name,
428 section=None,
429 start_position=0,
430 transformation=MotifTransformation.EXACT,
431 pitch_sequence=pitches,
432 interval_fingerprint=fp,
433 )
434 groups.append(
435 MotifGroup(
436 fingerprint=fp,
437 count=count,
438 occurrences=(occurrence,) * count,
439 label=label,
440 )
441 )
442 return tuple(sorted(groups, key=lambda g: g.count, reverse=True))
443
444
445 def _intervals_to_pitches(
446 intervals: IntervalSequence,
447 start: int = 60,
448 ) -> tuple[int, ...]:
449 """Reconstruct a pitch sequence from an interval sequence starting at *start*.
450
451 Args:
452 intervals: Signed semitone intervals.
453 start: MIDI pitch of the first note (default: 60 = middle C).
454
455 Returns:
456 Tuple of MIDI pitch values.
457 """
458 pitches: list[int] = [start]
459 for step in intervals:
460 pitches.append(pitches[-1] + step)
461 return tuple(pitches)
462
463
464 # ---------------------------------------------------------------------------
465 # Public async API (stub implementations — contract-correct)
466 # ---------------------------------------------------------------------------
467
468
469 async def find_motifs(
470 *,
471 commit_id: str,
472 branch: str,
473 min_length: int = 3,
474 track: Optional[str] = None,
475 section: Optional[str] = None,
476 as_json: bool = False,
477 ) -> MotifFindResult:
478 """Detect recurring melodic/rhythmic patterns in a single commit.
479
480 Scans the MIDI data at *commit_id* for note sequences that appear more
481 than once within the commit, groups them by their transposition-invariant
482 fingerprint, and returns them sorted by occurrence count.
483
484 Args:
485 commit_id: Short or full commit SHA to analyse.
486 branch: Branch name (for context in the result).
487 min_length: Minimum motif length in notes (default: 3). Shorter
488 motifs tend to be musically trivial.
489 track: Restrict analysis to a single named track, or ``None`` for all.
490 section: Restrict to a named section/region, or ``None`` for all.
491 as_json: Unused here — rendered by the CLI layer.
492
493 Returns:
494 A :class:`MotifFindResult` with all detected recurring motifs.
495 """
496 logger.info("✅ muse motif find: commit=%s min_length=%d", commit_id[:8], min_length)
497 groups = _stub_motif_groups(commit_id[:8], track=track, min_length=min_length)
498 return MotifFindResult(
499 commit_id=commit_id[:8],
500 branch=branch,
501 min_length=min_length,
502 motifs=groups,
503 total_found=len(groups),
504 source="stub",
505 )
506
507
508 async def track_motif(
509 *,
510 pattern: str,
511 commit_ids: list[str],
512 ) -> MotifTrackResult:
513 """Search all commits for appearances of a specific motif pattern.
514
515 Parses *pattern* as a sequence of pitch names or MIDI numbers, derives the
516 transposition-invariant interval fingerprint, and scans each commit in
517 *commit_ids* for exact matches or recognised transformations (inversion,
518 retrograde, retrograde-inversion).
519
520 Args:
521 pattern: Space-separated pitch names (e.g. ``"C D E G"``) or MIDI
522 numbers (e.g. ``"60 62 64 67"``).
523 commit_ids: Ordered list of commit SHAs to scan (newest first).
524
525 Returns:
526 A :class:`MotifTrackResult` with all occurrences found.
527
528 Raises:
529 ValueError: If *pattern* cannot be parsed into a valid pitch sequence.
530 """
531 pitches = parse_pitch_string(pattern)
532 fingerprint = pitches_to_intervals(pitches)
533 logger.info(
534 "✅ muse motif track: pattern=%r fingerprint=%r commits=%d",
535 pattern,
536 fingerprint,
537 len(commit_ids),
538 )
539
540 occurrences: list[MotifOccurrence] = []
541 for cid in commit_ids:
542 short = cid[:8]
543 occ = MotifOccurrence(
544 commit_id=short,
545 track="melody",
546 section=None,
547 start_position=0,
548 transformation=MotifTransformation.EXACT,
549 pitch_sequence=pitches,
550 interval_fingerprint=fingerprint,
551 )
552 occurrences.append(occ)
553
554 return MotifTrackResult(
555 pattern=pattern,
556 fingerprint=fingerprint,
557 occurrences=tuple(occurrences),
558 total_commits_scanned=len(commit_ids),
559 source="stub",
560 )
561
562
563 async def diff_motifs(
564 *,
565 commit_a_id: str,
566 commit_b_id: str,
567 ) -> MotifDiffResult:
568 """Show how the dominant motif transformed between two commits.
569
570 Extracts the most prominent motif from each commit and computes the
571 transformation relationship between them (exact, inversion, retrograde, etc.).
572
573 Args:
574 commit_a_id: Short or full SHA of the first (earlier) commit.
575 commit_b_id: Short or full SHA of the second (later) commit.
576
577 Returns:
578 A :class:`MotifDiffResult` describing the transformation.
579 """
580 fp_a: IntervalSequence = (2, 2, -1, 2)
581 fp_b: IntervalSequence = (-2, -2, 1, -2)
582
583 transformation = detect_transformation(fp_a, fp_b) or MotifTransformation.APPROXIMATE
584
585 descriptions: dict[MotifTransformation, str] = {
586 MotifTransformation.EXACT: "The motif is transposition-equivalent — same shape, different pitch level.",
587 MotifTransformation.INVERSION: "The motif was inverted — ascending intervals became descending.",
588 MotifTransformation.RETROGRADE: "The motif was played in retrograde — same pitches reversed.",
589 MotifTransformation.RETRO_INV: "The motif was retrograde-inverted — reversed and mirrored.",
590 MotifTransformation.AUGMENTED: "The motif was augmented — note durations scaled up.",
591 MotifTransformation.DIMINISHED: "The motif was diminished — note durations compressed.",
592 MotifTransformation.APPROXIMATE: "The motif contour changed significantly between commits.",
593 }
594
595 entry_a = MotifDiffEntry(
596 commit_id=commit_a_id[:8],
597 fingerprint=fp_a,
598 label=contour_label(fp_a),
599 pitch_sequence=_intervals_to_pitches(fp_a),
600 )
601 entry_b = MotifDiffEntry(
602 commit_id=commit_b_id[:8],
603 fingerprint=fp_b,
604 label=contour_label(fp_b),
605 pitch_sequence=_intervals_to_pitches(fp_b),
606 )
607
608 logger.info(
609 "✅ muse motif diff: %s → %s, transformation=%s",
610 commit_a_id[:8],
611 commit_b_id[:8],
612 transformation.value,
613 )
614
615 return MotifDiffResult(
616 commit_a=entry_a,
617 commit_b=entry_b,
618 transformation=transformation,
619 description=descriptions[transformation],
620 source="stub",
621 )
622
623
624 async def list_motifs(
625 *,
626 muse_dir_path: str,
627 ) -> MotifListResult:
628 """List all named motifs stored in ``.muse/motifs/``.
629
630 Named motifs are user-annotated melodic ideas saved for future recall.
631 This command surfaces them in a structured format suitable for both
632 human review and agent consumption.
633
634 Args:
635 muse_dir_path: Absolute path to the ``.muse/`` directory.
636
637 Returns:
638 A :class:`MotifListResult` with all saved named motifs.
639 """
640 logger.info("✅ muse motif list: scanning %s", muse_dir_path)
641 stub_motifs: tuple[SavedMotif, ...] = (
642 SavedMotif(
643 name="main-theme",
644 fingerprint=(2, 2, -1, 2),
645 created_at="2026-01-15T10:30:00Z",
646 description="The central ascending motif introduced in the opening.",
647 ),
648 SavedMotif(
649 name="bass-riff",
650 fingerprint=(-2, -3, 2),
651 created_at="2026-01-20T14:15:00Z",
652 description="Chromatic bass figure used throughout the bridge.",
653 ),
654 )
655 return MotifListResult(motifs=stub_motifs, source="stub")