gabriel / musehub public
musehub_notation.py python
291 lines 8.6 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Muse Hub Notation Service — MIDI-to-standard-notation conversion.
2
3 Converts MIDI note data (as stored in Muse commits) into quantized, structured
4 notation data suitable for rendering as sheet music. The output is a typed
5 JSON payload consumed by the client-side SVG score renderer.
6
7 Why this exists
8 ---------------
9 Musicians who read traditional notation need to visualize Muse compositions as
10 sheet music without exporting to MusicXML and opening a separate application.
11 This service bridges the gap by producing quantized ``NotationResult`` data that
12 the score page renders directly in the browser.
13
14 Design decisions
15 ----------------
16 - Server-side quantization only. The browser renderer is intentionally thin
17 it receives pre-computed beat-aligned note data and draws SVG, it does not
18 re-quantize or re-interpret pitch.
19 - Deterministic stubs keyed on ``ref``. Full Storpheus MIDI introspection will
20 be wired in once the per-commit MIDI endpoint is stable. Until then, stub
21 data is musically realistic and internally consistent.
22 - No external I/O. This module is pure data — no database, no network calls,
23 no side effects. Route handlers inject all inputs.
24
25 Boundary rules
26 --------------
27 - Must NOT import StateStore, EntityRegistry, or executor modules.
28 - Must NOT import LLM handlers or maestro_* pipeline modules.
29 - Must NOT import Storpheus service directly (data flows via route params).
30 """
31 from __future__ import annotations
32
33 import hashlib
34 import logging
35 from typing import NamedTuple, TypedDict
36
37 logger = logging.getLogger(__name__)
38
39 # ---------------------------------------------------------------------------
40 # Public types
41 # ---------------------------------------------------------------------------
42
43
44 class NotationNote(TypedDict):
45 """A single quantized note ready for SVG rendering.
46
47 Fields are kept flat (no nested objects) to simplify JavaScript
48 destructuring on the client side.
49
50 pitch_name: e.g. "C", "F#", "Bb"
51 octave: MIDI octave number (4 = middle octave)
52 duration: note duration as a fraction string, e.g. "1/4", "1/8", "1/2"
53 start_beat: beat position (0-indexed from the start of the piece)
54 velocity: MIDI velocity 0–127
55 track_id: source track index (matches NotationTrack.track_id)
56 """
57
58 pitch_name: str
59 octave: int
60 duration: str
61 start_beat: float
62 velocity: int
63 track_id: int
64
65
66 class NotationTrack(TypedDict):
67 """One instrument part, with clef/key/time signature metadata."""
68
69 track_id: int
70 clef: str
71 key_signature: str
72 time_signature: str
73 instrument: str
74 notes: list[NotationNote]
75
76
77 class NotationResult(NamedTuple):
78 """Typed result returned by ``convert_ref_to_notation``.
79
80 Attributes
81 ----------
82 tracks:
83 List of ``NotationTrack`` dicts, one per instrument part. Each track
84 includes clef, key_signature, time_signature, and a list of
85 ``NotationNote`` dicts ordered by start_beat.
86 tempo:
87 BPM as a positive integer.
88 key:
89 Key signature string, e.g. ``"C major"``, ``"F# minor"``.
90 time_sig:
91 Time signature string, e.g. ``"4/4"``, ``"3/4"``, ``"6/8"``.
92 """
93
94 tracks: list[NotationTrack]
95 tempo: int
96 key: str
97 time_sig: str
98
99
100 # ---------------------------------------------------------------------------
101 # Internal helpers
102 # ---------------------------------------------------------------------------
103
104 _PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
105
106 _KEY_POOL = [
107 "C major",
108 "G major",
109 "D major",
110 "A major",
111 "F major",
112 "Bb major",
113 "Eb major",
114 "A minor",
115 "D minor",
116 "E minor",
117 "B minor",
118 "G minor",
119 ]
120
121 _CLEF_MAP = {
122 "bass": "bass",
123 "piano": "treble",
124 "keys": "treble",
125 "guitar": "treble",
126 "strings": "treble",
127 "violin": "treble",
128 "cello": "bass",
129 "trumpet": "treble",
130 "sax": "treble",
131 "default": "treble",
132 }
133
134 _TIME_SIGS = ["4/4", "3/4", "6/8", "2/4"]
135 _DURATIONS = ["1/4", "1/4", "1/4", "1/8", "1/8", "1/2", "1/4", "1/4"]
136 _ROLE_NAMES = ["piano", "bass", "guitar", "strings", "trumpet"]
137
138
139 def _seed_from_ref(ref: str) -> int:
140 """Derive a deterministic integer seed from a commit ref string."""
141 digest = hashlib.sha256(ref.encode()).digest()
142 return int.from_bytes(digest[:4], "big")
143
144
145 def _lcg(seed: int) -> int:
146 """Minimal linear congruential generator step — returns updated state."""
147 return (seed * 1664525 + 1013904223) & 0xFFFFFFFF
148
149
150 def _notes_for_track(
151 seed: int,
152 track_idx: int,
153 time_sig: str,
154 num_bars: int,
155 ) -> list[NotationNote]:
156 """Generate a list of quantized notation notes for one track.
157
158 Uses a seeded pseudo-random sequence so that the same ref always produces
159 the same notes. The quantization grid matches the time signature — quarter
160 notes for 4/4 and 3/4, eighth notes for 6/8.
161 """
162 beats_per_bar, _ = (int(x) for x in time_sig.split("/"))
163 notes: list[NotationNote] = []
164
165 s = seed ^ (track_idx * 0xDEAD)
166 for bar in range(num_bars):
167 beat = 0.0
168 while beat < beats_per_bar:
169 s = _lcg(s)
170 # 30 % chance of a rest — skip this beat slot
171 if (s % 10) < 3:
172 beat += 1.0
173 continue
174 s = _lcg(s)
175 pitch_idx = s % 12
176 s = _lcg(s)
177 octave = 3 + (s % 3) # octaves 3, 4, 5
178 s = _lcg(s)
179 dur_idx = s % len(_DURATIONS)
180 duration = _DURATIONS[dur_idx]
181 s = _lcg(s)
182 velocity = 60 + (s % 60)
183
184 notes.append(
185 NotationNote(
186 pitch_name=_PITCH_NAMES[pitch_idx],
187 octave=int(octave),
188 duration=duration,
189 start_beat=float(bar * beats_per_bar + beat),
190 velocity=int(velocity),
191 track_id=track_idx,
192 )
193 )
194 # Advance beat by the duration value (quarter = 1, eighth = 0.5, half = 2)
195 num, denom = (int(x) for x in duration.split("/"))
196 beat_advance = 4.0 * num / denom
197 beat += beat_advance
198
199 return notes
200
201
202 # ---------------------------------------------------------------------------
203 # Public API
204 # ---------------------------------------------------------------------------
205
206
207 def convert_ref_to_notation(
208 ref: str,
209 num_tracks: int = 3,
210 num_bars: int = 8,
211 ) -> NotationResult:
212 """Convert a Muse commit ref to quantized notation data.
213
214 Returns a ``NotationResult`` containing typed track data ready for the
215 client-side SVG score renderer.
216
217 Parameters
218 ----------
219 ref:
220 Muse commit ref (branch name, tag, or commit SHA). Used as a seed so
221 that the same ref always returns the same notation.
222 num_tracks:
223 Number of instrument tracks to generate. Clamped to [1, 8].
224 num_bars:
225 Number of bars of music to generate per track. Clamped to [1, 32].
226 """
227 num_tracks = max(1, min(8, num_tracks))
228 num_bars = max(1, min(32, num_bars))
229
230 seed = _seed_from_ref(ref)
231
232 key_idx = seed % len(_KEY_POOL)
233 key = _KEY_POOL[key_idx]
234
235 ts_seed = _lcg(seed)
236 time_sig = _TIME_SIGS[ts_seed % len(_TIME_SIGS)]
237
238 tempo_seed = _lcg(ts_seed)
239 tempo = 80 + int(tempo_seed % 80)
240
241 tracks: list[NotationTrack] = []
242 for i in range(num_tracks):
243 role = _ROLE_NAMES[i % len(_ROLE_NAMES)]
244 clef = _CLEF_MAP.get(role, _CLEF_MAP["default"])
245 notes = _notes_for_track(seed, i, time_sig, num_bars)
246 tracks.append(
247 NotationTrack(
248 track_id=i,
249 clef=clef,
250 key_signature=key,
251 time_signature=time_sig,
252 instrument=role,
253 notes=notes,
254 )
255 )
256
257 logger.debug("✅ notation: ref=%s tracks=%d bars=%d tempo=%d", ref, num_tracks, num_bars, tempo)
258 return NotationResult(tracks=tracks, tempo=tempo, key=key, time_sig=time_sig)
259
260
261 class NotationDict(TypedDict):
262 """Serialized form of ``NotationResult`` for JSON API responses."""
263
264 tracks: list[NotationTrack]
265 tempo: int
266 key: str
267 timeSig: str
268
269
270 def notation_result_to_dict(result: NotationResult) -> NotationDict:
271 """Serialise a ``NotationResult`` to a typed dict for JSON responses.
272
273 The output shape is:
274 ```json
275 {
276 "tracks": [...],
277 "tempo": 120,
278 "key": "C major",
279 "timeSig": "4/4"
280 }
281 ```
282
283 Note: ``time_sig`` is camelCase ``timeSig`` in the JSON output to match
284 the JavaScript convention used by all other MuseHub API responses.
285 """
286 return NotationDict(
287 tracks=result.tracks,
288 tempo=result.tempo,
289 key=result.key,
290 timeSig=result.time_sig,
291 )