cgcardona / muse public
key.py python
463 lines 13.4 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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)