Spaces:
Running on Zero
Running on Zero
Download abc_tools.py from Reubencf/Kiku-Engine: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/spaces/Reubencf/Kiku-Engine/resolve/main/abc_tools.py
- Command line
-
hf download hf://spaces/Reubencf/Kiku-Engine/abc_tools.py
-
curl -L -o abc_tools.py https://huggingface.co/spaces/Reubencf/Kiku-Engine/resolve/main/abc_tools.py
15.9 kB
| # Vendored unchanged from the yue2-music skill (instrumental/scripts/abc_tools.py), | |
| # github.com/multimodal-art-projection/YuE, MIT licence: see LICENSE.yue2-music. | |
| #!/usr/bin/env python3 | |
| """Inspect the limited two-voice ABC dialect used by YuE2 and SheetSage2. | |
| Original, standard-library-only implementation. This is deliberately not a | |
| general ABC parser or a replacement for SheetSage2's native score serializer. | |
| All times are exact fractions of a quarter note. See references/abc-editing.md. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| import re | |
| import sys | |
| from dataclasses import dataclass, field | |
| from fractions import Fraction | |
| from pathlib import Path | |
| VOICES = ("Vocal", "Ins") | |
| DURATIONS = {1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48} | |
| QUALITIES = ("", "m", "dim", "aug", "7", "maj7", "m7", "dim7", "m7b5", | |
| "sus4", "sus2", "6", "m6", "7sus4", "m(maj7)") | |
| PITCH_NAME = r"[A-G](?:bb|##|b|#)?" | |
| CHORD = re.compile(PITCH_NAME + "(?:" + "|".join(re.escape(q) for q in QUALITIES) | |
| + ")(?:/" + PITCH_NAME + ")?") | |
| TOKEN = re.compile( | |
| r'"(?P<chord>[^"\n]*)"|\[K:(?P<key>[^\]\n]+)\]|' | |
| r"(?P<acc>\^\^|__|\^|_|=)?(?P<note>[A-Ga-gz])" | |
| r"(?P<oct>[,']*)(?P<duration>[0-9]*)(?P<tie>-?)" | |
| ) | |
| NATURAL = dict(zip("CDEFGAB", (0, 2, 4, 5, 7, 9, 11))) | |
| KEYS = { | |
| **dict(zip(("Cb", "Gb", "Db", "Ab", "Eb", "Bb", "F", "C", "G", "D", "A", "E", "B", "F#", "C#"), range(-7, 8))), | |
| **dict(zip(("Abm", "Ebm", "Bbm", "Fm", "Cm", "Gm", "Dm", "Am", "Em", "Bm", "F#m", "C#m", "G#m", "D#m", "A#m"), range(-7, 8))), | |
| } | |
| class AbcError(ValueError): | |
| """Unsupported notation or a failed structural invariant.""" | |
| def fail(condition: bool, message: str) -> None: | |
| if condition: | |
| raise AbcError(message) | |
| def key_accidentals(key: str) -> dict[str, int]: | |
| fail(key not in KEYS, f"Unsupported key {key!r}; use a standard major or minor K: field") | |
| count = KEYS[key] | |
| result = {letter: 0 for letter in NATURAL} | |
| for letter in ("FCGDAEB" if count > 0 else "BEADGCF")[:abs(count)]: | |
| result[letter] = 1 if count > 0 else -1 | |
| return result | |
| def meter_value(text: str) -> tuple[int, int]: | |
| match = re.fullmatch(r"([1-9][0-9]*)/([1-9][0-9]*)", text) | |
| fail(match is None, f"Unsupported meter {text!r}; write an explicit fraction") | |
| n, d = map(int, match.groups()) | |
| fail(d > 1024 or d & (d - 1) != 0, f"Unsupported meter denominator {d}") | |
| return n, d | |
| class Voice: | |
| meter: tuple[int, int] | |
| key: str | |
| time: Fraction = Fraction(0) | |
| notes: list = field(default_factory=list) | |
| bars: list = field(default_factory=list) | |
| chords: list = field(default_factory=list) | |
| keys: list = field(default_factory=list) | |
| pending: tuple | None = None | |
| class Score: | |
| text: str | |
| unit: Fraction | |
| bpm: int | |
| voices: dict[str, Voice] | |
| music_lines: dict[int, str] | |
| def parse_bar(body: str, voice: Voice, unit: Fraction, context: str) -> None: | |
| n, d = voice.meter | |
| length = Fraction(4 * n, d) | |
| start = voice.time | |
| offset = Fraction(0) | |
| local = {} # Native exporters propagate accidentals by letter, across octaves. | |
| if body == "Z": | |
| fail(voice.pending is not None, f"{context}: tie enters a full-measure rest") | |
| offset = length | |
| else: | |
| cursor = 0 | |
| while cursor < len(body): | |
| if body[cursor].isspace(): | |
| cursor += 1 | |
| continue | |
| match = TOKEN.match(body, cursor) | |
| fail(match is None, f"{context}: unsupported token at {body[cursor:cursor + 24]!r}") | |
| cursor = match.end() | |
| chord, key = match.group("chord", "key") | |
| fail(offset >= length, f"{context}: event after the measure end") | |
| if chord is not None: | |
| fail(CHORD.fullmatch(chord) is None, f"{context}: unsupported chord {chord!r}") | |
| voice.chords.append((start + offset, chord)) | |
| continue | |
| if key is not None: | |
| key_accidentals(key) | |
| voice.key = key | |
| voice.keys.append((start + offset, key)) | |
| local = {} | |
| continue | |
| note, acc, octave, tie = match.group("note", "acc", "oct", "tie") | |
| units = int(match.group("duration") or "1") | |
| fail(units not in DURATIONS, f"{context}: unsupported duration {units}; split it into tied supported lengths") | |
| duration = units * unit * 4 | |
| fail(offset + duration > length, f"{context}: note/rest exceeds meter duration") | |
| fail("," in octave and "'" in octave, f"{context}: mixed octave marks") | |
| if note == "z": | |
| fail(bool(acc or octave or tie), f"{context}: a rest cannot have accidentals, octave marks or ties") | |
| fail(voice.pending is not None, f"{context}: tie enters a rest") | |
| else: | |
| letter = note.upper() | |
| written = 60 + NATURAL[letter] + (12 if note.islower() else 0) | |
| written += 12 * (octave.count("'") - octave.count(",")) | |
| alteration = local.get(letter, key_accidentals(voice.key)[letter]) | |
| if acc: | |
| alteration = {"=": 0, "_": -1, "__": -2, "^": 1, "^^": 2}[acc] | |
| local[letter] = alteration | |
| pitch = written + alteration | |
| if voice.pending is not None: | |
| old_pitch, old_written = voice.pending | |
| # An unmarked continuation retains its tied accidental across | |
| # a barline. It does not alter later untied notes in that bar. | |
| if not acc and written == old_written: | |
| pitch = old_pitch | |
| fail(pitch != old_pitch, f"{context}: tie changes pitch from {old_pitch} to {pitch}") | |
| voice.notes[-1][2] += duration | |
| else: | |
| fail(not 0 <= pitch <= 127, f"{context}: pitch {pitch} is outside MIDI range") | |
| voice.notes.append([start + offset, pitch, duration]) | |
| voice.pending = (pitch, written) if tie else None | |
| offset += duration | |
| fail(offset != length, f"{context}: duration {offset} quarter notes != meter duration {length}") | |
| voice.bars.append((start, length, voice.meter)) | |
| voice.time += length | |
| def parse(text: str) -> Score: | |
| """Fail closed for unsupported tokens; resolve sounding notes, not token counts.""" | |
| lines = text.splitlines() | |
| fail(len(lines) < 12, "Incomplete native two-voice ABC") | |
| fail(lines[0:2] != ["X:1", "T:"], "Expected native X:1 and blank T: header") | |
| fail(not lines[2].startswith("M:"), "Missing header M:") | |
| meter = meter_value(lines[2][2:]) | |
| unit_match = re.fullmatch(r"L:1/([1-9][0-9]*)", lines[3]) | |
| fail(unit_match is None, "Expected L:1/<power of two>, usually L:1/32") | |
| denominator = int(unit_match.group(1)) | |
| fail(denominator > 1024 or denominator & (denominator - 1) != 0, "Unsupported L: denominator") | |
| unit = Fraction(1, denominator) | |
| tempo_match = re.fullmatch(r"Q:1/4=([1-9][0-9]*)", lines[4]) | |
| fail(tempo_match is None, "Expected integer quarter-note tempo Q:1/4=<BPM>") | |
| expected_voices = ['V: Vocal clef=treble name="Vocal Melody" snm="Vocal"', | |
| 'V: Ins clef=treble name="Ins Melody" snm="Inst."'] | |
| fail(lines[5:7] != expected_voices, "Preserve native Vocal and Ins voice definitions") | |
| fail(not lines[7].startswith("K:"), "Missing header K:") | |
| key = lines[7][2:] | |
| key_accidentals(key) | |
| voices = {name: Voice(meter, key, keys=[(Fraction(0), key)]) for name in VOICES} | |
| music_lines = {} | |
| cursor = 8 | |
| group = 0 | |
| while cursor < len(lines): | |
| while cursor < len(lines) and lines[cursor].startswith("% "): | |
| cursor += 1 | |
| fail(cursor == len(lines), "Dangling section comment without music") | |
| group += 1 | |
| counts = [] | |
| for name in VOICES: | |
| context = f"group {group}, {name}" | |
| fail(cursor >= len(lines) or lines[cursor] != f"V: {name}", f"{context}: expected V: {name}") | |
| cursor += 1 | |
| voice = voices[name] | |
| fields = set() | |
| while cursor < len(lines) and lines[cursor].startswith(("M:", "K:")): | |
| field_name, value = lines[cursor].split(":", 1) | |
| fail(field_name in fields, f"{context}: duplicate {field_name}: field") | |
| fields.add(field_name) | |
| if field_name == "M": | |
| voice.meter = meter_value(value) | |
| else: | |
| key_accidentals(value) | |
| voice.key = value | |
| voice.keys.append((voice.time, value)) | |
| cursor += 1 | |
| fail(cursor >= len(lines), f"{context}: missing music line") | |
| line = lines[cursor] | |
| fail(not line.endswith("|"), f"{context}: music line must end with a plain barline") | |
| music_lines[cursor] = name | |
| cursor += 1 | |
| bars = [] | |
| for bar in line[:-1].split("|"): | |
| bar = bar.strip() | |
| fail(not bar, f"{context}: empty measure or unsupported double/repeat barline") | |
| rest = re.fullmatch(r"Z([2-4])?", bar) | |
| if rest: | |
| bars.extend(["Z"] * int(rest.group(1) or "1")) | |
| else: | |
| bars.append(bar) | |
| fail(not 1 <= len(bars) <= 4, f"{context}: expected 1–4 measures after expanding Z rests") | |
| counts.append(len(bars)) | |
| for bar in bars: | |
| parse_bar(bar, voice, unit, f"{context}, bar {len(voice.bars) + 1}") | |
| fail(counts[0] != counts[1], f"group {group}: voices have different measure counts") | |
| for name, voice in voices.items(): | |
| fail(voice.pending is not None, f"{name}: unresolved tie at end of score") | |
| fail(voices["Ins"].chords != [], "Native chord symbols belong in Vocal, not Ins") | |
| fail(voices["Vocal"].bars != voices["Ins"].bars, "Voice meter/time grids differ") | |
| fail(voices["Vocal"].keys != voices["Ins"].keys, "Voice key-change timelines differ") | |
| return Score(text, unit, int(tempo_match.group(1)), voices, music_lines) | |
| def json_value(value): | |
| if isinstance(value, Fraction): | |
| return str(value) | |
| raise TypeError(type(value).__name__) | |
| def parse_abc(text: str) -> Score: | |
| """Public import entry point; no model load, files, or optional dependencies.""" | |
| return parse(text) | |
| def report(score: Score) -> dict: | |
| return { | |
| "scope": "native-dialect structural check; not native serializer reconstruction or audio verification", | |
| "sha256": hashlib.sha256(score.text.encode()).hexdigest(), | |
| "bpm": score.bpm, "unit_length": str(score.unit), | |
| "duration_quarters": str(score.voices["Vocal"].time), | |
| "nominal_duration_seconds": float(score.voices["Vocal"].time * 60 / score.bpm), | |
| "voices": {name: {"sounding_notes": len(v.notes), "measures": len(v.bars), | |
| "notes": [{"onset_quarters": str(t), "midi_pitch": p, "duration_quarters": str(d)} | |
| for t, p, d in v.notes], | |
| "chords": v.chords, "keys": v.keys, "bars": v.bars} | |
| for name, v in score.voices.items()}, | |
| } | |
| def compare(before: Score, after: Score, names=VOICES, allow_tempo_change=False) -> dict: | |
| differences = [] | |
| if before.bpm != after.bpm and not allow_tempo_change: | |
| differences.append("quarter-note tempo differs") | |
| for name in names: | |
| a, b = before.voices[name], after.voices[name] | |
| if a.bars != b.bars: | |
| differences.append(f"{name}: bar meter/time grid differs") | |
| if a.notes != b.notes: | |
| common = min(len(a.notes), len(b.notes)) | |
| first = next((i for i in range(common) if a.notes[i] != b.notes[i]), common) | |
| differences.append(f"{name}: sounding notes differ starting at note {first + 1} (pitch, onset or duration)") | |
| return {"match": not differences, "compared_voices": list(names), | |
| "tempo_change_allowed": allow_tempo_change, "differences": differences, | |
| "scope": "exact symbolic melody and meter; no claim about generated audio or unchanged harmony"} | |
| def strip_chords(text: str, keep_voice="both") -> str: | |
| source = parse(text) | |
| lines = text.splitlines(keepends=True) | |
| for index, name in source.music_lines.items(): | |
| def transform(match): | |
| if match.group("chord") is not None: | |
| return "" | |
| if keep_voice != "both" and name != keep_voice and match.group("note") is not None: | |
| return "z" + match.group("duration") | |
| return match.group(0) | |
| lines[index] = TOKEN.sub(transform, lines[index]) | |
| output = "".join(lines) | |
| result = parse(output) | |
| names = VOICES if keep_voice == "both" else (keep_voice,) | |
| invariant = compare(source, result, names) | |
| fail(not invariant["match"], f"Chord removal changed melody: {invariant['differences']}") | |
| fail(any(v.chords for v in result.voices.values()), "Chord removal left a chord symbol") | |
| if keep_voice != "both": | |
| fail(any(v.notes for name, v in result.voices.items() if name != keep_voice), "Unselected voice was not silenced") | |
| return output | |
| def main() -> int: | |
| cli = argparse.ArgumentParser(description=__doc__) | |
| commands = cli.add_subparsers(dest="command", required=True) | |
| inspect = commands.add_parser("inspect", help="validate and export exact note/meter/chord events") | |
| inspect.add_argument("score", type=Path) | |
| inspect.add_argument("--output", type=Path) | |
| strip = commands.add_parser("strip-chords", help="remove musical chord symbols, preserving header quotes") | |
| strip.add_argument("source", type=Path) | |
| strip.add_argument("output", type=Path) | |
| strip.add_argument("--keep-voice", choices=("both", *VOICES), default="both", | |
| help="both preserves all melodies; selecting one replaces the other voice with rests") | |
| diff = commands.add_parser("compare", help="check exact sounding-note/meter invariants after editing") | |
| diff.add_argument("before", type=Path) | |
| diff.add_argument("after", type=Path) | |
| diff.add_argument("--voices", choices=("both", *VOICES), default="both") | |
| diff.add_argument("--allow-tempo-change", action="store_true") | |
| diff.add_argument("--output", type=Path) | |
| args = cli.parse_args() | |
| try: | |
| if args.command == "inspect": | |
| data = report(parse(args.score.read_text(encoding="utf-8"))) | |
| elif args.command == "strip-chords": | |
| fail(args.source.resolve() == args.output.resolve(), "Write a new file; do not overwrite the source") | |
| output = strip_chords(args.source.read_text(encoding="utf-8"), args.keep_voice) | |
| with args.output.open("x", encoding="utf-8") as handle: | |
| handle.write(output) | |
| print(json.dumps({"output": str(args.output), "kept_voice": args.keep_voice})) | |
| return 0 | |
| else: | |
| names = VOICES if args.voices == "both" else (args.voices,) | |
| data = compare(parse(args.before.read_text(encoding="utf-8")), | |
| parse(args.after.read_text(encoding="utf-8")), names, args.allow_tempo_change) | |
| rendered = json.dumps(data, indent=2, ensure_ascii=False, default=json_value) + "\n" | |
| if args.output: | |
| fail(args.output.exists(), "Report output already exists; choose a new path") | |
| args.output.write_text(rendered, encoding="utf-8") | |
| else: | |
| print(rendered, end="") | |
| return 1 if data.get("match") is False else 0 | |
| except (AbcError, OSError) as error: | |
| print(f"ABC check failed: {error}", file=sys.stderr) | |
| return 2 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |