11# ruff: noqa: E402
22
3+ import hashlib
34import json
45import multiprocessing
56import os
@@ -119,7 +120,9 @@ def copy_all_mscx_files(working_dir: str, dest: str) -> None:
119120 shutil .copyfile (source , os .path .join (dest , file ))
120121
121122
122- def create_formats (source_file : str , formats : list [str ]) -> list [dict [str , str ]]:
123+ def create_formats (
124+ source_file : str , formats : list [str ], style_file : str | None = None
125+ ) -> list [dict [str , str ]]:
123126 jobs : list [dict [str , str ]] = []
124127
125128 # sq8940236: MuseScore seems to hang up
@@ -152,10 +155,66 @@ def create_formats(source_file: str, formats: list[str]) -> list[dict[str, str]]
152155 "in" : source_file ,
153156 "out" : out_name ,
154157 }
158+ if style_file is not None :
159+ job ["style" ] = style_file
155160 jobs .append (job )
156161 return jobs
157162
158163
164+ # Every Lieder page is rendered by MuseScore with its default "Leland" engraving font,
165+ # so the model only ever sees one glyph vocabulary for noteheads/clefs/accidentals/etc.
166+ # MuseScore ships several other SMuFL-compliant engraving fonts (selectable in the app
167+ # under Format > Style > Score > Musical Symbols, backed by a swappable <musicalSymbolFont>
168+ # style setting); rotating through them per piece costs nothing at render time and gives
169+ # the model exposure to multiple glyph "handwritings" without needing a different dataset
170+ # or renderer. This only varies glyph shapes, not MuseScore's own layout/spacing engine -
171+ # so it doesn't substitute for training on genuinely different renderers (Primus,
172+ # grandstaff), just cheaply widens the glyph diversity within Lieder itself.
173+ _MUSIC_FONTS = ["Leland" , "Bravura" , "Petaluma" , "MuseJazz" , "Gonville" ]
174+ _music_font_style_dir = os .path .join (dataset_root , "MuseScoreStyles" )
175+
176+
177+ def _music_font_style_file (font : str ) -> str :
178+ return os .path .join (_music_font_style_dir , f"{ font .replace (' ' , '_' )} .mss" )
179+
180+
181+ def _ensure_music_font_style_files () -> None :
182+ """
183+ Writes one minimal .mss style file per font in _MUSIC_FONTS (skipping ones that
184+ already exist), each just pointing MuseScore's musical-symbol and musical-text
185+ fonts at a single named font pair - MuseScore fills in every other style default.
186+ The "<Font> Text" naming for the paired text font mirrors the font-pair names
187+ MuseScore itself uses for its bundled fonts.
188+ """
189+ os .makedirs (_music_font_style_dir , exist_ok = True )
190+ for font in _MUSIC_FONTS :
191+ path = _music_font_style_file (font )
192+ if os .path .exists (path ):
193+ continue
194+ content = (
195+ '<?xml version="1.0" encoding="UTF-8"?>\n '
196+ '<museScore version="4.00">\n '
197+ " <Style>\n "
198+ f" <musicalSymbolFont>{ font } </musicalSymbolFont>\n "
199+ f" <musicalTextFont>{ font } Text</musicalTextFont>\n "
200+ " </Style>\n "
201+ "</museScore>\n "
202+ )
203+ with open (path , "w" , encoding = "utf-8" ) as f :
204+ f .write (content )
205+
206+
207+ def _music_font_for_file (source_file : str ) -> str :
208+ """
209+ Deterministic per-piece font choice (stable across reruns/partial recreates, so a
210+ piece doesn't silently re-render with a different font the next time this is run)
211+ based on a hash of the piece's own filename, not path or run order.
212+ """
213+ stem = os .path .basename (source_file ).split ("." )[0 ]
214+ index = int (hashlib .sha256 (stem .encode ()).hexdigest (), 16 ) % len (_MUSIC_FONTS )
215+ return _MUSIC_FONTS [index ]
216+
217+
159218"""
160219In mscx file, tuplet can be set invisible via the following ways:
1612201. <Tuplet( id="...")>
@@ -243,6 +302,37 @@ def _make_staff_visible(mscx_file: str) -> None:
243302 f .write (new_content )
244303
245304
305+ def _reset_note_positions (mscx_file : str ) -> None :
306+ """
307+ A manually-dragged notehead in MuseScore leaves a <pos x=".." y=".."/> override
308+ as the first child of its <Note> element, which shifts where it renders without
309+ touching its <pitch>. This is rare (~0.015% of notes dataset-wide) but when it
310+ happens, the rendered position can silently disagree with the note's own pitch -
311+ e.g. in lc4926375 a repeated E5 renders on the D5 line because of a y="0.5" (one
312+ diatonic step) override, so the exported image shows a different note than the
313+ label says.
314+
315+ <pos> is also used pervasively elsewhere in this format for unrelated, legitimate
316+ purposes - slur/tie curve control points (nested under <Note><Tie><SlurSegment>),
317+ augmentation-dot offsets (<Note><NoteDot>), staff text, tempo marks, stems - so
318+ this only strips a <pos> that is directly the first thing inside <Note>, never one
319+ nested deeper. MuseScore also serializes an empty element as either a self-closing
320+ tag or a separate open/close pair depending on context, so both forms are matched.
321+ """
322+ with open (mscx_file , encoding = "utf-8" ) as f :
323+ content = f .read ()
324+
325+ new_content = re .sub (
326+ r"(<Note>\s*)<pos\b[^>]*(?:/>|>\s*</pos>)\s*" ,
327+ r"\1" ,
328+ content ,
329+ )
330+
331+ if new_content != content :
332+ with open (mscx_file , "w" , encoding = "utf-8" ) as f :
333+ f .write (new_content )
334+
335+
246336def _create_musicxml_and_svg_files () -> None :
247337 dest = os .path .join (lieder , "flat" )
248338 os .makedirs (dest , exist_ok = True )
@@ -252,12 +342,16 @@ def _create_musicxml_and_svg_files() -> None:
252342
253343 MuseScore = os .path .join (dataset_root , "MuseScore" )
254344
345+ _ensure_music_font_style_files ()
346+
255347 all_jobs = []
256348
257349 for file in mscx_files :
258350 _make_tuplet_visible (str (file ))
259351 _make_staff_visible (str (file ))
260- jobs = create_formats (str (file ), ["musicxml" , "svg" ])
352+ _reset_note_positions (str (file ))
353+ style_file = _music_font_style_file (_music_font_for_file (str (file )))
354+ jobs = create_formats (str (file ), ["musicxml" , "svg" ], style_file )
261355 all_jobs .extend (jobs )
262356
263357 if len (all_jobs ) == 0 :
0 commit comments