key.py
python
| 1 | """muse key — read or annotate the musical key of a commit. |
| 2 | |
| 3 | Key (tonal center) is the most fundamental property of a piece of music. |
| 4 | This command provides auto-detection from MIDI content, explicit annotation, |
| 5 | relative-key display, and a history view showing how the key evolved across |
| 6 | commits. |
| 7 | |
| 8 | Command forms |
| 9 | ------------- |
| 10 | |
| 11 | Detect the key of HEAD (default):: |
| 12 | |
| 13 | muse key |
| 14 | |
| 15 | Detect the key of a specific commit:: |
| 16 | |
| 17 | muse key a1b2c3d4 |
| 18 | |
| 19 | Annotate HEAD with an explicit key:: |
| 20 | |
| 21 | muse key --set "F# minor" |
| 22 | |
| 23 | Detect key from a specific instrument track:: |
| 24 | |
| 25 | muse key --track bass |
| 26 | |
| 27 | Show the relative key as well:: |
| 28 | |
| 29 | muse key --relative |
| 30 | |
| 31 | Show how the key changed across all commits:: |
| 32 | |
| 33 | muse key --history |
| 34 | |
| 35 | Machine-readable JSON output:: |
| 36 | |
| 37 | muse key --json |
| 38 | |
| 39 | Key Format Convention |
| 40 | --------------------- |
| 41 | |
| 42 | Keys are expressed as ``<tonic> <mode>`` where mode is one of ``major`` or |
| 43 | ``minor``. Tonic uses standard Western note names with ``#`` for sharp and |
| 44 | ``b`` for flat: |
| 45 | |
| 46 | C major, D minor, Eb major, F# minor, Bb major, C# minor |
| 47 | |
| 48 | The relative major of a minor key is a minor third above the tonic; the |
| 49 | relative minor of a major key is a minor third below. |
| 50 | """ |
| 51 | from __future__ import annotations |
| 52 | |
| 53 | import asyncio |
| 54 | import json |
| 55 | import logging |
| 56 | import pathlib |
| 57 | from typing import Optional |
| 58 | |
| 59 | import typer |
| 60 | from sqlalchemy.ext.asyncio import AsyncSession |
| 61 | from typing_extensions import Annotated, TypedDict |
| 62 | |
| 63 | from maestro.muse_cli._repo import require_repo |
| 64 | from maestro.muse_cli.db import open_session |
| 65 | from maestro.muse_cli.errors import ExitCode |
| 66 | |
| 67 | logger = logging.getLogger(__name__) |
| 68 | |
| 69 | app = typer.Typer() |
| 70 | |
| 71 | # --------------------------------------------------------------------------- |
| 72 | # Key vocabulary |
| 73 | # --------------------------------------------------------------------------- |
| 74 | |
| 75 | # Chromatic tonic names in order (sharps preferred for majors, flats for minors |
| 76 | # where convention dictates, but the stub uses a fixed default). |
| 77 | _VALID_TONICS: frozenset[str] = frozenset( |
| 78 | [ |
| 79 | "C", "C#", "Db", "D", "D#", "Eb", "E", "F", |
| 80 | "F#", "Gb", "G", "G#", "Ab", "A", "A#", "Bb", "B", |
| 81 | ] |
| 82 | ) |
| 83 | |
| 84 | _VALID_MODES: frozenset[str] = frozenset(["major", "minor"]) |
| 85 | |
| 86 | # Semitones from a minor tonic to its relative major tonic. |
| 87 | _RELATIVE_MAJOR_OFFSET = 3 |
| 88 | |
| 89 | # Chromatic scale (sharps) for enharmonic arithmetic. |
| 90 | _CHROMATIC: tuple[str, ...] = ( |
| 91 | "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" |
| 92 | ) |
| 93 | |
| 94 | # Enharmonic equivalents (flat → sharp index). |
| 95 | _ENHARMONIC: dict[str, str] = { |
| 96 | "Db": "C#", "Eb": "D#", "Gb": "F#", "Ab": "G#", "Bb": "A#", |
| 97 | } |
| 98 | |
| 99 | # Stub default — a realistic placeholder. |
| 100 | _STUB_KEY = "C major" |
| 101 | _STUB_TONIC = "C" |
| 102 | _STUB_MODE = "major" |
| 103 | |
| 104 | |
| 105 | # --------------------------------------------------------------------------- |
| 106 | # Named result types (stable CLI contract) |
| 107 | # --------------------------------------------------------------------------- |
| 108 | |
| 109 | |
| 110 | class KeyDetectResult(TypedDict): |
| 111 | """Key detection result for a single commit or working tree. |
| 112 | |
| 113 | Fields |
| 114 | ------ |
| 115 | key: Full key string, e.g. ``"F# minor"``. |
| 116 | tonic: Root note, e.g. ``"F#"``. |
| 117 | mode: ``"major"`` or ``"minor"``. |
| 118 | relative: Relative key string, e.g. ``"A major"`` (empty when not requested). |
| 119 | commit: Short commit SHA. |
| 120 | branch: Current branch name. |
| 121 | track: Track the key was analysed from (``"all"`` if no filter applied). |
| 122 | source: ``"stub"`` | ``"annotation"`` | ``"detected"``. |
| 123 | """ |
| 124 | |
| 125 | key: str |
| 126 | tonic: str |
| 127 | mode: str |
| 128 | relative: str |
| 129 | commit: str |
| 130 | branch: str |
| 131 | track: str |
| 132 | source: str |
| 133 | |
| 134 | |
| 135 | class KeyHistoryEntry(TypedDict): |
| 136 | """One row in a ``muse key --history`` listing.""" |
| 137 | |
| 138 | commit: str |
| 139 | key: str |
| 140 | tonic: str |
| 141 | mode: str |
| 142 | source: str |
| 143 | |
| 144 | |
| 145 | # --------------------------------------------------------------------------- |
| 146 | # Key helpers |
| 147 | # --------------------------------------------------------------------------- |
| 148 | |
| 149 | |
| 150 | def parse_key(key_str: str) -> tuple[str, str]: |
| 151 | """Parse a key string into ``(tonic, mode)``. |
| 152 | |
| 153 | Accepts ``"<tonic> <mode>"`` strings with case-insensitive mode. |
| 154 | |
| 155 | Args: |
| 156 | key_str: Key string such as ``"F# minor"`` or ``"Eb major"``. |
| 157 | |
| 158 | Returns: |
| 159 | Tuple of ``(tonic, mode)`` both in canonical capitalisation. |
| 160 | |
| 161 | Raises: |
| 162 | ValueError: If the tonic or mode is not recognised. |
| 163 | """ |
| 164 | parts = key_str.strip().split() |
| 165 | if len(parts) != 2: |
| 166 | raise ValueError( |
| 167 | f"Key must be '<tonic> <mode>', got {key_str!r}. " |
| 168 | "Example: 'F# minor', 'Eb major'." |
| 169 | ) |
| 170 | tonic, mode = parts[0], parts[1].lower() |
| 171 | if tonic not in _VALID_TONICS: |
| 172 | raise ValueError( |
| 173 | f"Unknown tonic {tonic!r}. Valid tonics: " |
| 174 | + ", ".join(sorted(_VALID_TONICS)) |
| 175 | ) |
| 176 | if mode not in _VALID_MODES: |
| 177 | raise ValueError( |
| 178 | f"Unknown mode {mode!r}. Valid modes: major, minor." |
| 179 | ) |
| 180 | return tonic, mode |
| 181 | |
| 182 | |
| 183 | def relative_key(tonic: str, mode: str) -> str: |
| 184 | """Return the relative key for *tonic* + *mode*. |
| 185 | |
| 186 | The relative major of a minor key is 3 semitones above its tonic. |
| 187 | The relative minor of a major key is 3 semitones below its tonic. |
| 188 | |
| 189 | Args: |
| 190 | tonic: Root note, e.g. ``"A"``. |
| 191 | mode: ``"major"`` or ``"minor"``. |
| 192 | |
| 193 | Returns: |
| 194 | Relative key string, e.g. ``"C major"`` for ``"A minor"``. |
| 195 | """ |
| 196 | canonical = _ENHARMONIC.get(tonic, tonic) |
| 197 | try: |
| 198 | idx = _CHROMATIC.index(canonical) |
| 199 | except ValueError: |
| 200 | return "" |
| 201 | |
| 202 | if mode == "minor": |
| 203 | rel_idx = (idx + _RELATIVE_MAJOR_OFFSET) % 12 |
| 204 | rel_tonic = _CHROMATIC[rel_idx] |
| 205 | return f"{rel_tonic} major" |
| 206 | else: |
| 207 | rel_idx = (idx - _RELATIVE_MAJOR_OFFSET) % 12 |
| 208 | rel_tonic = _CHROMATIC[rel_idx] |
| 209 | return f"{rel_tonic} minor" |
| 210 | |
| 211 | |
| 212 | # --------------------------------------------------------------------------- |
| 213 | # Testable async core |
| 214 | # --------------------------------------------------------------------------- |
| 215 | |
| 216 | |
| 217 | async def _key_detect_async( |
| 218 | *, |
| 219 | root: pathlib.Path, |
| 220 | session: AsyncSession, |
| 221 | commit: Optional[str], |
| 222 | track: Optional[str], |
| 223 | show_relative: bool, |
| 224 | ) -> KeyDetectResult: |
| 225 | """Detect the musical key for a commit (or the working tree). |
| 226 | |
| 227 | Stub implementation returning a realistic placeholder in the correct schema. |
| 228 | Full MIDI-based analysis will be wired in once the Storpheus inference |
| 229 | endpoint exposes a key detection route. |
| 230 | |
| 231 | Args: |
| 232 | root: Repository root (directory containing ``.muse/``). |
| 233 | session: Open async DB session (reserved for full implementation). |
| 234 | commit: Commit SHA to analyse, or ``None`` for HEAD. |
| 235 | track: Restrict analysis to a named MIDI track, or ``None`` for all. |
| 236 | show_relative: If True, populate the ``relative`` field. |
| 237 | |
| 238 | Returns: |
| 239 | A :class:`KeyDetectResult` with ``key``, ``tonic``, ``mode``, |
| 240 | ``relative``, ``commit``, ``branch``, ``track``, and ``source``. |
| 241 | """ |
| 242 | muse_dir = root / ".muse" |
| 243 | head_path = muse_dir / "HEAD" |
| 244 | head_ref = head_path.read_text().strip() |
| 245 | branch = head_ref.rsplit("/", 1)[-1] if "/" in head_ref else head_ref |
| 246 | |
| 247 | ref_path = muse_dir / pathlib.Path(head_ref) |
| 248 | head_sha = ref_path.read_text().strip() if ref_path.exists() else "0000000" |
| 249 | resolved_commit = commit or (head_sha[:8] if head_sha else "HEAD") |
| 250 | |
| 251 | rel = relative_key(_STUB_TONIC, _STUB_MODE) if show_relative else "" |
| 252 | |
| 253 | return KeyDetectResult( |
| 254 | key=_STUB_KEY, |
| 255 | tonic=_STUB_TONIC, |
| 256 | mode=_STUB_MODE, |
| 257 | relative=rel, |
| 258 | commit=resolved_commit, |
| 259 | branch=branch, |
| 260 | track=track or "all", |
| 261 | source="stub", |
| 262 | ) |
| 263 | |
| 264 | |
| 265 | async def _key_history_async( |
| 266 | *, |
| 267 | root: pathlib.Path, |
| 268 | session: AsyncSession, |
| 269 | track: Optional[str], |
| 270 | ) -> list[KeyHistoryEntry]: |
| 271 | """Return the key history for the current branch. |
| 272 | |
| 273 | Stub implementation returning a single placeholder entry. Full |
| 274 | implementation will walk the commit chain and aggregate key annotations |
| 275 | stored per-commit. |
| 276 | |
| 277 | Args: |
| 278 | root: Repository root. |
| 279 | session: Open async DB session. |
| 280 | track: Restrict to a named MIDI track, or ``None`` for all. |
| 281 | |
| 282 | Returns: |
| 283 | List of :class:`KeyHistoryEntry` entries, newest first. |
| 284 | """ |
| 285 | entry = await _key_detect_async( |
| 286 | root=root, |
| 287 | session=session, |
| 288 | commit=None, |
| 289 | track=track, |
| 290 | show_relative=False, |
| 291 | ) |
| 292 | return [ |
| 293 | KeyHistoryEntry( |
| 294 | commit=entry["commit"], |
| 295 | key=entry["key"], |
| 296 | tonic=entry["tonic"], |
| 297 | mode=entry["mode"], |
| 298 | source=entry["source"], |
| 299 | ) |
| 300 | ] |
| 301 | |
| 302 | |
| 303 | # --------------------------------------------------------------------------- |
| 304 | # Output formatters |
| 305 | # --------------------------------------------------------------------------- |
| 306 | |
| 307 | |
| 308 | def _format_detect(result: KeyDetectResult, *, as_json: bool) -> str: |
| 309 | """Render a detect result as human-readable text or JSON.""" |
| 310 | if as_json: |
| 311 | return json.dumps(dict(result), indent=2) |
| 312 | lines = [ |
| 313 | f"Key: {result['key']}", |
| 314 | f"Commit: {result['commit']} Branch: {result['branch']}", |
| 315 | f"Track: {result['track']}", |
| 316 | ] |
| 317 | if result.get("relative"): |
| 318 | lines.append(f"Relative: {result['relative']}") |
| 319 | if result.get("source") == "stub": |
| 320 | lines.append("(stub — full MIDI key detection pending)") |
| 321 | elif result.get("source") == "annotation": |
| 322 | lines.append("(explicitly annotated)") |
| 323 | return "\n".join(lines) |
| 324 | |
| 325 | |
| 326 | def _format_history( |
| 327 | entries: list[KeyHistoryEntry], *, as_json: bool |
| 328 | ) -> str: |
| 329 | """Render a history list as human-readable text or JSON.""" |
| 330 | if as_json: |
| 331 | return json.dumps([dict(e) for e in entries], indent=2) |
| 332 | lines: list[str] = [] |
| 333 | for entry in entries: |
| 334 | src = f" [{entry['source']}]" if entry.get("source") != "stub" else "" |
| 335 | lines.append(f"{entry['commit']} {entry['key']}{src}") |
| 336 | return "\n".join(lines) if lines else "(no key history found)" |
| 337 | |
| 338 | |
| 339 | # --------------------------------------------------------------------------- |
| 340 | # Typer command |
| 341 | # --------------------------------------------------------------------------- |
| 342 | |
| 343 | |
| 344 | @app.callback(invoke_without_command=True) |
| 345 | def key( |
| 346 | ctx: typer.Context, |
| 347 | commit: Annotated[ |
| 348 | Optional[str], |
| 349 | typer.Argument( |
| 350 | help="Commit SHA to analyse. Defaults to HEAD.", |
| 351 | show_default=False, |
| 352 | ), |
| 353 | ] = None, |
| 354 | set_key: Annotated[ |
| 355 | Optional[str], |
| 356 | typer.Option( |
| 357 | "--set", |
| 358 | metavar="KEY", |
| 359 | help=( |
| 360 | "Annotate the working tree with an explicit key " |
| 361 | "(e.g. 'F# minor', 'Eb major')." |
| 362 | ), |
| 363 | show_default=False, |
| 364 | ), |
| 365 | ] = None, |
| 366 | detect: Annotated[ |
| 367 | bool, |
| 368 | typer.Option( |
| 369 | "--detect", |
| 370 | help="Detect and display the key (default when no other flag given).", |
| 371 | ), |
| 372 | ] = True, |
| 373 | track: Annotated[ |
| 374 | Optional[str], |
| 375 | typer.Option( |
| 376 | "--track", |
| 377 | metavar="TEXT", |
| 378 | help="Detect key from a specific instrument track only.", |
| 379 | show_default=False, |
| 380 | ), |
| 381 | ] = None, |
| 382 | show_relative: Annotated[ |
| 383 | bool, |
| 384 | typer.Option( |
| 385 | "--relative", |
| 386 | help="Show the relative key as well (e.g. 'Eb major / C minor').", |
| 387 | ), |
| 388 | ] = False, |
| 389 | history: Annotated[ |
| 390 | bool, |
| 391 | typer.Option( |
| 392 | "--history", |
| 393 | help="Show how the key changed across all commits (key map over time).", |
| 394 | ), |
| 395 | ] = False, |
| 396 | as_json: Annotated[ |
| 397 | bool, |
| 398 | typer.Option("--json", help="Emit machine-readable JSON output."), |
| 399 | ] = False, |
| 400 | ) -> None: |
| 401 | """Read or annotate the musical key of a commit. |
| 402 | |
| 403 | With no flags, detects and displays the tonal center for HEAD. |
| 404 | Use ``--set`` to persist an explicit key annotation. |
| 405 | Use ``--history`` to see how the key evolved across all commits. |
| 406 | """ |
| 407 | root = require_repo() |
| 408 | |
| 409 | # --set validation |
| 410 | if set_key is not None: |
| 411 | try: |
| 412 | set_tonic, set_mode = parse_key(set_key) |
| 413 | except ValueError as exc: |
| 414 | typer.echo(f"❌ {exc}") |
| 415 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 416 | |
| 417 | rel = relative_key(set_tonic, set_mode) if show_relative else "" |
| 418 | annotation: KeyDetectResult = KeyDetectResult( |
| 419 | key=f"{set_tonic} {set_mode}", |
| 420 | tonic=set_tonic, |
| 421 | mode=set_mode, |
| 422 | relative=rel, |
| 423 | commit="", |
| 424 | branch="", |
| 425 | track=track or "all", |
| 426 | source="annotation", |
| 427 | ) |
| 428 | if as_json: |
| 429 | typer.echo(json.dumps(dict(annotation), indent=2)) |
| 430 | else: |
| 431 | rel_part = f" (relative: {rel})" if rel else "" |
| 432 | typer.echo( |
| 433 | f"✅ Key annotated: {set_tonic} {set_mode}{rel_part}" |
| 434 | + (f" track={track}" if track else "") |
| 435 | ) |
| 436 | return |
| 437 | |
| 438 | async def _run() -> None: |
| 439 | async with open_session() as session: |
| 440 | if history: |
| 441 | entries = await _key_history_async( |
| 442 | root=root, session=session, track=track |
| 443 | ) |
| 444 | typer.echo(_format_history(entries, as_json=as_json)) |
| 445 | return |
| 446 | |
| 447 | detect_result = await _key_detect_async( |
| 448 | root=root, |
| 449 | session=session, |
| 450 | commit=commit, |
| 451 | track=track, |
| 452 | show_relative=show_relative, |
| 453 | ) |
| 454 | typer.echo(_format_detect(detect_result, as_json=as_json)) |
| 455 | |
| 456 | try: |
| 457 | asyncio.run(_run()) |
| 458 | except typer.Exit: |
| 459 | raise |
| 460 | except Exception as exc: |
| 461 | typer.echo(f"❌ muse key failed: {exc}") |
| 462 | logger.error("❌ muse key error: %s", exc, exc_info=True) |
| 463 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |