cgcardona / muse public
diff.py python
644 lines 20.2 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse diff — music-dimension diff between two commits.
2
3 Compares two commits across five orthogonal musical dimensions:
4
5 - **harmonic** — key, mode, chord progression, tension profile
6 - **rhythmic** — tempo, meter, swing factor, groove tightness
7 - **melodic** — motifs, melodic contour, pitch range
8 - **structural** — arrangement form, sections, instrumentation
9 - **dynamic** — overall volume arc, per-track loudness envelope
10
11 Each dimension produces a focused, human-readable diff (or structured JSON).
12 ``--all`` runs every dimension simultaneously and combines the results into a
13 single musical change report.
14
15 Flags
16 -----
17 COMMIT_A TEXT Earlier commit ref (default: HEAD~1).
18 COMMIT_B TEXT Later commit ref (default: HEAD).
19 --harmonic Compare harmonic content between commits.
20 --rhythmic Compare rhythmic content between commits.
21 --melodic Compare melodic content between commits.
22 --structural Compare arrangement/form between commits.
23 --dynamic Compare dynamic profiles between commits.
24 --all Run all dimension analyses and produce a combined report.
25 --json Emit structured JSON for each dimension instead of text.
26 """
27 from __future__ import annotations
28
29 import asyncio
30 import json
31 import logging
32 import pathlib
33 from typing import Optional
34
35 import typer
36 from typing_extensions import TypedDict
37
38 from maestro.muse_cli._repo import require_repo
39 from maestro.muse_cli.db import open_session
40 from maestro.muse_cli.errors import ExitCode
41
42 logger = logging.getLogger(__name__)
43
44 app = typer.Typer()
45
46
47 class HarmonicDiffResult(TypedDict):
48 """Harmonic-dimension diff between two commits."""
49 commit_a: str
50 commit_b: str
51 key_a: str
52 key_b: str
53 mode_a: str
54 mode_b: str
55 chord_prog_a: str
56 chord_prog_b: str
57 tension_a: float
58 tension_b: float
59 tension_label_a: str
60 tension_label_b: str
61 summary: str
62 changed: bool
63
64
65 class RhythmicDiffResult(TypedDict):
66 """Rhythmic-dimension diff between two commits."""
67 commit_a: str
68 commit_b: str
69 tempo_a: float
70 tempo_b: float
71 meter_a: str
72 meter_b: str
73 swing_a: float
74 swing_b: float
75 swing_label_a: str
76 swing_label_b: str
77 groove_drift_ms_a: float
78 groove_drift_ms_b: float
79 summary: str
80 changed: bool
81
82
83 class MelodicDiffResult(TypedDict):
84 """Melodic-dimension diff between two commits."""
85 commit_a: str
86 commit_b: str
87 motifs_introduced: list[str]
88 motifs_removed: list[str]
89 contour_a: str
90 contour_b: str
91 range_low_a: int
92 range_low_b: int
93 range_high_a: int
94 range_high_b: int
95 summary: str
96 changed: bool
97
98
99 class StructuralDiffResult(TypedDict):
100 """Structural/arrangement-dimension diff between two commits."""
101 commit_a: str
102 commit_b: str
103 sections_added: list[str]
104 sections_removed: list[str]
105 instruments_added: list[str]
106 instruments_removed: list[str]
107 form_a: str
108 form_b: str
109 summary: str
110 changed: bool
111
112
113 class DynamicDiffResult(TypedDict):
114 """Dynamic-profile-dimension diff between two commits."""
115 commit_a: str
116 commit_b: str
117 avg_velocity_a: int
118 avg_velocity_b: int
119 arc_a: str
120 arc_b: str
121 tracks_louder: list[str]
122 tracks_softer: list[str]
123 tracks_silent: list[str]
124 summary: str
125 changed: bool
126
127
128 class MusicDiffReport(TypedDict):
129 """Combined multi-dimension musical diff report (produced by --all)."""
130 commit_a: str
131 commit_b: str
132 harmonic: Optional[HarmonicDiffResult]
133 rhythmic: Optional[RhythmicDiffResult]
134 melodic: Optional[MelodicDiffResult]
135 structural: Optional[StructuralDiffResult]
136 dynamic: Optional[DynamicDiffResult]
137 changed_dimensions: list[str]
138 unchanged_dimensions: list[str]
139 summary: str
140
141
142 _TENSION_LOW = 0.33
143 _TENSION_MED = 0.66
144
145
146 def _tension_label(value: float) -> str:
147 """Map a normalized tension value (0-1) to a human-readable label."""
148 if value < _TENSION_LOW:
149 return "Low"
150 if value < _TENSION_MED:
151 return "Medium"
152 if value < 0.80:
153 return "Medium-High"
154 return "High"
155
156
157 def _stub_harmonic(commit_a: str, commit_b: str) -> HarmonicDiffResult:
158 """Return a stub harmonic diff between two commit refs."""
159 return HarmonicDiffResult(
160 commit_a=commit_a,
161 commit_b=commit_b,
162 key_a="Eb major",
163 key_b="F minor",
164 mode_a="Major",
165 mode_b="Minor",
166 chord_prog_a="I-IV-V-I",
167 chord_prog_b="i-VI-III-VII",
168 tension_a=0.2,
169 tension_b=0.65,
170 tension_label_a=_tension_label(0.2),
171 tension_label_b=_tension_label(0.65),
172 summary=(
173 "Major harmonic restructuring — key modulation down a minor 3rd, "
174 "shift to Andalusian cadence"
175 ),
176 changed=True,
177 )
178
179
180 def _stub_rhythmic(commit_a: str, commit_b: str) -> RhythmicDiffResult:
181 """Return a stub rhythmic diff."""
182 return RhythmicDiffResult(
183 commit_a=commit_a,
184 commit_b=commit_b,
185 tempo_a=120.0,
186 tempo_b=128.0,
187 meter_a="4/4",
188 meter_b="4/4",
189 swing_a=0.50,
190 swing_b=0.57,
191 swing_label_a="Straight",
192 swing_label_b="Light swing",
193 groove_drift_ms_a=12.0,
194 groove_drift_ms_b=6.0,
195 summary="Slightly faster, more swung, tighter quantization",
196 changed=True,
197 )
198
199
200 def _stub_melodic(commit_a: str, commit_b: str) -> MelodicDiffResult:
201 """Return a stub melodic diff."""
202 return MelodicDiffResult(
203 commit_a=commit_a,
204 commit_b=commit_b,
205 motifs_introduced=["chromatic-descent-4"],
206 motifs_removed=[],
207 contour_a="ascending-arch",
208 contour_b="descending-step",
209 range_low_a=48,
210 range_low_b=43,
211 range_high_a=84,
212 range_high_b=79,
213 summary=(
214 "New chromatic descent motif introduced; contour shifted from "
215 "ascending arch to descending step; overall range dropped by a 4th"
216 ),
217 changed=True,
218 )
219
220
221 def _stub_structural(commit_a: str, commit_b: str) -> StructuralDiffResult:
222 """Return a stub structural diff."""
223 return StructuralDiffResult(
224 commit_a=commit_a,
225 commit_b=commit_b,
226 sections_added=["bridge"],
227 sections_removed=[],
228 instruments_added=["acoustic_guitar"],
229 instruments_removed=["strings"],
230 form_a="Intro-Verse-Chorus-Verse-Chorus-Outro",
231 form_b="Intro-Verse-Chorus-Bridge-Chorus-Outro",
232 summary=(
233 "Bridge added between second chorus and outro; "
234 "strings removed, acoustic guitar added"
235 ),
236 changed=True,
237 )
238
239
240 def _stub_dynamic(commit_a: str, commit_b: str) -> DynamicDiffResult:
241 """Return a stub dynamic diff."""
242 return DynamicDiffResult(
243 commit_a=commit_a,
244 commit_b=commit_b,
245 avg_velocity_a=72,
246 avg_velocity_b=84,
247 arc_a="flat",
248 arc_b="crescendo",
249 tracks_louder=["drums", "bass"],
250 tracks_softer=[],
251 tracks_silent=["lead-synth"],
252 summary=(
253 "Overall louder (+12 avg velocity), arc shifted to crescendo; "
254 "lead-synth track went silent"
255 ),
256 changed=True,
257 )
258
259
260 def _resolve_refs(
261 root: pathlib.Path,
262 commit_a: Optional[str],
263 commit_b: Optional[str],
264 ) -> tuple[str, str]:
265 """Resolve commit_a and commit_b against the local .muse/ HEAD chain."""
266 muse_dir = root / ".muse"
267 head_path = muse_dir / "HEAD"
268 head_ref = head_path.read_text().strip() if head_path.exists() else "refs/heads/main"
269 ref_path = muse_dir / pathlib.Path(head_ref)
270 raw_sha = ref_path.read_text().strip() if ref_path.exists() else ""
271 head_sha = raw_sha[:8] if raw_sha else "HEAD"
272
273 resolved_b = commit_b or head_sha
274 resolved_a = commit_a or f"{resolved_b}~1"
275 return resolved_a, resolved_b
276
277
278 async def _harmonic_diff_async(
279 *,
280 root: pathlib.Path,
281 commit_a: str,
282 commit_b: str,
283 ) -> HarmonicDiffResult:
284 """Compute the harmonic diff between two commits (stub)."""
285 _ = root
286 return _stub_harmonic(commit_a, commit_b)
287
288
289 async def _rhythmic_diff_async(
290 *,
291 root: pathlib.Path,
292 commit_a: str,
293 commit_b: str,
294 ) -> RhythmicDiffResult:
295 """Compute the rhythmic diff between two commits (stub)."""
296 _ = root
297 return _stub_rhythmic(commit_a, commit_b)
298
299
300 async def _melodic_diff_async(
301 *,
302 root: pathlib.Path,
303 commit_a: str,
304 commit_b: str,
305 ) -> MelodicDiffResult:
306 """Compute the melodic diff between two commits (stub)."""
307 _ = root
308 return _stub_melodic(commit_a, commit_b)
309
310
311 async def _structural_diff_async(
312 *,
313 root: pathlib.Path,
314 commit_a: str,
315 commit_b: str,
316 ) -> StructuralDiffResult:
317 """Compute the structural diff between two commits (stub)."""
318 _ = root
319 return _stub_structural(commit_a, commit_b)
320
321
322 async def _dynamic_diff_async(
323 *,
324 root: pathlib.Path,
325 commit_a: str,
326 commit_b: str,
327 ) -> DynamicDiffResult:
328 """Compute the dynamic diff between two commits (stub)."""
329 _ = root
330 return _stub_dynamic(commit_a, commit_b)
331
332
333 async def _diff_all_async(
334 *,
335 root: pathlib.Path,
336 commit_a: str,
337 commit_b: str,
338 ) -> MusicDiffReport:
339 """Run all five dimension diffs and combine into a single report."""
340 harmonic = await _harmonic_diff_async(root=root, commit_a=commit_a, commit_b=commit_b)
341 rhythmic = await _rhythmic_diff_async(root=root, commit_a=commit_a, commit_b=commit_b)
342 melodic = await _melodic_diff_async(root=root, commit_a=commit_a, commit_b=commit_b)
343 structural = await _structural_diff_async(root=root, commit_a=commit_a, commit_b=commit_b)
344 dynamic = await _dynamic_diff_async(root=root, commit_a=commit_a, commit_b=commit_b)
345
346 changed: list[str] = []
347 unchanged: list[str] = []
348 for dim_name, result in [
349 ("harmonic", harmonic),
350 ("rhythmic", rhythmic),
351 ("melodic", melodic),
352 ("structural", structural),
353 ("dynamic", dynamic),
354 ]:
355 (changed if result["changed"] else unchanged).append(dim_name)
356
357 combined_summary = "; ".join([
358 f"harmonic: {harmonic['summary']}",
359 f"rhythmic: {rhythmic['summary']}",
360 f"melodic: {melodic['summary']}",
361 f"structural: {structural['summary']}",
362 f"dynamic: {dynamic['summary']}",
363 ])
364
365 return MusicDiffReport(
366 commit_a=commit_a,
367 commit_b=commit_b,
368 harmonic=harmonic,
369 rhythmic=rhythmic,
370 melodic=melodic,
371 structural=structural,
372 dynamic=dynamic,
373 changed_dimensions=changed,
374 unchanged_dimensions=unchanged,
375 summary=combined_summary,
376 )
377
378
379 def _render_harmonic(result: HarmonicDiffResult) -> str:
380 """Format a harmonic diff as a human-readable block."""
381 lines = [
382 f"Harmonic diff: {result['commit_a']} -> {result['commit_b']}",
383 "",
384 f"Key: {result['key_a']} -> {result['key_b']}",
385 f"Mode: {result['mode_a']} -> {result['mode_b']}",
386 f"Chord prog: {result['chord_prog_a']} -> {result['chord_prog_b']}",
387 (
388 f"Tension: {result['tension_label_a']} ({result['tension_a']}) "
389 f"-> {result['tension_label_b']} ({result['tension_b']})"
390 ),
391 f"Summary: {result['summary']}",
392 ]
393 if not result["changed"]:
394 lines.append("Unchanged")
395 return "\n".join(lines)
396
397
398 def _render_rhythmic(result: RhythmicDiffResult) -> str:
399 """Format a rhythmic diff as a human-readable block."""
400 tempo_sign = "+" if result["tempo_b"] >= result["tempo_a"] else ""
401 tempo_delta = result["tempo_b"] - result["tempo_a"]
402 lines = [
403 f"Rhythmic diff: {result['commit_a']} -> {result['commit_b']}",
404 "",
405 f"Tempo: {result['tempo_a']} BPM -> {result['tempo_b']} BPM ({tempo_sign}{tempo_delta:.1f} BPM)",
406 f"Meter: {result['meter_a']} -> {result['meter_b']}",
407 f"Swing: {result['swing_label_a']} ({result['swing_a']}) -> {result['swing_label_b']} ({result['swing_b']})",
408 f"Groove drift: {result['groove_drift_ms_a']}ms -> {result['groove_drift_ms_b']}ms",
409 f"Summary: {result['summary']}",
410 ]
411 if not result["changed"]:
412 lines.append("Unchanged")
413 return "\n".join(lines)
414
415
416 def _render_melodic(result: MelodicDiffResult) -> str:
417 """Format a melodic diff as a human-readable block."""
418 introduced = ", ".join(result["motifs_introduced"]) or "none"
419 removed = ", ".join(result["motifs_removed"]) or "none"
420 lines = [
421 f"Melodic diff: {result['commit_a']} -> {result['commit_b']}",
422 "",
423 f"Motifs introduced: {introduced}",
424 f"Motifs removed: {removed}",
425 f"Contour: {result['contour_a']} -> {result['contour_b']}",
426 f"Pitch range: {result['range_low_a']}-{result['range_high_a']} MIDI -> {result['range_low_b']}-{result['range_high_b']} MIDI",
427 f"Summary: {result['summary']}",
428 ]
429 if not result["changed"]:
430 lines.append("Unchanged")
431 return "\n".join(lines)
432
433
434 def _render_structural(result: StructuralDiffResult) -> str:
435 """Format a structural diff as a human-readable block."""
436 s_added = ", ".join(result["sections_added"]) or "none"
437 s_removed = ", ".join(result["sections_removed"]) or "none"
438 i_added = ", ".join(result["instruments_added"]) or "none"
439 i_removed = ", ".join(result["instruments_removed"]) or "none"
440 lines = [
441 f"Structural diff: {result['commit_a']} -> {result['commit_b']}",
442 "",
443 f"Sections added: {s_added}",
444 f"Sections removed: {s_removed}",
445 f"Instruments added: {i_added}",
446 f"Instruments removed: {i_removed}",
447 f"Form: {result['form_a']} -> {result['form_b']}",
448 f"Summary: {result['summary']}",
449 ]
450 if not result["changed"]:
451 lines.append("Unchanged")
452 return "\n".join(lines)
453
454
455 def _render_dynamic(result: DynamicDiffResult) -> str:
456 """Format a dynamic diff as a human-readable block."""
457 louder = ", ".join(result["tracks_louder"]) or "none"
458 softer = ", ".join(result["tracks_softer"]) or "none"
459 silent = ", ".join(result["tracks_silent"]) or "none"
460 vel_sign = "+" if result["avg_velocity_b"] >= result["avg_velocity_a"] else ""
461 vel_delta = result["avg_velocity_b"] - result["avg_velocity_a"]
462 lines = [
463 f"Dynamic diff: {result['commit_a']} -> {result['commit_b']}",
464 "",
465 f"Avg velocity: {result['avg_velocity_a']} -> {result['avg_velocity_b']} ({vel_sign}{vel_delta})",
466 f"Arc: {result['arc_a']} -> {result['arc_b']}",
467 f"Tracks louder: {louder}",
468 f"Tracks softer: {softer}",
469 f"Tracks silent: {silent}",
470 f"Summary: {result['summary']}",
471 ]
472 if not result["changed"]:
473 lines.append("Unchanged")
474 return "\n".join(lines)
475
476
477 def _render_report(report: MusicDiffReport) -> str:
478 """Format a full MusicDiffReport as a combined multi-dimension block."""
479 sections: list[str] = [
480 f"Music diff: {report['commit_a']} -> {report['commit_b']}",
481 f"Changed: {', '.join(report['changed_dimensions']) or 'none'}",
482 f"Unchanged: {', '.join(report['unchanged_dimensions']) or 'none'}",
483 "",
484 ]
485 if report["harmonic"] is not None:
486 sections.append("-- Harmonic --")
487 sections.append(_render_harmonic(report["harmonic"]))
488 sections.append("")
489 if report["rhythmic"] is not None:
490 sections.append("-- Rhythmic --")
491 sections.append(_render_rhythmic(report["rhythmic"]))
492 sections.append("")
493 if report["melodic"] is not None:
494 sections.append("-- Melodic --")
495 sections.append(_render_melodic(report["melodic"]))
496 sections.append("")
497 if report["structural"] is not None:
498 sections.append("-- Structural --")
499 sections.append(_render_structural(report["structural"]))
500 sections.append("")
501 if report["dynamic"] is not None:
502 sections.append("-- Dynamic --")
503 sections.append(_render_dynamic(report["dynamic"]))
504 sections.append("")
505 return "\n".join(sections)
506
507
508 @app.callback(invoke_without_command=True)
509 def diff(
510 ctx: typer.Context,
511 commit_a: Optional[str] = typer.Argument(
512 None,
513 help="Earlier commit ref (default: HEAD~1).",
514 metavar="COMMIT_A",
515 ),
516 commit_b: Optional[str] = typer.Argument(
517 None,
518 help="Later commit ref (default: HEAD).",
519 metavar="COMMIT_B",
520 ),
521 harmonic: bool = typer.Option(
522 False,
523 "--harmonic",
524 help="Compare harmonic content (key, mode, chord progression, tension).",
525 ),
526 rhythmic: bool = typer.Option(
527 False,
528 "--rhythmic",
529 help="Compare rhythmic content (tempo, meter, swing, groove drift).",
530 ),
531 melodic: bool = typer.Option(
532 False,
533 "--melodic",
534 help="Compare melodic content (motifs, contour, pitch range).",
535 ),
536 structural: bool = typer.Option(
537 False,
538 "--structural",
539 help="Compare structural content (sections, instrumentation, form).",
540 ),
541 dynamic: bool = typer.Option(
542 False,
543 "--dynamic",
544 help="Compare dynamic profiles (velocity arc, per-track loudness).",
545 ),
546 all_dims: bool = typer.Option(
547 False,
548 "--all",
549 help="Run all dimension analyses and produce a combined report.",
550 ),
551 as_json: bool = typer.Option(
552 False,
553 "--json",
554 help="Emit structured JSON output for agent consumption.",
555 ),
556 ) -> None:
557 """Compare two commits across musical dimensions.
558
559 Without dimension flags, displays a usage hint. Specify at least one of
560 --harmonic, --rhythmic, --melodic, --structural, --dynamic, or --all.
561 """
562 if ctx.invoked_subcommand is not None:
563 return
564
565 no_dims = not any([harmonic, rhythmic, melodic, structural, dynamic, all_dims])
566 if no_dims:
567 typer.echo(
568 "Specify at least one dimension flag: "
569 "--harmonic, --rhythmic, --melodic, --structural, --dynamic, --all"
570 )
571 typer.echo("Run `muse diff --help` for usage.")
572 raise typer.Exit(code=ExitCode.SUCCESS)
573
574 root = require_repo()
575
576 async def _run() -> None:
577 ref_a, ref_b = _resolve_refs(root, commit_a, commit_b)
578
579 async with open_session():
580 if all_dims:
581 report = await _diff_all_async(
582 root=root,
583 commit_a=ref_a,
584 commit_b=ref_b,
585 )
586 if as_json:
587 typer.echo(json.dumps(dict(report), indent=2))
588 else:
589 typer.echo(_render_report(report))
590 return
591
592 if harmonic:
593 result_h = await _harmonic_diff_async(
594 root=root, commit_a=ref_a, commit_b=ref_b
595 )
596 if as_json:
597 typer.echo(json.dumps(dict(result_h), indent=2))
598 else:
599 typer.echo(_render_harmonic(result_h))
600
601 if rhythmic:
602 result_r = await _rhythmic_diff_async(
603 root=root, commit_a=ref_a, commit_b=ref_b
604 )
605 if as_json:
606 typer.echo(json.dumps(dict(result_r), indent=2))
607 else:
608 typer.echo(_render_rhythmic(result_r))
609
610 if melodic:
611 result_m = await _melodic_diff_async(
612 root=root, commit_a=ref_a, commit_b=ref_b
613 )
614 if as_json:
615 typer.echo(json.dumps(dict(result_m), indent=2))
616 else:
617 typer.echo(_render_melodic(result_m))
618
619 if structural:
620 result_s = await _structural_diff_async(
621 root=root, commit_a=ref_a, commit_b=ref_b
622 )
623 if as_json:
624 typer.echo(json.dumps(dict(result_s), indent=2))
625 else:
626 typer.echo(_render_structural(result_s))
627
628 if dynamic:
629 result_d = await _dynamic_diff_async(
630 root=root, commit_a=ref_a, commit_b=ref_b
631 )
632 if as_json:
633 typer.echo(json.dumps(dict(result_d), indent=2))
634 else:
635 typer.echo(_render_dynamic(result_d))
636
637 try:
638 asyncio.run(_run())
639 except typer.Exit:
640 raise
641 except Exception as exc:
642 typer.echo(f"muse diff failed: {exc}")
643 logger.error("muse diff error: %s", exc, exc_info=True)
644 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)