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"]*>", 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