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