vocal-render / src /vocalrender /utils /score_rendering.py
multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
5ed07ee verified
Raw
History Blame Contribute Delete
21.8 kB
"""
SVS Score Rendering — Staff Notation PNG via hand-built LilyPond
Accepts structured metadata (bpm, words, pitches, notes, pitch2word) from
the preprocessed Arrow dataset and renders staff notation images. No SVS
prompt string parsing is needed.
Design goal: **faithfully** mirror the source annotation, even when it is
musically inconsistent. The note glyph is decided by ``pitch`` alone and
the lyric by ``word`` alone — the two are decoupled:
* pitch > 0 → a pitched note at that MIDI value.
* pitch == 0 → a rest glyph.
* real word (non SP/AP)→ its lyric is shown.
* "SP"/"AP" / empty → blank lyric line.
So a real syllable annotated on a 0-pitch segment renders as a *rest with
its lyric in the lyric line directly beneath it*, and an "SP" annotated on
a real pitch renders as a *note with a blank lyric* — neither is silently
dropped. This is intentional for visual inspection of the raw labels.
LilyPond's ``\\lyricsto`` skips rests, so it can't place a syllable under a
rest. Instead the lyric line is a timed ``\\lyricmode`` (one duration-tagged
token per segment — note *and* rest) that aligns to the staff by musical
moment, so a lyric sits under its own rest at the normal lyric baseline.
**Melisma slurs.** When one syllable is sung across several notes (a melisma —
``pitch2word`` maps multiple note segments to the same word index), those notes
are joined with a **slur** (连音线), the standard vocal-notation mark for "one
syllable, connected pitches". The slur spans from the first to the last *pitched*
note of each same-word run (runs with a single pitched note get none). Toggle
with ``draw_slurs`` (default on).
The key signature is auto-detected from the pitch distribution
(Krumhansl-Kessler profile correlation) so accidentals are spelled
correctly (flats vs sharps). Pass ``key_root`` to override.
"""
import os
from typing import List, Dict, Tuple, Optional
from concurrent.futures import ProcessPoolExecutor, as_completed
# ──────────────────────────────────────────────────────────────
# Duration mapping: SVS note token → LilyPond duration string
# ──────────────────────────────────────────────────────────────
_NOTE_TOKEN_TO_LY = {
"<NOTE_1>": "1",
"<NOTE_DOT_1>": "1.",
"<NOTE_2>": "2",
"<NOTE_DOT_2>": "2.",
"<NOTE_4>": "4",
"<NOTE_DOT_4>": "4.",
"<NOTE_8>": "8",
"<NOTE_DOT_8>": "8.",
"<NOTE_16>": "16",
"<NOTE_DOT_16>": "16.",
"<NOTE_32>": "32",
"<NOTE_DOT_32>": "32.",
}
# ──────────────────────────────────────────────────────────────
# Key detection (Krumhansl-Kessler) and key-aware spelling
# ──────────────────────────────────────────────────────────────
# Krumhansl-Kessler major key profile (perceptual weights)
_KK_MAJOR_PROFILE = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09,
2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
# Sharp keys: C, G, D, A, E, B (pitch classes 0, 7, 2, 9, 4, 11)
# Flat keys: F, Bb, Eb, Ab, Db, Gb (pitch classes 5, 10, 3, 8, 1, 6)
_FLAT_KEY_ROOTS = {5, 10, 3, 8, 1, 6}
# Pitch class → LilyPond note name (Dutch: cis=C#, des=Db, …). Flat keys use
# flat spelling so accidentals match the key signature.
_LY_NAMES_SHARP = ["c", "cis", "d", "dis", "e", "f", "fis", "g", "gis", "a", "ais", "b"]
_LY_NAMES_FLAT = ["c", "des", "d", "ees", "e", "f", "ges", "g", "aes", "a", "bes", "b"]
# Pitch class → LilyPond ``\key`` tonic name.
_KEY_ROOT_TO_LY_SHARP = {
0: "c", 1: "cis", 2: "d", 3: "dis", 4: "e", 5: "f",
6: "fis", 7: "g", 8: "gis", 9: "a", 10: "ais", 11: "b",
}
_KEY_ROOT_TO_LY_FLAT = {
0: "c", 1: "des", 2: "d", 3: "ees", 4: "e", 5: "f",
6: "ges", 7: "g", 8: "aes", 9: "a", 10: "bes", 11: "b",
}
def detect_key(pitches: List[int]) -> int:
"""Detect the most likely major key root from a MIDI pitch distribution.
Uses Krumhansl-Kessler profile correlation.
Args:
pitches: MIDI pitch per segment (0 / negative = rest, ignored).
Returns:
Key root as pitch class (0=C, 1=Db, 2=D, ... 11=B). Defaults to
0 (C) when there are no pitched events.
"""
counts = [0] * 12
for p in pitches:
if p > 0:
counts[int(p) % 12] += 1
total = sum(counts)
if total == 0:
return 0 # default C
best_root, best_score = 0, -999.0
mean_obs = total / 12
mean_prof = sum(_KK_MAJOR_PROFILE) / 12
for root in range(12):
rotated = [counts[(root + i) % 12] for i in range(12)]
num = sum((r - mean_obs) * (pf - mean_prof)
for r, pf in zip(rotated, _KK_MAJOR_PROFILE))
den_obs = sum((r - mean_obs) ** 2 for r in rotated) ** 0.5
den_prof = sum((pf - mean_prof) ** 2 for pf in _KK_MAJOR_PROFILE) ** 0.5
score = num / (den_obs * den_prof) if den_obs * den_prof > 0 else 0
if score > best_score:
best_score = score
best_root = root
return best_root
def _midi_to_lily(midi_val: int, use_flats: bool) -> str:
"""MIDI pitch → LilyPond note string (e.g. 60 → ``c'``).
MIDI octave convention: 60 = C4 (middle C) = LilyPond ``c'``.
"""
name = (_LY_NAMES_FLAT if use_flats else _LY_NAMES_SHARP)[midi_val % 12]
ly_oct = midi_val // 12 - 1 - 3 # MIDI octave (60→4) → LilyPond (c'=4)
if ly_oct > 0:
name += "'" * ly_oct
elif ly_oct < 0:
name += "," * (-ly_oct)
return name
def _ly_text(word: str) -> str:
"""Escape a lyric/markup string for a LilyPond double-quoted token."""
return word.replace("\\", "").replace('"', '')
def build_lily_source(
bpm: int,
words: List[str],
pitches: List[int],
notes: List[str],
pitch2word: Optional[List[int]] = None,
key_root: Optional[int] = None,
draw_slurs: bool = True,
draw_beams: bool = False,
) -> str:
"""
Build a LilyPond source string from structured SVS metadata.
Glyph (note vs rest) follows ``pitches``; lyric follows ``words`` — the
two are decoupled so the raw annotation is shown faithfully even when it
is musically wrong (see the module docstring).
Args:
bpm: Beats per minute.
words: Lyric characters, **one per syllable** (may contain "AP"/"SP"
rest markers). With melisma there are fewer words than notes.
pitches: MIDI pitch per note segment (0 for rests).
notes: Note token strings, e.g. ["<NOTE_4>", ...], one per segment.
pitch2word: ``pitch2word[i]`` = the word index that note ``i`` belongs
to (melisma alignment). ``None``/mismatched → identity 1:1.
key_root: Optional key root pitch class (0=C..11=B). ``None`` →
auto-detect via the Krumhansl-Kessler profile.
draw_slurs: When True (default), join each melisma's notes (multiple
notes under one word index) with a slur (连音线).
draw_beams: When True, **explicitly** beam maximal runs of consecutive
beamable notes (eighth-and-shorter, broken by rests / quarter-plus
notes). Off by default — a full score relies on LilyPond's metric
auto-beaming. Enable it for a short **excerpt** that doesn't fill a
measure (LilyPond leaves an incomplete measure's notes flagged, so an
excerpt would otherwise look un-beamed vs the complete score).
Returns:
Complete LilyPond source text.
"""
if not pitches or not notes:
raise ValueError("Empty pitches or notes list")
# ``pitch2word`` is per-note; a valid one matches ``pitches`` in length.
# Anything else falls back to identity (positional word↔note).
if not pitch2word or len(pitch2word) != len(pitches):
pitch2word = list(range(len(pitches)))
# Melisma slurs: within each maximal run of consecutive segments sharing a
# real (non SP/AP) word index, slur from the first to the last *pitched*
# note when the run has ≥2 of them. Rests advance without breaking the run
# (the slur spans them); a single-note syllable gets no slur. Keyed on the
# word *index*, so two adjacent identical characters stay separate.
n = len(pitches)
slur_open = [False] * n
slur_close = [False] * n
if draw_slurs:
i = 0
while i < n:
widx = pitch2word[i]
word = words[widx] if 0 <= widx < len(words) else ""
if not word or word.upper() in ("AP", "SP"):
i += 1
continue
j = i
while j < n and pitch2word[j] == widx:
j += 1
pitched = [k for k in range(i, j) if int(pitches[k]) > 0]
if len(pitched) >= 2:
slur_open[pitched[0]] = True
slur_close[pitched[-1]] = True
i = j
# Manual beams: maximal runs of consecutive beamable pitched notes (an
# eighth or shorter), broken by rests or quarter-plus notes. Only used for
# excerpts (see ``draw_beams``); a full score keeps LilyPond's auto-beaming.
_BEAMABLE = {"<NOTE_8>", "<NOTE_DOT_8>", "<NOTE_16>", "<NOTE_DOT_16>",
"<NOTE_32>", "<NOTE_DOT_32>"}
beam_open = [False] * n
beam_close = [False] * n
if draw_beams:
i = 0
while i < n:
tok = notes[i] if i < len(notes) else ""
if int(pitches[i]) > 0 and tok in _BEAMABLE:
j = i
while (j < n and int(pitches[j]) > 0
and (notes[j] if j < len(notes) else "") in _BEAMABLE):
j += 1
if j - i >= 2:
beam_open[i] = True
beam_close[j - 1] = True
i = j
else:
i += 1
if key_root is None:
key_root = detect_key(pitches)
use_flats = key_root in _FLAT_KEY_ROOTS
key_ly = (_KEY_ROOT_TO_LY_FLAT if use_flats else _KEY_ROOT_TO_LY_SHARP)[key_root]
# Clef from the median pitched note (bass for low voices).
pitched = [m for m in pitches if m > 0]
if pitched:
median = sorted(pitched)[len(pitched) // 2]
clef = "bass" if median < 55 else "treble"
else:
clef = "treble"
# One duration-tagged token per segment in BOTH lines so the timed
# \lyricmode aligns to the staff by moment (and a lyric can land under a
# rest). Melody: note or rest. Lyric: the syllable's text, or \skip.
melody: List[str] = []
lyrics: List[str] = []
prev_word_idx = None
for i in range(len(pitches)):
midi = int(pitches[i])
dur = _NOTE_TOKEN_TO_LY.get(notes[i] if i < len(notes) else "", "4")
widx = pitch2word[i]
word = words[widx] if 0 <= widx < len(words) else ""
is_sp = word.upper() in ("AP", "SP")
is_real = bool(word) and not is_sp
# Show the lyric on the first segment of each syllable; later segments
# sharing the word index are melisma and stay blank. Keyed on word
# *index* so two adjacent identical characters are separate syllables.
show_lyric = is_real and widx != prev_word_idx
prev_word_idx = widx if is_real else None
# Glyph follows pitch; lyric follows word — fully decoupled.
tok = f"{_midi_to_lily(midi, use_flats)}{dur}" if midi > 0 else f"r{dur}"
# Post-events on pitched notes only (beam/slur flags never set on rests).
# Order: beam bracket then slur — ``c'8[(`` … ``f'8])``.
if beam_open[i]:
tok += "["
if beam_close[i]:
tok += "]"
if slur_open[i]:
tok += "("
if slur_close[i]:
tok += ")"
melody.append(tok)
lyrics.append(f'"{_ly_text(word)}"{dur}' if show_lyric else f"\\skip {dur}")
# Layout: reuse music21's natural look — the lilypond-book snippet
# preamble + an empty \paper (justified default line-breaking, lines
# stretched to full width, no blank right margin). It paginates one
# system per page, which render_lily_to_png stitches back together.
# Layout (\paper/preamble) and content (the << staff + lyrics >> body)
# are orthogonal in LilyPond, so this keeps the note/lyric decoupling.
return (
'\\version "2.24.0"\n'
'\\include "lilypond-book-preamble.ly"\n'
'\\header { tagline = ##f }\n'
'\\paper { }\n'
'<<\n'
' \\new Staff { \\new Voice = "mel" {\n'
f' \\clef {clef} \\key {key_ly} \\major \\time 4/4 \\tempo 4 = {int(bpm)}\n'
f' {" ".join(melody)}\n'
' } }\n'
f' \\new Lyrics \\lyricmode {{ {" ".join(lyrics)} }}\n'
'>>\n'
)
def _autocrop_png(path: str, margin: int = 12) -> str:
"""Trim surrounding whitespace from a rendered PNG.
LilyPond emits a full A4 page, so a short melody sits at the top with a
large blank margin below. This crops to the bounding box of the actual
content (equivalent to lilypond's ``-dcrop``), leaving a small ``margin``
of padding. Returns ``path`` unchanged on any failure / blank image.
"""
try:
from PIL import Image, ImageChops
im = Image.open(path)
if im.mode in ("RGBA", "LA"):
bbox = im.split()[-1].getbbox() # non-transparent region
else:
rgb = im.convert("RGB")
bg = Image.new("RGB", rgb.size, (255, 255, 255))
bbox = ImageChops.difference(rgb, bg).getbbox()
if bbox is None:
return path
w, h = im.size
left = max(0, bbox[0] - margin)
upper = max(0, bbox[1] - margin)
right = min(w, bbox[2] + margin)
lower = min(h, bbox[3] + margin)
im.crop((left, upper, right, lower)).save(path)
except Exception as e:
import sys
print(f"[Score Rendering] Autocrop skipped for {path}: {e}", file=sys.stderr)
return path
def _stack_pngs_vertically(
page_paths: List[str], out_path: str, margin: int = 12, gap: int = 24
) -> str:
"""Crop each LilyPond page to its content and stack them vertically.
LilyPond writes a multi-page score as ``<base>-page1.png``,
``<base>-page2.png``, … (no ``<base>.png``). Each page is a full A4
sheet with the systems at the top. We crop every page to its bounding
box and paste them top-to-bottom into one continuous score image.
"""
from PIL import Image, ImageChops
crops = []
for p in page_paths:
im = Image.open(p).convert("RGB")
bg = Image.new("RGB", im.size, (255, 255, 255))
bbox = ImageChops.difference(im, bg).getbbox()
if bbox is None:
continue # blank trailing page
left = max(0, bbox[0] - margin)
upper = max(0, bbox[1] - margin)
right = min(im.size[0], bbox[2] + margin)
lower = min(im.size[1], bbox[3] + margin)
crops.append(im.crop((left, upper, right, lower)))
if not crops:
raise RuntimeError("all LilyPond pages were blank")
width = max(c.width for c in crops)
height = sum(c.height for c in crops) + gap * (len(crops) - 1)
canvas = Image.new("RGB", (width, height), (255, 255, 255))
y = 0
for c in crops:
canvas.paste(c, (0, y))
y += c.height + gap
canvas.save(out_path)
return out_path
def render_lily_to_png(ly_source: str, output_path: str, resolution: int = 300) -> str:
"""
Render a LilyPond source string to a PNG at ``resolution`` DPI.
The lilypond-book preamble (see :func:`build_lily_source`) emits one
system per page, so a single-line score is one page that
:func:`_autocrop_png` trims to content, and any multi-line score is
several pages that :func:`_stack_pngs_vertically` stitches back into one
continuous image.
Args:
ly_source: Complete LilyPond source text (see :func:`build_lily_source`).
output_path: Output file path (without extension; .png is added).
resolution: Render DPI (300 ≈ 3× the music21 default).
Returns:
Path to the generated (cropped) PNG file.
"""
import glob
import re
import shutil
import subprocess
import tempfile
import lilypond as lilypond_pkg
if output_path.endswith('.png'):
output_path = output_path[:-4]
# output_path may nest a subdir (e.g. muse item_name "song/segment");
# lilypond won't create missing parents.
parent = os.path.dirname(output_path)
if parent:
os.makedirs(parent, exist_ok=True)
lily_exec = str(lilypond_pkg.executable())
final_png = output_path + '.png'
with tempfile.TemporaryDirectory() as td:
ly_path = os.path.join(td, 'score.ly')
with open(ly_path, 'w', encoding='utf-8') as f:
f.write(ly_source)
base = os.path.join(td, 'score')
# capture_output swallows lilypond's chatty stderr; gate on the PNG
# existing rather than returncode (barcheck warnings give rc=1 even on
# a good render).
subprocess.run(
[lily_exec, '-f', 'png', f'-dresolution={resolution}',
'-dbackend=eps', '-o', base, ly_path],
capture_output=True, text=True,
)
rendered = base + '.png'
if os.path.exists(rendered):
shutil.copy2(rendered, final_png) # single page
else:
pages = sorted(
glob.glob(base + '-page*.png'),
key=lambda p: int(re.search(r'-page(\d+)\.png$', p).group(1)),
)
if not pages:
raise RuntimeError(f"lilypond produced no PNG for {output_path}")
_stack_pngs_vertically(pages, final_png) # multi-page stitch
return _autocrop_png(final_png)
def render_score(
bpm: int,
words: List[str],
pitches: List[int],
notes: List[str],
output_path: str,
pitch2word: Optional[List[int]] = None,
key_root: Optional[int] = None,
draw_slurs: bool = True,
draw_beams: bool = False,
) -> Optional[str]:
"""
Convenience function: structured metadata → staff notation PNG.
Args:
bpm: Beats per minute.
words: Lyric characters, one per syllable.
pitches: MIDI pitch per note segment.
notes: Note token strings, one per note segment.
output_path: Output PNG file path (without extension).
pitch2word: Per-note word index for melisma-correct lyric placement
(see :func:`build_lily_source`). ``None`` → legacy 1:1 mapping.
key_root: Optional key root pitch class to override auto-detection.
draw_slurs: Join each melisma's notes with a slur (see
:func:`build_lily_source`). Default on.
draw_beams: Explicitly beam beamable runs — for excerpts that don't fill
a measure (see :func:`build_lily_source`). Default off.
Returns:
Path to the generated PNG file, or None on failure.
"""
try:
ly = build_lily_source(bpm, words, pitches, notes,
pitch2word=pitch2word, key_root=key_root,
draw_slurs=draw_slurs, draw_beams=draw_beams)
return render_lily_to_png(ly, output_path)
except Exception as e:
import sys
print(f"[Score Rendering] Failed to render score: {e}", file=sys.stderr)
return None
def _render_one(args: Tuple) -> Tuple[str, Optional[str]]:
"""Worker function for parallel rendering.
Accepts a 6-tuple ``(bpm, words, pitches, notes, pitch2word, output_path)``
or a 7-tuple with a trailing ``key_root`` override.
"""
bpm, words, pitches, notes, pitch2word, output_path = args[:6]
key_root = args[6] if len(args) > 6 else None
return output_path, render_score(bpm, words, pitches, notes, output_path,
pitch2word=pitch2word, key_root=key_root)
def render_scores_parallel(
tasks: List[Tuple],
max_workers: int = 16,
) -> Dict[str, Optional[str]]:
"""
Render multiple scores to PNGs in parallel.
Args:
tasks: List of (bpm, words, pitches, notes, pitch2word, output_path)
tuples (optional trailing key_root).
max_workers: Number of parallel workers.
Returns:
Dict mapping output_path → rendered PNG path (or None on failure).
"""
if not tasks:
return {}
import sys
print(f"[Score Rendering] Rendering {len(tasks)} scores with {max_workers} workers...",
file=sys.stderr)
results = {}
with ProcessPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(_render_one, t): t for t in tasks}
for future in as_completed(futures):
try:
output_path, rendered_path = future.result()
results[output_path] = rendered_path
except Exception as e:
task_data = futures[future]
output_path = task_data[5] # (bpm, words, pitches, notes, pitch2word, output_path)
print(f"[Score Rendering] Failed: {output_path}: {e}", file=sys.stderr)
results[output_path] = None
success = sum(1 for v in results.values() if v is not None)
print(f"[Score Rendering] Done: {success}/{len(tasks)} scores rendered.", file=sys.stderr)
return results