mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Clean release snapshot
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
"""Guitar Pro → Sloppak Notation Format importer (GPIF: .gpx GP6 / .gp GP7-8).
|
||||
|
||||
Builds the per-arrangement ``notation_<id>.json`` payload documented in
|
||||
``docs/sloppak-spec.md`` §5.3 from a parsed GPIF score, so piano/keys tracks
|
||||
imported from Guitar Pro carry real engraving data (measures → staves →
|
||||
voices → beats → notes with absolute MIDI pitch) instead of only the
|
||||
``midi = string*24 + fret`` guitar wire encoding.
|
||||
|
||||
Voice → staff routing (salvaged from the superseded PR #703 ``stf`` wire-field
|
||||
approach): the GP author's voice position within a bar decides the hand —
|
||||
voice position 0 lands on the ``rh`` staff (treble, ``G2``), voice positions
|
||||
≥ 1 land on the ``lh`` staff (bass, ``F4``). A forced-LH track (the merged
|
||||
``Piano LH`` partner of an LH/RH pair, or a standalone track whose name ends
|
||||
in ``LH``) routes every voice to ``lh``. This preserves authored hand
|
||||
crossings instead of inferring hands from pitch.
|
||||
|
||||
Timing reuses the same machinery as ``gp2rs_gpx.convert_file`` — the
|
||||
bar-indexed tempo map, per-beat rhythm durations (dots + tuplets; see
|
||||
``_beat_secs`` for the one deliberate double-dot divergence), and
|
||||
``_note_midi`` — so the
|
||||
notation beats line up with the RS-XML notes the highway plays (see
|
||||
slopsmith#618 for the longer-term goal of sharing the note-building walk
|
||||
itself, and slopsmith#261 for the time-signature-denominator pitfalls the
|
||||
``beat_groups`` emission here exists to avoid re-introducing).
|
||||
|
||||
Where this plugs in: ``gp2rs_gpx.convert_file`` calls
|
||||
``convert_track_to_notation`` for every keys track and writes the payload as
|
||||
an ``<xml-stem>.notation.json`` sidecar next to the arrangement XML. The
|
||||
sloppak *assembly* step (which assigns arrangement ids and writes
|
||||
``manifest.yaml`` — today that lives in the editor plugin's create-mode save)
|
||||
then renames the sidecar into place via ``attach_notation_to_sloppak``.
|
||||
|
||||
Analogous to ``gp2rs.convert_drum_track_to_drumtab`` for the drum tab format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import notation as notation_mod
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.gp2notation")
|
||||
|
||||
|
||||
# GPX NoteValue string → notation duration denominator (sloppak-spec §5.3:
|
||||
# 1=whole … 32=thirty-second). 64th/128th are below the schema floor; those
|
||||
# beats are DROPPED with a warning (v1 non-features doctrine: drop, never
|
||||
# approximate) — clamping the written value to 32 while time advances by the
|
||||
# true 64th/128th span would emit self-contradictory notation (overlapping
|
||||
# written durations).
|
||||
_NOTE_VALUE_DEN: dict[str, int] = {
|
||||
"Whole": 1, "Half": 2, "Quarter": 4, "Eighth": 8,
|
||||
"16th": 16, "32nd": 32,
|
||||
}
|
||||
|
||||
_SUB_FLOOR_NOTE_VALUES = frozenset({"64th", "128th"})
|
||||
|
||||
_STAFF_DEFS: dict[str, dict] = {
|
||||
"rh": {"id": "rh", "clef": "G2", "label": "Right Hand"},
|
||||
"lh": {"id": "lh", "clef": "F4", "label": "Left Hand"},
|
||||
}
|
||||
|
||||
# Track names that force every voice onto the lh staff (e.g. the "Piano LH"
|
||||
# half of an LH/RH pair imported standalone).
|
||||
_LH_NAME_RE = re.compile(r"\blh\b\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _children(root: ET.Element, tag: str) -> list[ET.Element]:
|
||||
"""Children of ``root/<tag>``, or ``[]`` — explicit None check (an empty
|
||||
Element is falsy, so ``find(...) or []`` would mis-handle it and trips
|
||||
ElementTree's truth-value DeprecationWarning)."""
|
||||
el = root.find(tag)
|
||||
return list(el) if el is not None else []
|
||||
|
||||
|
||||
def beat_groups_for(num: int, den: int) -> list[int] | None:
|
||||
"""Return the spec ``beat_groups`` list for a time signature, or ``None``.
|
||||
|
||||
Simple meters (denominator < 8, e.g. 2/4, 3/4, 4/4) have unambiguous
|
||||
grouping and omit the field. Compound meters built from dotted beats
|
||||
group in threes (6/8 → [3, 3]; 9/8 → [3, 3, 3]; 12/8 → [3, 3, 3, 3]);
|
||||
the common irregular meters get their conventional default (5/8 → [2, 3];
|
||||
7/8 → [2, 2, 3]). Anything else is omitted — the renderer's default
|
||||
grouping applies (sloppak-spec §5.3: the field is renderer-agnostic and
|
||||
optional).
|
||||
"""
|
||||
if den < 8:
|
||||
return None
|
||||
if num > 3 and num % 3 == 0:
|
||||
return [3] * (num // 3)
|
||||
if num == 5:
|
||||
return [2, 3]
|
||||
if num == 7:
|
||||
return [2, 2, 3]
|
||||
return None
|
||||
|
||||
|
||||
def _rhythm_fields(beat_el: ET.Element, rhythms_dict: dict) -> tuple[int, int, list[int] | None]:
|
||||
"""Return ``(dur, dot, tu)`` notation fields for a GPIF beat.
|
||||
|
||||
- ``dur`` — duration denominator from the referenced Rhythm's NoteValue
|
||||
(unknown values default to quarter, matching ``_beat_dur_secs``).
|
||||
- ``dot`` — augmentation dots from ``<AugmentationDot count="N">``,
|
||||
clamped to the schema's 0–2 range. Beat *times* advance via
|
||||
``_beat_secs``, which applies the matching multiplier (×1.5 single,
|
||||
×1.75 double), so the written dots and the emitted times agree.
|
||||
- ``tu`` — ``[num, den]`` tuplet from ``<PrimaryTuplet>``, or ``None``.
|
||||
"""
|
||||
dur = 4
|
||||
dot = 0
|
||||
tu: list[int] | None = None
|
||||
rref = beat_el.find("Rhythm")
|
||||
if rref is not None:
|
||||
rhythm = rhythms_dict.get(rref.get("ref", ""))
|
||||
if rhythm is not None:
|
||||
nv = rhythm.findtext("NoteValue", "Quarter")
|
||||
dur = _NOTE_VALUE_DEN.get(nv, 4)
|
||||
dot_el = rhythm.find("AugmentationDot")
|
||||
if dot_el is not None:
|
||||
try:
|
||||
dot = max(1, min(2, int(dot_el.get("count", 1))))
|
||||
except (TypeError, ValueError):
|
||||
dot = 1
|
||||
tuplet = rhythm.find("PrimaryTuplet")
|
||||
if tuplet is not None:
|
||||
try:
|
||||
t_num = int(tuplet.get("num", 1))
|
||||
t_den = int(tuplet.get("den", 1))
|
||||
if t_num > 0 and t_den > 0 and (t_num, t_den) != (1, 1):
|
||||
tu = [t_num, t_den]
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return dur, dot, tu
|
||||
|
||||
|
||||
def _beat_secs(beat_el: ET.Element, rhythms_dict: dict, tempo_bpm: float) -> float:
|
||||
"""Duration of a GPIF beat in seconds, honouring the full dot count.
|
||||
|
||||
Mirrors ``gp2rs_gpx._beat_dur_secs`` except for double dots: that helper
|
||||
applies ×1.5 for any ``<AugmentationDot>`` regardless of its ``count``
|
||||
attribute, which would make a written ``dot: 2`` disagree with the
|
||||
emitted absolute beat times (overlapping engraving). Here a single dot
|
||||
is ×1.5 and a double dot ×1.75, so the notation walk stays
|
||||
self-consistent; for the rare double-dotted keys beat this intentionally
|
||||
diverges from the RS-XML walk's single-dot approximation.
|
||||
"""
|
||||
from gp2rs_gpx import _NOTE_VALUE_QN
|
||||
|
||||
dur_qn = 0.25
|
||||
rref = beat_el.find("Rhythm")
|
||||
if rref is not None:
|
||||
rhythm = rhythms_dict.get(rref.get("ref", ""))
|
||||
if rhythm is not None:
|
||||
nv = rhythm.findtext("NoteValue", "Quarter")
|
||||
dur_qn = _NOTE_VALUE_QN.get(nv, 0.25)
|
||||
dot_el = rhythm.find("AugmentationDot")
|
||||
if dot_el is not None:
|
||||
try:
|
||||
count = int(dot_el.get("count", 1))
|
||||
except (TypeError, ValueError):
|
||||
count = 1
|
||||
dur_qn *= 1.75 if count >= 2 else 1.5
|
||||
tuplet = rhythm.find("PrimaryTuplet")
|
||||
if tuplet is not None:
|
||||
try:
|
||||
num = int(tuplet.get("num", 1))
|
||||
den = int(tuplet.get("den", 1))
|
||||
if num and den:
|
||||
dur_qn *= den / num
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return dur_qn * (60.0 / tempo_bpm)
|
||||
|
||||
|
||||
def _masterbar_ks(mb: ET.Element) -> int | None:
|
||||
"""Key signature (semitones from C, −7…+7) from a MasterBar, or None."""
|
||||
key_el = mb.find("Key")
|
||||
if key_el is None:
|
||||
return None
|
||||
raw = key_el.findtext("AccidentalCount")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
ks = int(raw.strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return ks if -7 <= ks <= 7 else None
|
||||
|
||||
|
||||
def _walk_track_beats(
|
||||
root: ET.Element,
|
||||
raw_idx: int,
|
||||
string_pitches: list[int],
|
||||
*,
|
||||
audio_offset: float,
|
||||
force_staff: str | None,
|
||||
) -> list[dict[str, list[list[dict]]]]:
|
||||
"""Walk one raw track bar-by-bar and bucket its beats per staff.
|
||||
|
||||
Returns one entry per masterbar: ``{staff_id: [voice_beats, ...]}`` where
|
||||
each ``voice_beats`` is the ordered beat list of one GP voice. Timing
|
||||
mirrors ``gp2rs_gpx.convert_file`` (bar-indexed tempo map applied at bar
|
||||
starts, ``_beat_secs`` per beat) so notation lines up with the RS XML
|
||||
(modulo the double-dot fix documented on ``_beat_secs``).
|
||||
"""
|
||||
# Local import keeps lib's flat-import convention and avoids a hard cycle
|
||||
# (gp2rs_gpx imports this module lazily from inside convert_file).
|
||||
from gp2rs_gpx import (
|
||||
_build_tempo_map, _gpif_tempo, _note_is_tie, _note_midi, _notes_by_id,
|
||||
)
|
||||
|
||||
masterbars = _children(root, "MasterBars")
|
||||
bars_by_id = {b.get("id"): b for b in _children(root, "Bars")}
|
||||
voices_dict = {v.get("id"): v for v in _children(root, "Voices")}
|
||||
beats_dict = {b.get("id"): b for b in _children(root, "Beats")}
|
||||
rhythms_dict = {r.get("id"): r for r in _children(root, "Rhythms")}
|
||||
# Same duplicate-id-tolerant note lookup convert_file uses.
|
||||
notes_dict = _notes_by_id(root)
|
||||
|
||||
tempo_bpm = _gpif_tempo(root)
|
||||
tempo_iter = iter(_build_tempo_map(root))
|
||||
next_tempo_bar, next_tempo_bpm = next(tempo_iter, (999999, tempo_bpm))
|
||||
cur_tempo = tempo_bpm
|
||||
|
||||
out: list[dict[str, list[list[dict]]]] = []
|
||||
current_time = 0.0
|
||||
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tempo_bar:
|
||||
cur_tempo = next_tempo_bpm
|
||||
next_tempo_bar, next_tempo_bpm = next(tempo_iter, (999999, cur_tempo))
|
||||
|
||||
time_sig = mb.findtext("Time", "4/4")
|
||||
try:
|
||||
num_b, den_b = [int(x) for x in time_sig.split("/")]
|
||||
except ValueError:
|
||||
num_b, den_b = 4, 4
|
||||
# A malformed-but-parseable signature like "4/0" or "-3/4" would
|
||||
# divide by zero / run time backwards below.
|
||||
if num_b <= 0 or den_b <= 0:
|
||||
log.warning("gp2notation: invalid time signature %r — assuming 4/4", time_sig)
|
||||
num_b, den_b = 4, 4
|
||||
bar_duration = num_b * (4.0 / den_b) * (60.0 / cur_tempo)
|
||||
|
||||
per_staff: dict[str, list[list[dict]]] = {}
|
||||
bar_ids = mb.findtext("Bars", "").split()
|
||||
bid = bar_ids[raw_idx] if raw_idx < len(bar_ids) else "-1"
|
||||
bar = bars_by_id.get(bid) if bid not in ("-1", "") else None
|
||||
if bar is not None:
|
||||
for voice_pos, vid in enumerate(bar.findtext("Voices", "").split()):
|
||||
if vid == "-1":
|
||||
continue
|
||||
voice = voices_dict.get(vid)
|
||||
if voice is None:
|
||||
continue
|
||||
|
||||
# PR #703's voice→staff rule: GP voice position 0 = right
|
||||
# hand (treble), positions ≥ 1 = left hand (bass); a forced
|
||||
# staff (merged/standalone LH track) overrides both.
|
||||
staff = force_staff or ("rh" if voice_pos == 0 else "lh")
|
||||
|
||||
voice_beats: list[dict] = []
|
||||
voice_time = current_time
|
||||
for beat_id in voice.findtext("Beats", "").split():
|
||||
beat_el = beats_dict.get(beat_id)
|
||||
if beat_el is None:
|
||||
continue
|
||||
dur_secs = _beat_secs(beat_el, rhythms_dict, cur_tempo)
|
||||
# Sub-32nd rhythms can't be written in schema v1: drop the
|
||||
# beat (warning) but advance time by its true span so the
|
||||
# rest of the bar stays aligned with the RS-XML walk.
|
||||
rref = beat_el.find("Rhythm")
|
||||
rhythm = rhythms_dict.get(rref.get("ref", "")) if rref is not None else None
|
||||
nv = rhythm.findtext("NoteValue", "Quarter") if rhythm is not None else "Quarter"
|
||||
if nv in _SUB_FLOOR_NOTE_VALUES:
|
||||
log.warning(
|
||||
"gp2notation: dropping %s beat at %.3fs — below the "
|
||||
"schema's 32nd floor (v1 non-feature)",
|
||||
nv, voice_time + audio_offset,
|
||||
)
|
||||
voice_time += dur_secs
|
||||
continue
|
||||
dur, dot, tu = _rhythm_fields(beat_el, rhythms_dict)
|
||||
|
||||
beat_out: dict = {
|
||||
"t": round(voice_time + audio_offset, 3),
|
||||
"dur": dur,
|
||||
}
|
||||
if dot:
|
||||
beat_out["dot"] = dot
|
||||
if tu:
|
||||
beat_out["tu"] = tu
|
||||
|
||||
notes_out: list[dict] = []
|
||||
for nid in beat_el.findtext("Notes", "").strip().split():
|
||||
note_el = notes_dict.get(nid)
|
||||
if note_el is None:
|
||||
continue
|
||||
# GP pitch is absolute for piano-family tracks:
|
||||
# String+Fret resolves via the track's string-template
|
||||
# pitches (string_pitches[idx] + fret, concert pitch)
|
||||
# and Tone+Octave is (octave+1)*12 + step semitone —
|
||||
# both yield a real MIDI number, no tuning offset.
|
||||
midi = _note_midi(note_el, string_pitches)
|
||||
if midi is None or not 0 <= midi <= 127:
|
||||
continue
|
||||
note_out: dict = {"midi": midi}
|
||||
# Unlike the RS-XML walk (which drops tie destinations
|
||||
# and extends the origin's sustain), notation keeps
|
||||
# tied continuations as their own beats — engraving
|
||||
# needs the tied notehead.
|
||||
if _note_is_tie(note_el):
|
||||
note_out["tied"] = True
|
||||
notes_out.append(note_out)
|
||||
|
||||
if notes_out:
|
||||
beat_out["notes"] = notes_out
|
||||
else:
|
||||
# Authored rest, or every note failed pitch extraction.
|
||||
beat_out["rest"] = True
|
||||
voice_beats.append(beat_out)
|
||||
voice_time += dur_secs
|
||||
|
||||
if voice_beats:
|
||||
per_staff.setdefault(staff, []).append(voice_beats)
|
||||
|
||||
out.append(per_staff)
|
||||
current_time += bar_duration
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def convert_track_to_notation(
|
||||
root: ET.Element,
|
||||
raw_idx: int,
|
||||
string_pitches: list[int],
|
||||
*,
|
||||
instrument: str = "piano",
|
||||
audio_offset: float = 0.0,
|
||||
track_name: str = "",
|
||||
lh_raw_idx: int | None = None,
|
||||
lh_string_pitches: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Convert one GPIF keys/piano track to a notation payload (spec §5.3).
|
||||
|
||||
Args:
|
||||
root: Parsed ``score.gpif`` element (``gp2rs_gpx._load_gpif`` output).
|
||||
raw_idx: Raw track index into MasterBar ``Bars`` id lists (the same
|
||||
index ``convert_file`` derives via ``filtered_to_raw``).
|
||||
string_pitches: The track's string-template tuning (may be empty for
|
||||
Tone+Octave-encoded tracks).
|
||||
instrument: Self-describing instrument name for the payload.
|
||||
audio_offset: Seconds added to every emitted time (audio sync).
|
||||
track_name: Used only to detect a standalone forced-LH track
|
||||
(name ending in "LH" → every voice routes to the lh staff).
|
||||
lh_raw_idx / lh_string_pitches: When a Piano LH/RH pair was merged
|
||||
(``gp2rs_gpx._find_piano_pairs``), the LH partner's raw index and
|
||||
tuning — its beats are walked separately and forced onto ``lh``.
|
||||
|
||||
Returns the validated notation dict (``version``/``instrument``/
|
||||
``staves``/``measures``). Raises ``ValueError`` if the built payload
|
||||
fails ``notation.validate_notation`` (importer bug guard).
|
||||
"""
|
||||
from gp2rs_gpx import _build_tempo_map, _gpif_tempo
|
||||
|
||||
force_staff = "lh" if (track_name and _LH_NAME_RE.search(track_name)) else None
|
||||
walked = _walk_track_beats(
|
||||
root, raw_idx, string_pitches,
|
||||
audio_offset=audio_offset, force_staff=force_staff,
|
||||
)
|
||||
if lh_raw_idx is not None:
|
||||
lh_walked = _walk_track_beats(
|
||||
root, lh_raw_idx, lh_string_pitches or [],
|
||||
audio_offset=audio_offset, force_staff="lh",
|
||||
)
|
||||
# Merge the LH partner's voices into each measure's lh staff, after
|
||||
# any voices the main track already routed there.
|
||||
for main_bar, lh_bar in zip(walked, lh_walked):
|
||||
for staff_id, voices in lh_bar.items():
|
||||
main_bar.setdefault(staff_id, []).extend(voices)
|
||||
|
||||
masterbars = _children(root, "MasterBars")
|
||||
tempo_bpm = _gpif_tempo(root)
|
||||
tempo_iter = iter(_build_tempo_map(root))
|
||||
next_tempo_bar, next_tempo_bpm = next(tempo_iter, (999999, tempo_bpm))
|
||||
cur_tempo = tempo_bpm
|
||||
|
||||
measures: list[dict] = []
|
||||
used_staves: set[str] = set()
|
||||
current_time = 0.0
|
||||
last_ts: tuple[int, int] | None = None
|
||||
last_tempo: float | None = None
|
||||
last_ks: int | None = None
|
||||
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tempo_bar:
|
||||
cur_tempo = next_tempo_bpm
|
||||
next_tempo_bar, next_tempo_bpm = next(tempo_iter, (999999, cur_tempo))
|
||||
|
||||
time_sig = mb.findtext("Time", "4/4")
|
||||
try:
|
||||
num_b, den_b = [int(x) for x in time_sig.split("/")]
|
||||
except ValueError:
|
||||
num_b, den_b = 4, 4
|
||||
if num_b <= 0 or den_b <= 0:
|
||||
log.warning("gp2notation: invalid time signature %r — assuming 4/4", time_sig)
|
||||
num_b, den_b = 4, 4
|
||||
|
||||
measure: dict = {
|
||||
"idx": mb_idx + 1,
|
||||
"t": round(current_time + audio_offset, 3),
|
||||
}
|
||||
if (num_b, den_b) != last_ts:
|
||||
measure["ts"] = [num_b, den_b]
|
||||
groups = beat_groups_for(num_b, den_b)
|
||||
if groups:
|
||||
measure["beat_groups"] = groups
|
||||
last_ts = (num_b, den_b)
|
||||
if cur_tempo != last_tempo:
|
||||
measure["tempo"] = cur_tempo
|
||||
last_tempo = cur_tempo
|
||||
ks = _masterbar_ks(mb)
|
||||
if ks is not None and ks != last_ks:
|
||||
measure["ks"] = ks
|
||||
last_ks = ks
|
||||
|
||||
staves_payload: dict[str, dict] = {}
|
||||
for staff_id in ("rh", "lh"): # stable staff order
|
||||
voices = (walked[mb_idx] if mb_idx < len(walked) else {}).get(staff_id)
|
||||
if not voices:
|
||||
continue
|
||||
used_staves.add(staff_id)
|
||||
staves_payload[staff_id] = {
|
||||
"voices": [
|
||||
{"v": v_num, "beats": beats}
|
||||
for v_num, beats in enumerate(voices, start=1)
|
||||
],
|
||||
}
|
||||
measure["staves"] = staves_payload
|
||||
measures.append(measure)
|
||||
current_time += num_b * (4.0 / den_b) * (60.0 / cur_tempo)
|
||||
|
||||
staves = [_STAFF_DEFS[s] for s in ("rh", "lh") if s in used_staves]
|
||||
if not staves:
|
||||
staves = [_STAFF_DEFS["rh"]] # empty track — still a valid grand-staff stub
|
||||
|
||||
payload = {
|
||||
"version": notation_mod.SCHEMA_VERSION,
|
||||
"instrument": instrument,
|
||||
"staves": staves,
|
||||
"measures": measures,
|
||||
}
|
||||
ok, reason = notation_mod.validate_notation(payload)
|
||||
if not ok:
|
||||
raise ValueError(f"gp2notation built an invalid payload: {reason}")
|
||||
return payload
|
||||
|
||||
|
||||
# ── Sidecar + manifest wiring ─────────────────────────────────────────────────
|
||||
|
||||
def notation_sidecar_path(xml_path: str | Path) -> Path:
|
||||
"""The notation sidecar written next to a converted arrangement XML.
|
||||
|
||||
``Foo_Keys.xml`` → ``Foo_Keys.notation.json``. Arrangement ids don't
|
||||
exist yet at convert time (they're assigned when the sloppak manifest is
|
||||
assembled), so the sidecar pairs with the XML by filename stem; the
|
||||
assembly step renames it to ``notation_<id>.json`` via
|
||||
``attach_notation_to_sloppak``.
|
||||
"""
|
||||
p = Path(xml_path)
|
||||
return p.with_name(p.stem + ".notation.json")
|
||||
|
||||
|
||||
def write_notation_sidecar(xml_path: str | Path, payload: dict) -> Path:
|
||||
"""Validate and write the notation sidecar for a converted XML."""
|
||||
ok, reason = notation_mod.validate_notation(payload)
|
||||
if not ok:
|
||||
raise ValueError(f"refusing to write invalid notation sidecar: {reason}")
|
||||
side = notation_sidecar_path(xml_path)
|
||||
side.write_text(json.dumps(payload, separators=(",", ":")), encoding="utf-8")
|
||||
return side
|
||||
|
||||
|
||||
def attach_notation_to_sloppak(sloppak_dir: str | Path, arr_id: str, payload: dict) -> Path:
|
||||
"""Write ``notation_<arr_id>.json`` into a directory-form sloppak and add
|
||||
the ``notation:`` sub-key to that arrangement's manifest entry.
|
||||
|
||||
Raises ``ValueError`` on an invalid payload, an unsafe/unknown
|
||||
arrangement id, or a manifest without a matching arrangement entry.
|
||||
Note: the manifest is round-tripped through PyYAML (``safe_load`` +
|
||||
``safe_dump(sort_keys=False)``) — key order is preserved but comments
|
||||
and custom formatting are lost.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
ok, reason = notation_mod.validate_notation(payload)
|
||||
if not ok:
|
||||
raise ValueError(f"invalid notation payload: {reason}")
|
||||
if not arr_id or not re.fullmatch(r"[A-Za-z0-9_-]+", arr_id):
|
||||
raise ValueError(f"unsafe arrangement id for notation filename: {arr_id!r}")
|
||||
|
||||
pak = Path(sloppak_dir)
|
||||
manifest_path = pak / "manifest.yaml"
|
||||
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError(f"{manifest_path} is not a mapping")
|
||||
|
||||
entry = next(
|
||||
(e for e in (manifest.get("arrangements") or [])
|
||||
if isinstance(e, dict) and e.get("id") == arr_id),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
raise ValueError(f"no arrangement with id {arr_id!r} in {manifest_path}")
|
||||
|
||||
filename = f"notation_{arr_id}.json"
|
||||
(pak / filename).write_text(
|
||||
json.dumps(payload, separators=(",", ":")), encoding="utf-8"
|
||||
)
|
||||
entry["notation"] = filename
|
||||
manifest_path.write_text(
|
||||
yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return pak / filename
|
||||
Reference in New Issue
Block a user