cgcardona / muse public
contour.py python
489 lines 15.6 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse contour — analyze the melodic contour and phrase shape of a composition.
2
3 Melodic contour encodes whether a melody rises, falls, arches, or waves — a
4 fundamental expressive quality that distinguishes two otherwise similar melodies.
5 This command computes the pitch trajectory, classifies the overall shape, and
6 reports phrase statistics for a target commit (default HEAD).
7
8 Shape vocabulary
9 ----------------
10 - ascending — net upward movement across the full phrase
11 - descending — net downward movement across the full phrase
12 - arch — rises then falls (single peak)
13 - inverted-arch — falls then rises (valley shape)
14 - wave — multiple peaks; alternating rise and fall
15 - static — narrow pitch range (< 2 semitones spread)
16
17 Command forms
18 -------------
19
20 Analyse melodic contour at HEAD (default)::
21
22 muse contour
23
24 Analyse at a specific commit::
25
26 muse contour a1b2c3d4
27
28 Restrict to a named melodic track::
29
30 muse contour --track keys
31
32 Scope to a section::
33
34 muse contour --section verse
35
36 Compare contour between two commits::
37
38 muse contour --compare HEAD~10 HEAD
39
40 Show overall shape label only::
41
42 muse contour --shape
43
44 Show contour evolution across all commits::
45
46 muse contour --history
47
48 Machine-readable JSON output::
49
50 muse contour --json
51 """
52 from __future__ import annotations
53
54 import asyncio
55 import json
56 import logging
57 import pathlib
58 from typing import Optional
59
60 import typer
61 from sqlalchemy.ext.asyncio import AsyncSession
62 from typing_extensions import Annotated, TypedDict
63
64 from maestro.muse_cli._repo import require_repo
65 from maestro.muse_cli.db import open_session
66 from maestro.muse_cli.errors import ExitCode
67
68 logger = logging.getLogger(__name__)
69
70 app = typer.Typer()
71
72 # ---------------------------------------------------------------------------
73 # Shape label constants
74 # ---------------------------------------------------------------------------
75
76 ShapeLabel = str # one of the SHAPE_LABELS values
77
78 SHAPE_LABELS: tuple[str, ...] = (
79 "ascending",
80 "descending",
81 "arch",
82 "inverted-arch",
83 "wave",
84 "static",
85 )
86
87 VALID_SHAPES: frozenset[str] = frozenset(SHAPE_LABELS)
88
89 # ---------------------------------------------------------------------------
90 # Named result types — stable CLI contract
91 # ---------------------------------------------------------------------------
92
93
94 class ContourResult(TypedDict):
95 """Melodic contour analysis for a single commit or working tree.
96
97 Fields
98 ------
99 shape: Overall melodic shape label (ascending, descending, arch, …).
100 tessitura: Effective pitch range in semitones.
101 avg_interval: Mean absolute note-to-note interval (semitones). Higher
102 values indicate more angular, wider-leaping melodies.
103 phrase_count: Number of detected melodic phrases.
104 avg_phrase_bars: Mean phrase length in bars.
105 commit: Commit SHA analysed (8-char prefix).
106 branch: Current branch name.
107 track: Track name analysed, or "all".
108 section: Section name scoped, or "all".
109 source: "stub" until MIDI analysis is wired in.
110 """
111
112 shape: str
113 tessitura: int
114 avg_interval: float
115 phrase_count: int
116 avg_phrase_bars: float
117 commit: str
118 branch: str
119 track: str
120 section: str
121 source: str
122
123
124 class ContourCompareResult(TypedDict):
125 """Comparison of melodic contour between two commits.
126
127 Fields
128 ------
129 commit_a: ContourResult for the first commit (or HEAD).
130 commit_b: ContourResult for the reference commit.
131 shape_changed: True when the overall shape label differs.
132 angularity_delta: Change in avg_interval (positive = more angular).
133 tessitura_delta: Change in tessitura semitones (positive = wider).
134 """
135
136 commit_a: ContourResult
137 commit_b: ContourResult
138 shape_changed: bool
139 angularity_delta: float
140 tessitura_delta: int
141
142
143 # ---------------------------------------------------------------------------
144 # Stub data
145 # ---------------------------------------------------------------------------
146
147 _STUB_SHAPE: ShapeLabel = "arch"
148 _STUB_TESSITURA = 24 # 2 octaves
149 _STUB_AVG_INTERVAL = 2.5 # semitones
150 _STUB_PHRASE_COUNT = 4
151 _STUB_AVG_PHRASE_BARS = 8.0
152
153
154 # ---------------------------------------------------------------------------
155 # Testable async core
156 # ---------------------------------------------------------------------------
157
158
159 async def _resolve_head(root: pathlib.Path) -> tuple[str, str]:
160 """Return (branch, head_sha) from the .muse/ layout.
161
162 Reads HEAD → ref → SHA, returning empty string for the SHA when no commits
163 exist yet. Called by every analysis function to avoid duplicating file I/O.
164
165 Args:
166 root: Repository root (directory that contains ``.muse/``).
167
168 Returns:
169 Tuple of (branch_name, head_sha_or_empty).
170 """
171 muse_dir = root / ".muse"
172 head_ref = (muse_dir / "HEAD").read_text().strip()
173 branch = head_ref.rsplit("/", 1)[-1] if "/" in head_ref else head_ref
174 ref_path = muse_dir / pathlib.Path(head_ref)
175 head_sha = ref_path.read_text().strip() if ref_path.exists() else ""
176 return branch, head_sha
177
178
179 async def _contour_detect_async(
180 *,
181 root: pathlib.Path,
182 session: AsyncSession,
183 commit: Optional[str],
184 track: Optional[str],
185 section: Optional[str],
186 ) -> ContourResult:
187 """Compute the melodic contour for a single commit (or working tree).
188
189 Stub implementation: resolves branch/commit metadata from ``.muse/`` and
190 returns a realistic placeholder result in the correct schema. Full MIDI
191 analysis will be wired once Storpheus exposes a pitch-trajectory route.
192
193 Args:
194 root: Repository root.
195 session: Open async DB session (reserved for full implementation).
196 commit: Commit SHA to analyse, or ``None`` for HEAD.
197 track: Named MIDI track to analyse, or ``None`` for all tracks.
198 section: Named section to scope analysis, or ``None`` for the full piece.
199
200 Returns:
201 A :class:`ContourResult` describing shape, tessitura, angularity,
202 phrase statistics, and provenance metadata.
203 """
204 branch, head_sha = await _resolve_head(root)
205 resolved_commit = commit or (head_sha[:8] if head_sha else "HEAD")
206
207 return ContourResult(
208 shape=_STUB_SHAPE,
209 tessitura=_STUB_TESSITURA,
210 avg_interval=_STUB_AVG_INTERVAL,
211 phrase_count=_STUB_PHRASE_COUNT,
212 avg_phrase_bars=_STUB_AVG_PHRASE_BARS,
213 commit=resolved_commit,
214 branch=branch,
215 track=track or "all",
216 section=section or "all",
217 source="stub",
218 )
219
220
221 async def _contour_compare_async(
222 *,
223 root: pathlib.Path,
224 session: AsyncSession,
225 commit_a: Optional[str],
226 commit_b: str,
227 track: Optional[str],
228 section: Optional[str],
229 ) -> ContourCompareResult:
230 """Compare melodic contour between two commits.
231
232 Stub implementation: both sides share the same placeholder metric values,
233 so shape_changed is always False and deltas are always 0. Full implementation
234 will load per-commit pitch trajectories from Storpheus.
235
236 Args:
237 root: Repository root.
238 session: Open async DB session.
239 commit_a: First commit ref (defaults to HEAD when ``None``).
240 commit_b: Reference commit ref to compare against.
241 track: Named MIDI track, or ``None`` for all.
242 section: Named section, or ``None`` for the full piece.
243
244 Returns:
245 A :class:`ContourCompareResult` with both sides and diff metrics.
246 """
247 result_a = await _contour_detect_async(
248 root=root, session=session, commit=commit_a, track=track, section=section
249 )
250 result_b = await _contour_detect_async(
251 root=root, session=session, commit=commit_b, track=track, section=section
252 )
253 result_b = ContourResult(**{**result_b, "commit": commit_b})
254
255 return ContourCompareResult(
256 commit_a=result_a,
257 commit_b=result_b,
258 shape_changed=result_a["shape"] != result_b["shape"],
259 angularity_delta=round(result_a["avg_interval"] - result_b["avg_interval"], 4),
260 tessitura_delta=result_a["tessitura"] - result_b["tessitura"],
261 )
262
263
264 async def _contour_history_async(
265 *,
266 root: pathlib.Path,
267 session: AsyncSession,
268 track: Optional[str],
269 section: Optional[str],
270 ) -> list[ContourResult]:
271 """Return the contour history for the current branch.
272
273 Stub implementation: returns a single entry for HEAD. Full implementation
274 will walk the commit chain and return one :class:`ContourResult` per commit,
275 newest first.
276
277 Args:
278 root: Repository root.
279 session: Open async DB session.
280 track: Named MIDI track, or ``None`` for all.
281 section: Named section, or ``None`` for the full piece.
282
283 Returns:
284 List of :class:`ContourResult` entries, newest first.
285 """
286 entry = await _contour_detect_async(
287 root=root, session=session, commit=None, track=track, section=section
288 )
289 return [entry]
290
291
292 # ---------------------------------------------------------------------------
293 # Output formatters
294 # ---------------------------------------------------------------------------
295
296
297 def _format_detect(result: ContourResult, *, as_json: bool, shape_only: bool) -> str:
298 """Render a detect result as human-readable text or JSON.
299
300 When *shape_only* is True, only the shape label line is printed (useful
301 for quick scripting). When *as_json* is True, the full :class:`ContourResult`
302 is serialised as indented JSON.
303
304 Args:
305 result: Analysis result to render.
306 as_json: Emit JSON instead of human-readable text.
307 shape_only: Emit the shape label line only (ignored when as_json=True).
308
309 Returns:
310 Formatted string ready for ``typer.echo``.
311 """
312 if as_json:
313 return json.dumps(dict(result), indent=2)
314 if shape_only:
315 return f"Shape: {result['shape']}"
316 octaves = result["tessitura"] // 12
317 semitones_rem = result["tessitura"] % 12
318 range_str = f"{octaves} octave{'s' if octaves != 1 else ''}"
319 if semitones_rem:
320 range_str += f" + {semitones_rem} st"
321 lines = [
322 f"Shape: {result['shape']} | Range: {range_str} | "
323 f"Phrases: {result['phrase_count']} avg {result['avg_phrase_bars']:.0f} bars",
324 f"Commit: {result['commit']} Branch: {result['branch']}",
325 f"Track: {result['track']} Section: {result['section']}",
326 f"Angularity: {result['avg_interval']} st avg interval",
327 ]
328 if result.get("source") == "stub":
329 lines.append("(stub — full MIDI analysis pending)")
330 return "\n".join(lines)
331
332
333 def _format_compare(result: ContourCompareResult, *, as_json: bool) -> str:
334 """Render a compare result as human-readable text or JSON.
335
336 Args:
337 result: Comparison result to render.
338 as_json: Emit JSON instead of human-readable text.
339
340 Returns:
341 Formatted string ready for ``typer.echo``.
342 """
343 if as_json:
344 return json.dumps(
345 {
346 "commit_a": dict(result["commit_a"]),
347 "commit_b": dict(result["commit_b"]),
348 "shape_changed": result["shape_changed"],
349 "angularity_delta": result["angularity_delta"],
350 "tessitura_delta": result["tessitura_delta"],
351 },
352 indent=2,
353 )
354 a = result["commit_a"]
355 b = result["commit_b"]
356 ang_delta = result["angularity_delta"]
357 sign = "+" if ang_delta >= 0 else ""
358 shape_note = " (shape changed)" if result["shape_changed"] else ""
359 return (
360 f"A ({a['commit']}) Shape: {a['shape']} | Angularity: {a['avg_interval']} st\n"
361 f"B ({b['commit']}) Shape: {b['shape']} | Angularity: {b['avg_interval']} st\n"
362 f"Delta angularity {sign}{ang_delta} st | tessitura {result['tessitura_delta']:+d} st"
363 + shape_note
364 )
365
366
367 def _format_history(entries: list[ContourResult], *, as_json: bool) -> str:
368 """Render a contour history list as human-readable text or JSON.
369
370 Args:
371 entries: History entries, newest first.
372 as_json: Emit JSON instead of human-readable text.
373
374 Returns:
375 Formatted string ready for ``typer.echo``.
376 """
377 if as_json:
378 return json.dumps([dict(e) for e in entries], indent=2)
379 if not entries:
380 return "(no contour history found)"
381 lines: list[str] = []
382 for entry in entries:
383 lines.append(
384 f"{entry['commit']} {entry['shape']} | "
385 f"range {entry['tessitura']} st | "
386 f"ang {entry['avg_interval']} st"
387 + (f" [{entry['track']}]" if entry["track"] != "all" else "")
388 )
389 return "\n".join(lines)
390
391
392 # ---------------------------------------------------------------------------
393 # Typer command
394 # ---------------------------------------------------------------------------
395
396
397 @app.callback(invoke_without_command=True)
398 def contour(
399 ctx: typer.Context,
400 commit: Annotated[
401 Optional[str],
402 typer.Argument(
403 help="Commit SHA to analyse. Defaults to HEAD.",
404 show_default=False,
405 ),
406 ] = None,
407 track: Annotated[
408 Optional[str],
409 typer.Option(
410 "--track",
411 metavar="TEXT",
412 help="Restrict analysis to a named melodic track (e.g. 'keys', 'lead').",
413 show_default=False,
414 ),
415 ] = None,
416 section: Annotated[
417 Optional[str],
418 typer.Option(
419 "--section",
420 metavar="TEXT",
421 help="Scope analysis to a named section (e.g. 'verse', 'chorus').",
422 show_default=False,
423 ),
424 ] = None,
425 compare: Annotated[
426 Optional[str],
427 typer.Option(
428 "--compare",
429 metavar="COMMIT",
430 help="Compare contour against another commit.",
431 show_default=False,
432 ),
433 ] = None,
434 history: Annotated[
435 bool,
436 typer.Option("--history", help="Show contour evolution across all commits."),
437 ] = False,
438 shape: Annotated[
439 bool,
440 typer.Option("--shape", help="Print the overall shape label only."),
441 ] = False,
442 as_json: Annotated[
443 bool,
444 typer.Option("--json", help="Emit machine-readable JSON output."),
445 ] = False,
446 ) -> None:
447 """Analyze melodic contour and phrase shape for a composition commit.
448
449 With no flags, analyses HEAD and prints shape, range, phrase count, and
450 angularity. Use ``--compare`` to diff two commits, ``--history`` to see
451 how melodic character evolved, and ``--shape`` for a one-line shape label.
452 """
453 root = require_repo()
454
455 async def _run() -> None:
456 async with open_session() as session:
457 if history:
458 entries = await _contour_history_async(
459 root=root, session=session, track=track, section=section
460 )
461 typer.echo(_format_history(entries, as_json=as_json))
462 return
463
464 if compare is not None:
465 compare_result = await _contour_compare_async(
466 root=root,
467 session=session,
468 commit_a=commit,
469 commit_b=compare,
470 track=track,
471 section=section,
472 )
473 typer.echo(_format_compare(compare_result, as_json=as_json))
474 return
475
476 # Default: detect
477 detect_result = await _contour_detect_async(
478 root=root, session=session, commit=commit, track=track, section=section
479 )
480 typer.echo(_format_detect(detect_result, as_json=as_json, shape_only=shape))
481
482 try:
483 asyncio.run(_run())
484 except typer.Exit:
485 raise
486 except Exception as exc:
487 typer.echo(f"❌ muse contour failed: {exc}")
488 logger.error("❌ muse contour error: %s", exc, exc_info=True)
489 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)