accompy / src /fingering.py
jasonmdong's picture
Sync from GitHub via hub-sync
c78e8af verified
Raw
History Blame Contribute Delete
8.35 kB
import contextlib
import importlib.util
import io
import logging
import re
import xml.etree.ElementTree as ET
from pathlib import Path
logger = logging.getLogger(__name__)
ENGINE_NAME = "pianoplayer"
VALID_HAND_SIZES = {"XXS", "XS", "S", "M", "L", "XL", "XXL"}
FINGERING_LYRIC_NUMBER = "pianoplayer-fingering"
def _hand_size() -> str:
import os
size = (os.getenv("ACCOMPY_FINGERING_HAND_SIZE") or "M").strip().upper() or "M"
return size if size in VALID_HAND_SIZES else "M"
def engine_available() -> bool:
return importlib.util.find_spec("pianoplayer") is not None
def score_is_eligible(parts_data: list[dict]) -> bool:
return 0 < len(parts_data) <= 2
def build_fingering_state(parts_data: list[dict]) -> dict:
return {
"engine": ENGINE_NAME,
"available": engine_available(),
"eligible": score_is_eligible(parts_data),
"applied": False,
"hand_size": None,
"annotations": 0,
"reason": "not_generated",
}
def normalize_fingering_state(
parts_data: list[dict],
existing: dict | None = None,
*,
has_fingered_sheet: bool = False,
) -> dict:
metadata = build_fingering_state(parts_data)
if isinstance(existing, dict):
for key in ("hand_size", "annotations", "reason"):
if key in existing:
metadata[key] = existing[key]
if has_fingered_sheet:
metadata["applied"] = True
metadata["reason"] = "generated"
elif not metadata["eligible"]:
metadata["reason"] = "unsupported_parts"
elif not metadata["available"]:
metadata["reason"] = "missing_dependency"
else:
metadata["reason"] = "not_generated"
if not metadata["applied"]:
metadata["annotations"] = 0
return metadata
def _count_fingering_annotations(path: str | Path) -> int:
tree = ET.parse(path)
count = 0
for note_el in tree.iterfind(".//note"):
technical = note_el.find("./notations/technical/fingering")
lyric = note_el.find(f"./lyric[@number='{FINGERING_LYRIC_NUMBER}']/text")
if technical is not None or lyric is not None:
count += 1
return count
def _tag_has_class(tag: str, class_name: str) -> bool:
class_match = re.search(r"""\bclass\s*=\s*(['"])(.*?)\1""", tag, re.IGNORECASE | re.DOTALL)
return bool(class_match and class_name in class_match.group(2).split())
def _extract_balanced_tag_fragments(markup: str, tag_name: str, class_name: str | None = None) -> list[tuple[int, int]]:
fragments = []
token_re = re.compile(rf"<(/?){re.escape(tag_name)}\b[^>]*>", re.IGNORECASE)
search_from = 0
while True:
start_match = token_re.search(markup, search_from)
if not start_match:
break
if start_match.group(1):
search_from = start_match.end()
continue
if start_match.group(0).rstrip().endswith("/>"):
search_from = start_match.end()
continue
if class_name and not _tag_has_class(start_match.group(0), class_name):
search_from = start_match.end()
continue
depth = 1
end_pos = start_match.end()
for match in token_re.finditer(markup, start_match.end()):
if match.group(0).rstrip().endswith("/>"):
continue
depth += -1 if match.group(1) else 1
if depth == 0:
end_pos = match.end()
break
else:
break
fragments.append((start_match.start(), end_pos))
search_from = end_pos
return fragments
def _attr_value(tag: str, name: str) -> str | None:
match = re.search(rf"""\b{re.escape(name)}\s*=\s*(['"])(.*?)\1""", tag, re.IGNORECASE | re.DOTALL)
return match.group(2) if match else None
def _set_attr_value(tag: str, name: str, value: str) -> str:
attr_re = re.compile(rf"""(\b{re.escape(name)}\s*=\s*)(['"])(.*?)\2""", re.IGNORECASE | re.DOTALL)
if attr_re.search(tag):
return attr_re.sub(lambda match: f"{match.group(1)}{match.group(2)}{value}{match.group(2)}", tag, count=1)
return tag[:-1] + f' {name}="{value}">'
def _format_svg_number(value: float) -> str:
return str(round(value, 2)).rstrip("0").rstrip(".")
def _stack_chord_verses_in_fragment(chord_markup: str) -> tuple[str, int]:
text_nodes = []
for verse_start, verse_end in _extract_balanced_tag_fragments(chord_markup, "g", "verse"):
verse_markup = chord_markup[verse_start:verse_end]
text_match = re.search(r"<text\b[^>]*>", verse_markup, re.IGNORECASE | re.DOTALL)
if not text_match:
continue
text_tag = text_match.group(0)
x_value = _attr_value(text_tag, "x")
y_value = _attr_value(text_tag, "y")
if x_value is None or y_value is None:
continue
try:
text_nodes.append({
"start": verse_start + text_match.start(),
"end": verse_start + text_match.end(),
"tag": text_tag,
"x": float(x_value),
"y": float(y_value),
})
except ValueError:
continue
if len(text_nodes) <= 1:
return chord_markup, 0
x = min(node["x"] for node in text_nodes)
base_y = max(node["y"] for node in text_nodes)
line_gap = 360
center_index = (len(text_nodes) - 1) / 2
rebuilt = chord_markup
for index, node in reversed(list(enumerate(text_nodes))):
y = base_y + ((center_index - index) * line_gap)
tag = _set_attr_value(node["tag"], "x", _format_svg_number(x))
tag = _set_attr_value(tag, "y", _format_svg_number(y))
rebuilt = rebuilt[:node["start"]] + tag + rebuilt[node["end"]:]
return rebuilt, len(text_nodes)
def stack_fingering_chord_numbers_in_html(html: str) -> str:
if "class=\"chord\"" not in html and "class='chord'" not in html:
return html
fragments = _extract_balanced_tag_fragments(html, "g", "chord")
if not fragments:
return html
rebuilt = html
total_changed = 0
for start, end in reversed(fragments):
replacement, changed = _stack_chord_verses_in_fragment(rebuilt[start:end])
rebuilt = rebuilt[:start] + replacement + rebuilt[end:]
total_changed += changed
return rebuilt if total_changed else html
def apply_auto_fingering(
source_path: str,
*,
out_dir: str,
score_name: str,
parts_data: list[dict],
progress_callback=None,
) -> tuple[str, dict]:
progress = progress_callback or (lambda *_args, **_kwargs: None)
metadata = build_fingering_state(parts_data)
metadata["hand_size"] = _hand_size()
if not metadata["eligible"]:
metadata["reason"] = "unsupported_parts"
return source_path, metadata
if not metadata["available"]:
metadata["reason"] = "missing_dependency"
return source_path, metadata
from pianoplayer.core import run_annotate
output_path = Path(out_dir) / f"{score_name}__fingered.musicxml"
try:
progress(35, "Analyzing score")
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
run_annotate(
filename=source_path,
outputfile=str(output_path),
quiet=True,
hand_size=metadata["hand_size"],
below_beam=True,
)
except Exception as exc:
metadata["reason"] = "annotation_failed"
logger.warning("PianoPlayer fingering failed for %s: %s", source_path, exc)
return source_path, metadata
if not output_path.exists():
metadata["reason"] = "missing_output"
return source_path, metadata
try:
progress(65, "Counting annotations")
metadata["annotations"] = _count_fingering_annotations(output_path)
except Exception as exc:
metadata["reason"] = "annotation_count_failed"
logger.warning("Could not inspect PianoPlayer output %s: %s", output_path, exc)
return str(output_path), metadata
if metadata["annotations"] <= 0:
metadata["reason"] = "no_annotations"
return str(output_path), metadata
metadata["applied"] = True
metadata["reason"] = "generated"
return str(output_path), metadata