mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 05:14:29 +00:00
Merge branch 'main' into chore/deprecate-sloppak-to-feedpak
# Conflicts: # lib/sloppak.py
This commit is contained in:
@@ -522,6 +522,10 @@ def attach_notation_to_sloppak(sloppak_dir: str | Path, arr_id: str, payload: di
|
||||
json.dumps(payload, separators=(",", ":")), encoding="utf-8"
|
||||
)
|
||||
entry["notation"] = filename
|
||||
# Stamp the format version while we're rewriting the manifest (spec §4),
|
||||
# without downgrading an existing (possibly higher) declared version.
|
||||
from sloppak import FEEDPAK_VERSION
|
||||
manifest.setdefault("feedpak_version", FEEDPAK_VERSION)
|
||||
manifest_path.write_text(
|
||||
yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
|
||||
+177
-30
@@ -1,5 +1,6 @@
|
||||
"""Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -56,6 +57,8 @@ class RsNote:
|
||||
fret: int
|
||||
sustain: float = 0.0
|
||||
bend: float = 0.0
|
||||
bend_intent: int = 0
|
||||
bend_values: list | None = None
|
||||
slide_to: int = -1
|
||||
slide_unpitch_to: int = -1
|
||||
hammer_on: bool = False
|
||||
@@ -191,6 +194,67 @@ def _duration_to_seconds(duration: guitarpro.Duration, tempo: float) -> float:
|
||||
return beats * (60.0 / tempo)
|
||||
|
||||
|
||||
# pyguitarpro models bend-point x-positions on 0..BendEffect.maxPosition (12)
|
||||
# across the note's duration; y-values are half-quarter-tone units where 12 = 6
|
||||
# semitones, so semitones = value / 2.0 (matches the scalar `bend` derivation).
|
||||
_GP_BEND_MAX_POSITION = 12
|
||||
|
||||
|
||||
def _bend_intent_from_values(values: list[float]) -> int:
|
||||
"""Classify a bend gesture (§6.2.1) from its time-ordered semitone values:
|
||||
0 up, 1 release, 2 pre-bend, 3 pre-bend-and-release, 4 round-trip."""
|
||||
if not values:
|
||||
return 0
|
||||
eps = 0.05
|
||||
first, last, peak = values[0], values[-1], max(values)
|
||||
if first > eps:
|
||||
if last <= eps:
|
||||
return 3 # pre-bent, then released to pitch
|
||||
if last < first - eps:
|
||||
return 1 # held bend let down
|
||||
return 2 # pre-bend held
|
||||
if peak > eps and last <= eps:
|
||||
return 4 # bend up and back down
|
||||
return 0 # plain bend up
|
||||
|
||||
|
||||
def _gp_bend_shape(bend, duration_secs: float):
|
||||
"""From a pyguitarpro ``BendEffect``, return ``(peak, intent, curve)``.
|
||||
|
||||
``peak`` is the bend's peak in semitones (the scalar ``bn``); ``intent`` is
|
||||
the §6.2.1 ``bt`` code; ``curve`` is the time-stamped ``bnv`` list
|
||||
(``[{t: seconds-from-onset, v: semitones}]``) or ``None`` when there's no
|
||||
usable shape (no points, or a zero-length note collapsing every point to
|
||||
``t=0``)."""
|
||||
pts = sorted(bend.points or [], key=lambda p: p.position)
|
||||
if not pts:
|
||||
return 0.0, 0, None
|
||||
values = [round(p.value / 2.0, 1) for p in pts]
|
||||
peak = round(max(values), 1)
|
||||
intent = _bend_intent_from_values(values)
|
||||
curve = None
|
||||
if duration_secs > 0 and len(pts) >= 2:
|
||||
curve = [
|
||||
{"t": round(duration_secs * (p.position / _GP_BEND_MAX_POSITION), 3),
|
||||
"v": v}
|
||||
for p, v in zip(pts, values)
|
||||
]
|
||||
return peak, intent, curve
|
||||
|
||||
|
||||
def _bend_shape_xml_attrs(n: "RsNote") -> dict:
|
||||
"""Optional bend-shape XML attributes for a <note>/<chordNote>, default-
|
||||
omitted: `bendIntent` only when non-zero, `bendValues` (a JSON-encoded
|
||||
[{t,v}] curve) only when present. `_parse_note` (lib/song.py) reads these
|
||||
back so a GP-imported bend curve survives import → wire → highway."""
|
||||
attrs: dict = {}
|
||||
if n.bend_intent:
|
||||
attrs["bendIntent"] = str(int(n.bend_intent))
|
||||
if n.bend_values:
|
||||
attrs["bendValues"] = json.dumps(n.bend_values, separators=(",", ":"))
|
||||
return attrs
|
||||
|
||||
|
||||
def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float:
|
||||
"""Get the tempo at a given tick."""
|
||||
result = tempo_map[0].tempo
|
||||
@@ -460,6 +524,56 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int:
|
||||
return num_strings - gp_string
|
||||
|
||||
|
||||
def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]:
|
||||
"""Per-string fingering for a chord template, in RS string order.
|
||||
|
||||
pyguitarpro exposes the chord-diagram voicing on ``beat.effect.chord``:
|
||||
``chord.strings`` is a per-string fret list indexed 0 = highest string
|
||||
(GP string 1), -1 = unplayed; ``chord.fingerings`` is the parallel list
|
||||
of :class:`guitarpro.Fingering` enums (``open=-1, thumb=0, index=1,
|
||||
middle=2, annular=3, little=4`` — already the RS finger integers). The
|
||||
fingerings list may carry one trailing extra entry, so we only read the
|
||||
first ``len(strings)`` of it.
|
||||
|
||||
Returns a list the same width as ``frets`` (RS string index 0 = low).
|
||||
Only strings that are actually played in this template (``frets[rs] >= 0``)
|
||||
get a finger; everything else stays -1. A chord without a populated
|
||||
voicing yields all -1, so diagram-less charts are unchanged.
|
||||
"""
|
||||
fingers = [-1] * len(frets)
|
||||
strings = getattr(chord, "strings", None) or []
|
||||
fingerings = getattr(chord, "fingerings", None) or []
|
||||
for i, fret in enumerate(strings):
|
||||
if fret is None or fret < 0:
|
||||
continue # string not part of the voicing
|
||||
rs = _gp_string_to_rs(i + 1, num_strings)
|
||||
if not (0 <= rs < len(frets)) or frets[rs] < 0:
|
||||
continue
|
||||
if i < len(fingerings):
|
||||
val = getattr(fingerings[i], "value", fingerings[i])
|
||||
fingers[rs] = val if isinstance(val, int) else -1
|
||||
return fingers
|
||||
|
||||
|
||||
def _chord_diagram_frets(chord, num_strings: int, width: int) -> list[int]:
|
||||
"""RS-string-ordered absolute frets of the chord DIAGRAM voicing, padded to
|
||||
``width`` with -1.
|
||||
|
||||
Used to confirm the diagram describes the voicing actually played before
|
||||
enriching a template — mirrors the GP8 exact fret-pattern guard. pyguitarpro
|
||||
stores absolute frets in ``chord.strings`` (``firstFret`` is display-only),
|
||||
so the result compares directly against the played ``frets``."""
|
||||
out = [-1] * width
|
||||
strings = getattr(chord, "strings", None) or []
|
||||
for i, fret in enumerate(strings):
|
||||
if fret is None or fret < 0:
|
||||
continue
|
||||
rs = _gp_string_to_rs(i + 1, num_strings)
|
||||
if 0 <= rs < width:
|
||||
out[rs] = fret
|
||||
return out
|
||||
|
||||
|
||||
def _is_bass_track(track: guitarpro.Track) -> bool:
|
||||
"""Detect whether a GP track is a bass.
|
||||
|
||||
@@ -685,12 +799,13 @@ def convert_track(
|
||||
# Techniques
|
||||
eff = note.effect
|
||||
if eff.bend and eff.bend.points:
|
||||
# pyguitarpro bend point values are in quarter-tones
|
||||
# (maxValue 12 = 3 whole tones = 6 semitones), so
|
||||
# semitones = value / 2. The old /100.0 made every bend
|
||||
# round to 0 (a whole-tone bend is value 4 -> 0.04).
|
||||
max_bend = max(p.value for p in eff.bend.points)
|
||||
rn.bend = round(max_bend / 2.0, 1)
|
||||
# `bn` is the peak; `bnv`/`bt` describe the shape over
|
||||
# time (§6.2.1). semitones = value / 2 (maxValue 12 = 6
|
||||
# semitones); the old /100.0 made every bend round to 0.
|
||||
peak, intent, curve = _gp_bend_shape(eff.bend, dur)
|
||||
rn.bend = peak
|
||||
rn.bend_intent = intent
|
||||
rn.bend_values = curve
|
||||
|
||||
if eff.hammer:
|
||||
# HO vs PO from pitch direction off the prior note on the
|
||||
@@ -828,17 +943,44 @@ def convert_track(
|
||||
fret_key = tuple(frets)
|
||||
|
||||
if fret_key not in chord_template_map:
|
||||
# Try to get chord name from GP
|
||||
chord_name = ""
|
||||
if beat.effect and beat.effect.chord:
|
||||
chord_name = beat.effect.chord.name or ""
|
||||
idx = len(chord_templates)
|
||||
chord_templates.append(ChordTemplate(
|
||||
name=chord_name,
|
||||
name="",
|
||||
frets=list(frets),
|
||||
fingers=[-1] * width,
|
||||
))
|
||||
chord_template_map[fret_key] = idx
|
||||
else:
|
||||
idx = chord_template_map[fret_key]
|
||||
|
||||
# Enrich the template from the GP chord diagram attached to
|
||||
# this beat — but ONLY when the diagram describes the voicing
|
||||
# actually played (same width-normalized fret pattern). A
|
||||
# mismatched chord label/diagram would otherwise mis-name /
|
||||
# finger the played template, and the back-fill would spread
|
||||
# it to other strums of the same played pattern. Mirrors the
|
||||
# GP8 exact fret-pattern guard.
|
||||
#
|
||||
# Name and fingers back-fill INDEPENDENTLY: a name-only first
|
||||
# annotation must not block a later beat that carries fingers
|
||||
# (and vice versa). Back-fill any still-blank field so the
|
||||
# data attaches regardless of which strum carries it.
|
||||
if beat.effect and beat.effect.chord:
|
||||
gpc = beat.effect.chord
|
||||
# Compare over the FULL string span (played width vs the
|
||||
# track's string count) so a diagram that frets an
|
||||
# extended string the played voicing doesn't use counts
|
||||
# as a mismatch instead of being silently trimmed.
|
||||
_w = max(len(frets), num_strings)
|
||||
_played = frets + [-1] * (_w - len(frets))
|
||||
if _chord_diagram_frets(gpc, num_strings, _w) == _played:
|
||||
ct = chord_templates[idx]
|
||||
if not ct.name and gpc.name:
|
||||
ct.name = gpc.name
|
||||
if all(f < 0 for f in ct.fingers):
|
||||
fingers = _chord_fingers(gpc, frets, num_strings)
|
||||
if any(f >= 0 for f in fingers):
|
||||
ct.fingers = fingers
|
||||
|
||||
rs_chords.append(RsChord(
|
||||
time=t,
|
||||
@@ -1021,6 +1163,7 @@ def _build_xml(
|
||||
"tap": "1" if n.tap else "0",
|
||||
"ignore": "0",
|
||||
}
|
||||
attrs.update(_bend_shape_xml_attrs(n))
|
||||
ET.SubElement(notes_el, "note", **attrs)
|
||||
|
||||
# Chords
|
||||
@@ -1031,25 +1174,29 @@ def _build_xml(
|
||||
chordId=str(ch.template_idx),
|
||||
highDensity="0", strum="down")
|
||||
for cn in ch.notes:
|
||||
ET.SubElement(chord_el, "chordNote",
|
||||
time=f"{cn.time:.3f}",
|
||||
string=str(cn.string),
|
||||
fret=str(cn.fret),
|
||||
sustain=f"{cn.sustain:.3f}",
|
||||
bend=f"{cn.bend:.1f}" if cn.bend else "0",
|
||||
hammerOn="1" if cn.hammer_on else "0",
|
||||
pullOff="1" if cn.pull_off else "0",
|
||||
slideTo=str(cn.slide_to),
|
||||
slideUnpitchTo=str(cn.slide_unpitch_to),
|
||||
harmonic="1" if cn.harmonic else "0",
|
||||
harmonicPinch="1" if cn.harmonic_pinch else "0",
|
||||
palmMute="1" if cn.palm_mute else "0",
|
||||
mute="1" if cn.mute else "0",
|
||||
vibrato="1" if cn.vibrato else "0",
|
||||
tremolo="1" if cn.tremolo else "0",
|
||||
accent="1" if cn.accent else "0",
|
||||
linkNext="1" if cn.link_next else "0",
|
||||
tap="1" if cn.tap else "0", ignore="0")
|
||||
cn_attrs = {
|
||||
"time": f"{cn.time:.3f}",
|
||||
"string": str(cn.string),
|
||||
"fret": str(cn.fret),
|
||||
"sustain": f"{cn.sustain:.3f}",
|
||||
"bend": f"{cn.bend:.1f}" if cn.bend else "0",
|
||||
"hammerOn": "1" if cn.hammer_on else "0",
|
||||
"pullOff": "1" if cn.pull_off else "0",
|
||||
"slideTo": str(cn.slide_to),
|
||||
"slideUnpitchTo": str(cn.slide_unpitch_to),
|
||||
"harmonic": "1" if cn.harmonic else "0",
|
||||
"harmonicPinch": "1" if cn.harmonic_pinch else "0",
|
||||
"palmMute": "1" if cn.palm_mute else "0",
|
||||
"mute": "1" if cn.mute else "0",
|
||||
"vibrato": "1" if cn.vibrato else "0",
|
||||
"tremolo": "1" if cn.tremolo else "0",
|
||||
"accent": "1" if cn.accent else "0",
|
||||
"linkNext": "1" if cn.link_next else "0",
|
||||
"tap": "1" if cn.tap else "0",
|
||||
"ignore": "0",
|
||||
}
|
||||
cn_attrs.update(_bend_shape_xml_attrs(cn))
|
||||
ET.SubElement(chord_el, "chordNote", **cn_attrs)
|
||||
|
||||
# Anchors
|
||||
anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors)))
|
||||
|
||||
+163
-15
@@ -445,6 +445,93 @@ def _gp6_element_variation_to_midi(element: int, variation: int) -> int | None:
|
||||
return _ART_TO_MIDI.get(art_id, art_id)
|
||||
|
||||
|
||||
# GPIF chord-diagram <Position finger="..."> names → RS finger integers,
|
||||
# matching the editor (E1) + gp2rs/pyguitarpro convention:
|
||||
# open/unused = -1, thumb = 0, index = 1, middle = 2, ring = 3, pinky = 4.
|
||||
_GPIF_FINGER_MAP = {
|
||||
'none': -1, 'open': -1, '': -1,
|
||||
'thumb': 0,
|
||||
'index': 1,
|
||||
'middle': 2,
|
||||
'ring': 3, 'annular': 3,
|
||||
'pinky': 4, 'little': 4,
|
||||
}
|
||||
|
||||
|
||||
def _rs_string_order(string_pitches: list[int]) -> dict[int, int]:
|
||||
"""Map each GPIF string index → RS string index (0 = lowest pitch).
|
||||
|
||||
Mirrors the per-note transform in ``convert_file`` (sort GPIF string
|
||||
indices by open pitch ascending, tiebreak on index, use the rank), so a
|
||||
chord diagram's string indices land on the same RS strings as the played
|
||||
notes regardless of format direction (GP6 .gpx high→low, GP8 .gp low→high).
|
||||
"""
|
||||
order = sorted(range(len(string_pitches)),
|
||||
key=lambda i: (string_pitches[i], i))
|
||||
return {gp: rs for rs, gp in enumerate(order)}
|
||||
|
||||
|
||||
def _parse_chord_diagrams(track_el, string_pitches: list[int]) -> dict:
|
||||
"""Map fret-pattern tuple → ``{'name', 'fingers'}`` from a track's diagrams.
|
||||
|
||||
GP7/GP8 GPIF stores authored chord diagrams per track under
|
||||
``Properties/Property[@name="DiagramCollection"]/Items/Item``. Each Item
|
||||
carries the chord name (its ``name`` attribute) and a ``<Diagram>`` with
|
||||
per-string ``<Fret string=.. fret=..>`` plus
|
||||
``<Fingering><Position finger=.. string=..></Fingering>``. Diagram string
|
||||
indices share the positional space of note ``String`` indices, so they go
|
||||
through the same pitch-rank transform; ``<Fret fret>`` is the absolute fret
|
||||
(``baseFret`` is display-only and not applied).
|
||||
|
||||
Keying by fret pattern (width-normalised to ≥6, exactly like the template
|
||||
build site) keeps the join key consistent with GP5 + the editor's
|
||||
preserve-by-fret-key (E0). Returns ``{}`` when there are no diagrams or no
|
||||
string tuning (orientation/width would be undefined).
|
||||
"""
|
||||
diagrams: dict[tuple, dict] = {}
|
||||
if track_el is None or not string_pitches:
|
||||
return diagrams
|
||||
gp_to_rs = _rs_string_order(string_pitches)
|
||||
for item in track_el.findall(
|
||||
'.//Property[@name="DiagramCollection"]/Items/Item'):
|
||||
diag = item.find('Diagram')
|
||||
if diag is None:
|
||||
continue
|
||||
rs_frets: dict[int, int] = {}
|
||||
for fr in diag.findall('Fret'):
|
||||
try:
|
||||
gp = int(fr.get('string'))
|
||||
fret = int(fr.get('fret'))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if fret < 0:
|
||||
continue
|
||||
rs = gp_to_rs.get(gp)
|
||||
if rs is not None:
|
||||
rs_frets[rs] = fret
|
||||
if not rs_frets:
|
||||
continue
|
||||
width = max(6, max(rs_frets) + 1)
|
||||
frets = [-1] * width
|
||||
fingers = [-1] * width
|
||||
for rs, fret in rs_frets.items():
|
||||
frets[rs] = fret
|
||||
for pos in diag.findall('Fingering/Position'):
|
||||
try:
|
||||
gp = int(pos.get('string'))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
rs = gp_to_rs.get(gp)
|
||||
if rs is None or not (0 <= rs < width) or frets[rs] < 0:
|
||||
continue
|
||||
fname = (pos.get('finger') or '').strip().lower()
|
||||
fingers[rs] = _GPIF_FINGER_MAP.get(fname, -1)
|
||||
# First diagram wins for a given voicing (stable, deterministic).
|
||||
diagrams.setdefault(tuple(frets),
|
||||
{'name': item.get('name', '') or '', 'fingers': fingers})
|
||||
return diagrams
|
||||
|
||||
|
||||
def _gpx_percussion_midis(track_el) -> list[int]:
|
||||
"""Flatten a drumKit ``InstrumentSet``'s articulations into a list of GM
|
||||
``OutputMidiNumber``s, positionally indexed to match a note's
|
||||
@@ -1060,6 +1147,59 @@ def _gpx_bend_scale(root: ET.Element) -> float:
|
||||
return 50.0 if peak <= 400 else 2500.0
|
||||
|
||||
|
||||
def _gpx_bend_float(tp: dict, name: str):
|
||||
"""Read a GPIF bend `<Property><Float>` value from the property map, or None."""
|
||||
el = tp.get(name)
|
||||
if el is None:
|
||||
return None
|
||||
try:
|
||||
return float(el.findtext('Float') or 0)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _gpx_bend_shape(tp: dict, divisor: float, sustain: float):
|
||||
"""Build ``(peak, intent, curve)`` from a GPIF note's bend Properties (§6.2.1).
|
||||
|
||||
GPIF describes a bend as origin / middle / destination value+offset pairs;
|
||||
`value / divisor` is semitones (divisor auto-detected per file) and the
|
||||
`*Offset` Properties are 0..100 (percent of the note's duration). Produces a
|
||||
bnv curve of up to three points (mapping each offset to seconds-from-onset),
|
||||
or ``None`` when there's no usable shape (no points, flat-zero, or a
|
||||
zero-length note). When an offset Property is absent the stage falls back to
|
||||
an evenly-spaced default (origin 0%, middle 50%, destination 100%).
|
||||
|
||||
NOTE: offset Property names should be confirmed against a real GP8 export;
|
||||
the value path matches the existing scalar-bend extraction either way."""
|
||||
from gp2rs import _bend_intent_from_values # lazy: gp2rs<->gpx circular
|
||||
stages = (
|
||||
('BendOriginValue', 'BendOriginOffset', 0.0),
|
||||
('BendMiddleValue', 'BendMiddleOffset1', 50.0),
|
||||
('BendDestinationValue', 'BendDestinationOffset', 100.0),
|
||||
)
|
||||
pts = []
|
||||
for vkey, okey, default_off in stages:
|
||||
v = _gpx_bend_float(tp, vkey)
|
||||
if v is None:
|
||||
continue
|
||||
off = _gpx_bend_float(tp, okey)
|
||||
if off is None:
|
||||
off = default_off
|
||||
off = max(0.0, min(100.0, off))
|
||||
pts.append((off, round(v / divisor, 1)))
|
||||
if not pts:
|
||||
return 0.0, 0, None
|
||||
pts.sort(key=lambda p: p[0])
|
||||
values = [v for _, v in pts]
|
||||
peak = round(max(values), 1)
|
||||
intent = _bend_intent_from_values(values)
|
||||
curve = None
|
||||
if peak > 0 and sustain > 0 and len(pts) >= 2:
|
||||
curve = [{"t": round(sustain * (off / 100.0), 3), "v": v}
|
||||
for off, v in pts]
|
||||
return peak, intent, curve
|
||||
|
||||
|
||||
def _resolve_pending_slides(rs_notes, rs_chords, pending_slides):
|
||||
"""Resolve GP slide flags collected during the beat loop into RS slide
|
||||
fields, now that every note on each string is known.
|
||||
@@ -1277,6 +1417,10 @@ def convert_file(
|
||||
rs_chords: list[RsChord] = []
|
||||
chord_templates: list[ChordTemplate] = []
|
||||
chord_template_map: dict[tuple, int] = {}
|
||||
# Authored chord diagrams (name + per-string fingering) for this track,
|
||||
# keyed by fret pattern so they enrich matching played voicings.
|
||||
chord_diagram_map = _parse_chord_diagrams(
|
||||
track.get('_el'), track['string_pitches'])
|
||||
beats_out: list[RsBeat] = []
|
||||
sections: list[RsSection] = []
|
||||
section_counts: dict[str, int] = {}
|
||||
@@ -1473,21 +1617,21 @@ def convert_file(
|
||||
rn.pull_off = True
|
||||
else:
|
||||
rn.hammer_on = True
|
||||
# Bend: peak amount (GPIF bend value → semitones,
|
||||
# scale auto-detected per file in _bend_divisor).
|
||||
# Bend: `bn` is the peak; `bnv`/`bt` capture
|
||||
# the shape over time (§6.2.1). value/divisor
|
||||
# = semitones (scale auto-detected per file).
|
||||
if 'Bended' in _tp:
|
||||
_bv = 0.0
|
||||
for _bk in ('BendDestinationValue',
|
||||
'BendMiddleValue', 'BendOriginValue'):
|
||||
_be = _tp.get(_bk)
|
||||
if _be is not None:
|
||||
try:
|
||||
_bv = max(_bv, float(
|
||||
_be.findtext('Float') or 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if _bv > 0:
|
||||
rn.bend = round(_bv / _bend_divisor, 1)
|
||||
# Use the beat duration `dur`, not
|
||||
# `rn.sustain` (zeroed for notes <= 0.2s),
|
||||
# so short bends keep their bnv curve —
|
||||
# matching the GP5 path, which maps over
|
||||
# the raw note duration.
|
||||
_peak, _intent, _curve = _gpx_bend_shape(
|
||||
_tp, _bend_divisor, dur)
|
||||
if _peak > 0:
|
||||
rn.bend = _peak
|
||||
rn.bend_intent = _intent
|
||||
rn.bend_values = _curve
|
||||
# Slide flags: 1/2 = pitched slide to the next
|
||||
# note; 4 = slide out down, 8 = out up. Resolved
|
||||
# post-loop (needs the next note on the string).
|
||||
@@ -1533,8 +1677,12 @@ def convert_file(
|
||||
fkey = tuple(frets_t)
|
||||
if fkey not in chord_template_map:
|
||||
chord_template_map[fkey] = len(chord_templates)
|
||||
_diag = chord_diagram_map.get(fkey)
|
||||
chord_templates.append(ChordTemplate(
|
||||
name='', frets=list(frets_t), fingers=[-1] * width,
|
||||
name=(_diag['name'] if _diag else ''),
|
||||
frets=list(frets_t),
|
||||
fingers=(list(_diag['fingers']) if _diag
|
||||
else [-1] * width),
|
||||
))
|
||||
rs_chords.append(RsChord(
|
||||
time=t,
|
||||
|
||||
+227
-7
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
@@ -32,6 +33,11 @@ from pathlib import Path
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.sloppak")
|
||||
|
||||
# The feedpak format version this build targets / writes (manifest
|
||||
# `feedpak_version`, a semver string per spec §4). Readers tolerate any version
|
||||
# (additive/MINOR compatibility); writers stamp this.
|
||||
FEEDPAK_VERSION = "1.2.0"
|
||||
|
||||
import yaml
|
||||
|
||||
from safepath import safe_join
|
||||
@@ -42,6 +48,7 @@ from song import (
|
||||
Arrangement,
|
||||
arrangement_from_wire,
|
||||
_finite_float,
|
||||
sanitize_tempos,
|
||||
)
|
||||
import drums as drums_mod
|
||||
import notation as notation_mod
|
||||
@@ -82,6 +89,27 @@ def is_sloppak(path: Path) -> bool:
|
||||
_source_cache: dict[str, tuple[Path, float, int]] = {}
|
||||
_source_lock = threading.Lock()
|
||||
|
||||
# Full-archive unpacks (zip form) are expensive — they write every stem to
|
||||
# disk. Cap how many run at once so a burst (e.g. many plays queued, or a stray
|
||||
# caller looping the library) can't saturate disk/CPU, and serialize per-file so
|
||||
# two callers never rmtree + re-extract the same dest simultaneously (which
|
||||
# would corrupt the half-written dir the other is reading).
|
||||
_UNPACK_MAX_CONCURRENCY = 2
|
||||
_unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY)
|
||||
_unpack_locks: dict[str, threading.Lock] = {}
|
||||
_unpack_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
def _unpack_lock_for(filename: str) -> threading.Lock:
|
||||
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
|
||||
serialize instead of racing on the same destination dir."""
|
||||
with _unpack_locks_guard:
|
||||
lk = _unpack_locks.get(filename)
|
||||
if lk is None:
|
||||
lk = threading.Lock()
|
||||
_unpack_locks[filename] = lk
|
||||
return lk
|
||||
|
||||
|
||||
def _unpack_zip(zip_path: Path, dest: Path) -> None:
|
||||
"""Extract a sloppak zip archive into dest, replacing any previous contents.
|
||||
@@ -154,10 +182,26 @@ def resolve_source_dir(
|
||||
if path.is_dir():
|
||||
resolved = path
|
||||
else:
|
||||
# Zip form — unpack to the cache.
|
||||
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||
dest = unpack_cache_root / _safe_id(filename)
|
||||
_unpack_zip(path, dest)
|
||||
resolved = dest
|
||||
with _unpack_lock_for(filename):
|
||||
# Re-check the cache inside the per-file lock — a prior holder may
|
||||
# have just finished unpacking this exact (mtime, size).
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
if (
|
||||
cached
|
||||
and cached[1] == mtime
|
||||
and cached[2] == size
|
||||
and cached[0].exists()
|
||||
):
|
||||
resolved = cached[0]
|
||||
else:
|
||||
with _unpack_semaphore:
|
||||
_unpack_zip(path, dest)
|
||||
resolved = dest
|
||||
|
||||
with _source_lock:
|
||||
_source_cache[filename] = (resolved, mtime, size)
|
||||
@@ -224,6 +268,97 @@ def read_feedpak_version(manifest: dict) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
_COVER_MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".webp": "image/webp",
|
||||
}
|
||||
|
||||
|
||||
def _cover_media_type(name: str) -> str:
|
||||
return _COVER_MEDIA_TYPES.get(Path(name).suffix.lower(), "image/jpeg")
|
||||
|
||||
|
||||
def read_cover_bytes(
|
||||
path: Path, manifest: dict | None = None
|
||||
) -> tuple[bytes, str] | None:
|
||||
"""Return ``(image_bytes, media_type)`` for a sloppak's cover, or ``None``.
|
||||
|
||||
Reads ONLY the cover image. For a zipped sloppak this opens the single
|
||||
cover member rather than unpacking the whole archive (stems included), so
|
||||
serving album art on the library grid never triggers a full extraction —
|
||||
the dominant cost behind slow cover loading on scroll.
|
||||
"""
|
||||
try:
|
||||
if manifest is None:
|
||||
manifest = load_manifest(path)
|
||||
except Exception:
|
||||
manifest = {}
|
||||
cover_rel = str((manifest or {}).get("cover") or "cover.jpg")
|
||||
|
||||
if path.is_dir():
|
||||
# Directory form — read the file, guarding against escape.
|
||||
cover_path = (path / cover_rel).resolve()
|
||||
try:
|
||||
cover_path.relative_to(path.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
if cover_path.is_file():
|
||||
try:
|
||||
return cover_path.read_bytes(), _cover_media_type(cover_path.name)
|
||||
except OSError as e:
|
||||
log.warning("sloppak: failed to read cover %r: %s", cover_path, e)
|
||||
return None
|
||||
|
||||
# Zip form — read just the cover member, no unpack. Normalize the manifest
|
||||
# name the way the filesystem would (collapse './' and 'a/../b', backslash →
|
||||
# slash) so a non-canonical-but-valid cover like './cover.jpg' still resolves
|
||||
# to the archive member 'cover.jpg' — matching the old unpack-then-resolve
|
||||
# behavior — and reject zip-slip escape before opening.
|
||||
_zip_root = Path("/_root").resolve()
|
||||
safe = safe_join(_zip_root, cover_rel)
|
||||
# `safe is None` → escape; `safe == _zip_root` → a degenerate name like "."
|
||||
# or "subdir/.." that collapses to the root (member would be "."). Reject
|
||||
# both, mirroring _unpack_zip's degenerate-root guard.
|
||||
if safe is None or safe == _zip_root:
|
||||
log.warning("sloppak: rejected unsafe cover name %r in %r", cover_rel, path)
|
||||
return None
|
||||
member = safe.relative_to(_zip_root).as_posix()
|
||||
try:
|
||||
with zipfile.ZipFile(str(path), "r") as zf:
|
||||
try:
|
||||
data = zf.read(member)
|
||||
except KeyError:
|
||||
return None
|
||||
return data, _cover_media_type(member)
|
||||
except (OSError, zipfile.BadZipFile, RuntimeError) as e:
|
||||
log.warning("sloppak: failed to read cover from zip %r: %s", path, e)
|
||||
return None
|
||||
|
||||
|
||||
def _sanitize_time_signatures(events) -> list[dict]:
|
||||
"""Clean a time-signature event list (``[{time, ts:[num, den]}]``): keep
|
||||
entries with a finite non-bool ``time`` and a ``ts`` of two integers >= 1,
|
||||
sorted by time. Non-list / all-invalid input -> ``[]``."""
|
||||
out: list[dict] = []
|
||||
if isinstance(events, list):
|
||||
for ev in events:
|
||||
if not isinstance(ev, dict):
|
||||
continue
|
||||
t = ev.get("time")
|
||||
ts = ev.get("ts")
|
||||
if (not isinstance(t, (int, float)) or isinstance(t, bool)
|
||||
or not math.isfinite(t)):
|
||||
continue
|
||||
if not isinstance(ts, list) or len(ts) != 2:
|
||||
continue
|
||||
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 1
|
||||
for x in ts):
|
||||
continue
|
||||
out.append({"time": float(t), "ts": [int(ts[0]), int(ts[1])]})
|
||||
out.sort(key=lambda e: e["time"])
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoadedSloppak:
|
||||
"""Result of loading a sloppak: the Song object plus stem descriptors."""
|
||||
@@ -231,6 +366,9 @@ class LoadedSloppak:
|
||||
stems: list[dict] # [{"id": str, "file": str, "default": bool}]
|
||||
source_dir: Path
|
||||
manifest: dict
|
||||
# The pack's declared format version (manifest `feedpak_version`, a semver
|
||||
# string per spec §4). None when absent (legacy / pre-versioning packs).
|
||||
feedpak_version: str | None = None
|
||||
# Parsed `drum_tab.json` payload when the manifest carries a `drum_tab:`
|
||||
# key pointing at a readable, schema-valid file. None otherwise (older
|
||||
# sloppaks, sloppaks without drums, sloppaks whose drum tab failed to
|
||||
@@ -242,6 +380,18 @@ class LoadedSloppak:
|
||||
# When present, its beats/sections take priority over any beats/sections
|
||||
# embedded in the arrangement JSONs.
|
||||
song_timeline: dict | None = None
|
||||
# Parsed `keys.json` payload (manifest `keys:` key) — a song-level,
|
||||
# instrument-independent key/scale-change track (spec §7.7). None when
|
||||
# absent / unreadable / malformed. Streamed over the highway WS as a
|
||||
# `keys` message; consumers (renderers, plugins) read it from there.
|
||||
keys: dict | None = None
|
||||
# Sanitized song-level tempo + time-signature maps from `song_timeline.json`
|
||||
# (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}].
|
||||
# None when absent/empty. Streamed over the highway WS (`tempos` /
|
||||
# `time_signatures` messages); a per-chart arrangement `tempos` overrides
|
||||
# `tempos` for that chart (spec §6.10).
|
||||
tempos: list | None = None
|
||||
time_signatures: list | None = None
|
||||
# Maps arrangement id → validated notation payload. None when no
|
||||
# arrangement passed schema validation; a non-empty dict only when at least
|
||||
# one arrangement carried a `notation:` sub-key whose file loaded and passed
|
||||
@@ -252,9 +402,6 @@ class LoadedSloppak:
|
||||
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
|
||||
# absent so indexing by song.arrangements index is safe.
|
||||
arrangement_ids: list[str | None] = field(default_factory=list)
|
||||
# Declared `feedpak_version` from the manifest (semver string), or None when
|
||||
# absent. Per the feedpak spec §4.1 an absent value is treated as "1.0.0".
|
||||
feedpak_version: str | None = None
|
||||
|
||||
|
||||
def load_song(
|
||||
@@ -453,6 +600,8 @@ def load_song(
|
||||
# already loaded onto the song object — song_timeline is the authoritative
|
||||
# source for timeline data in sloppaks that carry it.
|
||||
song_timeline_data: dict | None = None
|
||||
tempos_data: list | None = None
|
||||
time_sigs_data: list | None = None
|
||||
song_timeline_rel = manifest.get("song_timeline")
|
||||
if isinstance(song_timeline_rel, str) and song_timeline_rel:
|
||||
try:
|
||||
@@ -535,6 +684,13 @@ def load_song(
|
||||
)
|
||||
continue
|
||||
song_timeline_data = raw
|
||||
# tempos / time_signatures (feedpak 1.2.0) are independent of the
|
||||
# beats/sections validation above — all are optional — so load them
|
||||
# whenever the payload parsed to a dict.
|
||||
if isinstance(raw, dict):
|
||||
tempos_data = sanitize_tempos(raw.get("tempos")) or None
|
||||
time_sigs_data = _sanitize_time_signatures(
|
||||
raw.get("time_signatures")) or None
|
||||
|
||||
# Optional shared lyrics file. Same safety posture as the drum_tab
|
||||
# loader above: constrain the manifest-declared path to source_dir
|
||||
@@ -625,16 +781,80 @@ def load_song(
|
||||
default_on = bool(default_val)
|
||||
stems.append({"id": sid, "file": sfile, "default": default_on})
|
||||
|
||||
# Optional keys.json — song-level, instrument-independent key/scale track
|
||||
# (manifest `keys:` key, spec §7.7). Permissive like the other side-files:
|
||||
# missing / unreadable / malformed -> None, never fatal. Stored as a
|
||||
# sanitized {version, events:[{t, key, scale?}]} (finite t, non-empty string
|
||||
# key, sorted) so the highway WS can stream it without re-validating.
|
||||
keys_data: dict | None = None
|
||||
keys_rel = manifest.get("keys")
|
||||
if isinstance(keys_rel, str) and keys_rel:
|
||||
try:
|
||||
k_path = (source_dir / keys_rel).resolve()
|
||||
k_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: keys path %r escapes source_dir — skipped", keys_rel)
|
||||
k_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: keys path resolution failed (%s) — skipped", e)
|
||||
k_path = None
|
||||
if k_path is not None and k_path.exists():
|
||||
try:
|
||||
raw = json.loads(k_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
log.warning("sloppak: failed to parse keys %r: %s", keys_rel, e)
|
||||
raw = None
|
||||
if raw is not None and not isinstance(raw, dict):
|
||||
log.warning("sloppak: keys %r ignored — expected dict, got %s",
|
||||
keys_rel, type(raw).__name__)
|
||||
elif isinstance(raw, dict):
|
||||
if not isinstance(raw.get("events"), list):
|
||||
log.warning("sloppak: keys %r ignored — 'events' must be a list", keys_rel)
|
||||
else:
|
||||
clean_events: list[dict] = []
|
||||
for ev in raw["events"]:
|
||||
if not isinstance(ev, dict):
|
||||
continue
|
||||
# Drop events with a missing / non-numeric / non-finite
|
||||
# time rather than silently rewriting them to 0.0 — a
|
||||
# bad `t` makes the whole event meaningless.
|
||||
t = ev.get("t")
|
||||
if (not isinstance(t, (int, float)) or isinstance(t, bool)
|
||||
or not math.isfinite(t)):
|
||||
continue
|
||||
t = float(t)
|
||||
key = ev.get("key")
|
||||
if not isinstance(key, str) or not key:
|
||||
continue
|
||||
entry = {"t": t, "key": key}
|
||||
scale = ev.get("scale")
|
||||
if isinstance(scale, str) and scale:
|
||||
entry["scale"] = scale
|
||||
clean_events.append(entry)
|
||||
clean_events.sort(key=lambda e: e["t"])
|
||||
# int only — a float version (incl. NaN/Inf, which json.loads
|
||||
# accepts) would raise on int(); default rather than abort the
|
||||
# load of an optional side-file.
|
||||
_ver = raw.get("version")
|
||||
keys_data = {
|
||||
"version": _ver if isinstance(_ver, int)
|
||||
and not isinstance(_ver, bool) else 1,
|
||||
"events": clean_events,
|
||||
}
|
||||
|
||||
return LoadedSloppak(
|
||||
song=song,
|
||||
stems=stems,
|
||||
source_dir=source_dir,
|
||||
manifest=manifest,
|
||||
feedpak_version=read_feedpak_version(manifest),
|
||||
drum_tab=drum_tab_data,
|
||||
song_timeline=song_timeline_data,
|
||||
tempos=tempos_data,
|
||||
time_signatures=time_sigs_data,
|
||||
keys=keys_data,
|
||||
notation_by_id=notation_by_id_data,
|
||||
arrangement_ids=arrangement_ids_acc,
|
||||
feedpak_version=read_feedpak_version(manifest),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+92
@@ -20,6 +20,13 @@ class Note:
|
||||
slide_to: int = -1
|
||||
slide_unpitch_to: int = -1
|
||||
bend: float = 0.0
|
||||
# Bend shape (§6.2.1, feedpak 1.4.0). `bend` stays the peak magnitude;
|
||||
# `bend_intent` is the gesture (0 up, 1 release, 2 pre-bend,
|
||||
# 3 pre-bend-release, 4 round-trip) and `bend_values` is the optional
|
||||
# time-stamped curve [{t: seconds-from-onset, v: semitones}], authoritative
|
||||
# when present. Both default-omitted on the wire; older readers ignore them.
|
||||
bend_intent: int = 0
|
||||
bend_values: list | None = None
|
||||
hammer_on: bool = False
|
||||
pull_off: bool = False
|
||||
harmonic: bool = False
|
||||
@@ -151,6 +158,10 @@ class Arrangement:
|
||||
# RS2014 custom song pitch-shift field (cents). Commonly -1200.0 (one octave
|
||||
# down) for extended-range bass arrangements. 0.0 when absent or zero.
|
||||
cent_offset: float = 0.0
|
||||
# Per-chart tempo override (§6.10): [{time, bpm}]. None when the chart
|
||||
# follows the song-level tempo; when present a Reader uses it for this
|
||||
# chart and ignores the song-level tempo.
|
||||
tempos: list | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -217,6 +228,16 @@ def note_to_wire(n: Note) -> dict:
|
||||
out["pkd"] = n.pick_direction
|
||||
if n.ignore:
|
||||
out["ig"] = True
|
||||
# Bend shape (§6.2.1) — default-omitted: `bt` only when non-zero, `bnv`
|
||||
# only when a curve is present. Mirrors the spec's "omit fields equal to
|
||||
# their default" so a plain bend stays a single `bn` scalar on the wire.
|
||||
if n.bend_intent:
|
||||
out["bt"] = int(n.bend_intent)
|
||||
if n.bend_values:
|
||||
out["bnv"] = [
|
||||
{"t": round(p["t"], 3), "v": round(p["v"], 1)}
|
||||
for p in n.bend_values
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
@@ -279,6 +300,33 @@ def _wire_int_optional(v, default=-1):
|
||||
return default
|
||||
|
||||
|
||||
def _sanitize_bend_curve(raw):
|
||||
"""Clean a time-stamped bend curve (``[{t, v}]``, §6.2.1): keep entries with
|
||||
a finite, non-bool numeric ``t`` and ``v``, coerced to float and sorted by
|
||||
``t``. Non-list / absent / all-invalid input -> ``None`` so an empty curve
|
||||
round-trips as *omitted*, never ``[]``. ``t`` is seconds from the note
|
||||
onset; ``v`` is semitones (same scale as the scalar ``bn`` peak)."""
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
out: list[dict] = []
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
t = p.get("t")
|
||||
v = p.get("v")
|
||||
if (not isinstance(t, (int, float)) or isinstance(t, bool)
|
||||
or not math.isfinite(t)):
|
||||
continue
|
||||
if (not isinstance(v, (int, float)) or isinstance(v, bool)
|
||||
or not math.isfinite(v)):
|
||||
continue
|
||||
out.append({"t": float(t), "v": float(v)})
|
||||
if not out:
|
||||
return None
|
||||
out.sort(key=lambda e: e["t"])
|
||||
return out
|
||||
|
||||
|
||||
def note_from_wire(d: dict, time: float | None = None) -> Note:
|
||||
return Note(
|
||||
time=float(d.get("t", time if time is not None else 0.0)),
|
||||
@@ -288,6 +336,8 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
|
||||
slide_to=int(d.get("sl", -1)),
|
||||
slide_unpitch_to=int(d.get("slu", -1)),
|
||||
bend=float(d.get("bn", 0.0)),
|
||||
bend_intent=_wire_int_optional(d.get("bt"), 0),
|
||||
bend_values=_sanitize_bend_curve(d.get("bnv")),
|
||||
hammer_on=bool(d.get("ho", False)),
|
||||
pull_off=bool(d.get("po", False)),
|
||||
harmonic=bool(d.get("hm", False)),
|
||||
@@ -585,6 +635,29 @@ def _finite_float(value, default: float = 0.0) -> float:
|
||||
return v if math.isfinite(v) else default
|
||||
|
||||
|
||||
def sanitize_tempos(events) -> list[dict]:
|
||||
"""Clean a tempo-event list (``[{time, bpm}]``): keep entries with a finite
|
||||
non-bool ``time`` and a finite ``bpm > 0``, coerced to float and sorted by
|
||||
time. Non-list / all-invalid input -> ``[]``. Shared by the per-chart
|
||||
arrangement ``tempos`` (§6.10) and the song-level ``song_timeline.tempos``."""
|
||||
out: list[dict] = []
|
||||
if isinstance(events, list):
|
||||
for ev in events:
|
||||
if not isinstance(ev, dict):
|
||||
continue
|
||||
t = ev.get("time")
|
||||
bpm = ev.get("bpm")
|
||||
if (not isinstance(t, (int, float)) or isinstance(t, bool)
|
||||
or not math.isfinite(t)):
|
||||
continue
|
||||
if (not isinstance(bpm, (int, float)) or isinstance(bpm, bool)
|
||||
or not math.isfinite(bpm) or bpm <= 0):
|
||||
continue
|
||||
out.append({"time": float(t), "bpm": float(bpm)})
|
||||
out.sort(key=lambda e: e["time"])
|
||||
return out
|
||||
|
||||
|
||||
def arrangement_to_wire(arr: Arrangement) -> dict:
|
||||
"""Serialize an Arrangement into a JSON-ready dict matching the wire format."""
|
||||
out = {
|
||||
@@ -612,6 +685,10 @@ def arrangement_to_wire(arr: Arrangement) -> dict:
|
||||
# "no tones".
|
||||
if arr.tones:
|
||||
out["tones"] = arr.tones
|
||||
# Per-chart tempo override (§6.10) — additive; omit when the chart follows
|
||||
# the song-level tempo (empty/None).
|
||||
if arr.tempos:
|
||||
out["tempos"] = list(arr.tempos)
|
||||
return out
|
||||
|
||||
|
||||
@@ -622,6 +699,7 @@ def arrangement_from_wire(d: dict) -> Arrangement:
|
||||
tuning=list(d.get("tuning", [0] * 6)),
|
||||
capo=int(d.get("capo", 0)),
|
||||
cent_offset=_finite_float(d.get("centOffset", 0.0)),
|
||||
tempos=(sanitize_tempos(d.get("tempos")) or None),
|
||||
notes=[note_from_wire(n) for n in d.get("notes", [])],
|
||||
chords=[chord_from_wire(c) for c in d.get("chords", [])],
|
||||
anchors=[
|
||||
@@ -736,6 +814,18 @@ def _chord_high_density(elem: ET.Element) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _parse_bend_values(n):
|
||||
"""Read a `bendValues` JSON attribute (GP import emits it; §6.2.1) and
|
||||
sanitize it into a [{t,v}] curve, or None when absent/malformed."""
|
||||
raw = n.get("bendValues")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return _sanitize_bend_curve(json.loads(raw))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_note(n) -> Note:
|
||||
return Note(
|
||||
time=_float(n, "time"),
|
||||
@@ -745,6 +835,8 @@ def _parse_note(n) -> Note:
|
||||
slide_to=_int(n, "slideTo", -1),
|
||||
slide_unpitch_to=_int(n, "slideUnpitchTo", -1),
|
||||
bend=_float(n, "bend"),
|
||||
bend_intent=_int(n, "bendIntent", 0),
|
||||
bend_values=_parse_bend_values(n),
|
||||
hammer_on=_bool(n, "hammerOn"),
|
||||
pull_off=_bool(n, "pullOff"),
|
||||
harmonic=_bool(n, "harmonic"),
|
||||
|
||||
@@ -49,6 +49,15 @@ def _apply_to_sloppak_manifest(manifest: dict, fields: dict) -> bool:
|
||||
if "year" in fields:
|
||||
manifest["year"] = _coerce_year(fields["year"])
|
||||
dirty = True
|
||||
# Opportunistically declare the format version (spec §4) when we're already
|
||||
# rewriting because a metadata field was supplied. Gated on `dirty` (i.e. a
|
||||
# field was given) so this never forces a *standalone* rewrite with no fields
|
||||
# passed, and `not in` so an existing (possibly higher) version is preserved,
|
||||
# never downgraded. NB `dirty` here means "a field was supplied" — a
|
||||
# supplied-but-identical value already triggers a rewrite (pre-existing).
|
||||
if dirty and "feedpak_version" not in manifest:
|
||||
from sloppak import FEEDPAK_VERSION
|
||||
manifest["feedpak_version"] = FEEDPAK_VERSION
|
||||
return dirty
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user