Merge branch 'main' into chore/deprecate-sloppak-to-feedpak

# Conflicts:
#	lib/sloppak.py
This commit is contained in:
Bret Mogilefsky
2026-06-20 20:29:07 -07:00
29 changed files with 2094 additions and 1072 deletions
+90
View File
@@ -0,0 +1,90 @@
// Behavioural tests for the per-note bend-curve (bnv, §6.2.1) render helpers:
// `bnvNormalizedPoints` (static/highway.js, 2D glyph) and `bnvSampleAt`
// (plugins/highway_3d/screen.js, 3D Y gesture). Both are pure, so we extract
// the function source by brace-matching and eval it in isolation.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function extractFn(src, name) {
const start = src.indexOf('function ' + name);
assert.ok(start >= 0, `function ${name} must exist`);
const open = src.indexOf('{', start);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
}
throw new Error(`unbalanced braces extracting ${name}`);
}
function loadFn(file, name) {
const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8');
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
}
const bnvNormalizedPoints = loadFn('static/highway.js', 'bnvNormalizedPoints');
const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt');
// ── bnvNormalizedPoints (2D) ─────────────────────────────────────────────────
test('bnvNormalizedPoints normalizes t to 0..1 across the curve span (no sus)', () => {
const pts = bnvNormalizedPoints([
{ t: 0.5, v: 0 }, { t: 1.0, v: 2 }, { t: 1.5, v: 0 }]);
assert.deepEqual(pts, [
{ x: 0, v: 0 }, { x: 0.5, v: 2 }, { x: 1, v: 0 }]);
});
test('bnvNormalizedPoints maps t over the note sus span when given', () => {
// A bend that completes at t=0.4 of a 0.5s note draws to x=0.8, not x=1 —
// i.e. it stops short of the glyph's right edge (correct timing shape).
assert.deepEqual(
bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 0.25, v: 1 }, { t: 0.4, v: 0 }], 0.5),
[{ x: 0, v: 0 }, { x: 0.5, v: 1 }, { x: 0.8, v: 0 }]);
// Points beyond sus clamp to 1; sus<=0 falls back to curve-span mapping.
assert.deepEqual(bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 1, v: 2 }], 0.5),
[{ x: 0, v: 0 }, { x: 1, v: 2 }]);
assert.deepEqual(bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 1, v: 2 }], 0),
[{ x: 0, v: 0 }, { x: 1, v: 2 }]);
});
test('bnvNormalizedPoints handles degenerate/empty input', () => {
assert.deepEqual(bnvNormalizedPoints([]), []);
assert.deepEqual(bnvNormalizedPoints(null), []);
// All-same-t span collapses x to 0 (no divide-by-zero).
assert.deepEqual(bnvNormalizedPoints([{ t: 1, v: 1 }, { t: 1, v: 2 }]),
[{ x: 0, v: 1 }, { x: 0, v: 2 }]);
});
// ── bnvSampleAt (3D) ─────────────────────────────────────────────────────────
test('bnvSampleAt linearly interpolates between points', () => {
const bnv = [{ t: 0, v: 0 }, { t: 1, v: 2 }];
assert.equal(bnvSampleAt(bnv, 0.5), 1); // midpoint
assert.equal(bnvSampleAt(bnv, 0.25), 0.5);
});
test('bnvSampleAt clamps to the endpoints', () => {
const bnv = [{ t: 0.2, v: 1 }, { t: 0.8, v: 3 }];
assert.equal(bnvSampleAt(bnv, 0), 1); // before first
assert.equal(bnvSampleAt(bnv, 5), 3); // after last
});
test('bnvSampleAt traces a round-trip curve up then back down', () => {
const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 2 }, { t: 1, v: 0 }];
assert.equal(bnvSampleAt(bnv, 0.25), 1); // rising
assert.equal(bnvSampleAt(bnv, 0.5), 2); // peak
assert.equal(bnvSampleAt(bnv, 0.75), 1); // falling
});
test('bnvSampleAt returns 0 for an empty/invalid curve', () => {
assert.equal(bnvSampleAt([], 0.5), 0);
assert.equal(bnvSampleAt(null, 0.5), 0);
});
test('bnvSampleAt tolerates a zero-width segment (duplicate t)', () => {
const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 1 }, { t: 0.5, v: 2 }, { t: 1, v: 2 }];
assert.equal(bnvSampleAt(bnv, 0.5), 1); // first matching segment wins
});
+6 -2
View File
@@ -502,8 +502,12 @@ def test_attach_notation_to_sloppak(tmp_path):
entries = {e["id"]: e for e in rewritten["arrangements"]}
assert entries["keys"]["notation"] == "notation_keys.json"
assert "notation" not in entries["lead"]
# Key order preserved (sort_keys=False round-trip).
assert list(rewritten.keys()) == ["title", "artist", "arrangements", "stems"]
# Original key order preserved (sort_keys=False round-trip); the manifest
# rewrite also stamps feedpak_version (spec §4), appended at the end.
assert list(rewritten.keys()) == [
"title", "artist", "arrangements", "stems", "feedpak_version"]
from sloppak import FEEDPAK_VERSION
assert rewritten["feedpak_version"] == FEEDPAK_VERSION
def test_attach_notation_unknown_arrangement_raises(tmp_path):
+261 -3
View File
@@ -20,9 +20,11 @@ import pytest
from gp2rs import (
GP_TICKS_PER_QUARTER,
TempoEvent,
_bend_intent_from_values,
_build_playback_schedule,
_compute_tuning,
_extract_year,
_gp_bend_shape,
_gp_string_to_rs,
_is_bass_track,
_standard_tuning_for,
@@ -795,12 +797,14 @@ def _ct_note(note_type, gp_string, fret):
)
def _ct_song(beats):
"""One-measure mock song for convert_track, standard 6-string guitar at 120 BPM."""
def _ct_song(beats, string_values=None):
"""One-measure mock song for convert_track, standard 6-string guitar at 120 BPM.
`string_values` overrides the tuning/string count (e.g. a 7-string track)."""
voice = SimpleNamespace(beats=beats)
measure = SimpleNamespace(voices=[voice])
strings = [SimpleNamespace(number=i + 1, value=v)
for i, v in enumerate([64, 59, 55, 50, 45, 40])]
for i, v in enumerate(string_values or [64, 59, 55, 50, 45, 40])]
track = SimpleNamespace(
strings=strings,
channel=SimpleNamespace(instrument=24),
@@ -862,6 +866,80 @@ def test_tied_note_without_predecessor_is_silently_dropped():
assert len(notes) == 0
# ── convert_track: bend shape (bn / bt / bnv, §6.2.1) ────────────────────────
def _ct_bend(points):
"""A pyguitarpro-shaped BendEffect: points are (position 0..12, value)
pairs where value is half-quarter-tone units (12 = 6 semitones)."""
return SimpleNamespace(
points=[SimpleNamespace(position=p, value=v) for p, v in points],
)
def test_bend_intent_classifier():
assert _bend_intent_from_values([0.0, 1.0, 2.0]) == 0 # up
assert _bend_intent_from_values([2.0, 1.0, 0.0]) == 3 # pre-bend+release
assert _bend_intent_from_values([2.0, 2.0]) == 2 # pre-bend held
assert _bend_intent_from_values([2.0, 1.0]) == 1 # release (let down)
assert _bend_intent_from_values([0.0, 2.0, 0.0]) == 4 # round-trip
assert _bend_intent_from_values([]) == 0
def test_gp_bend_shape_units_and_time():
"""value/2 = semitones; position/12 * duration = seconds-from-onset."""
# 0.5 s note, up-bend 0 → value 4 (2 semitones) at the end.
peak, intent, curve = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.5)
assert peak == 2.0
assert intent == 0
assert curve == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}]
# Zero-length note collapses every point to t=0 → no usable curve.
_, _, curve0 = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.0)
assert curve0 is None
# A single point carries only the peak, no curve.
_, _, curve1 = _gp_bend_shape(_ct_bend([(6, 4)]), 0.5)
assert curve1 is None
def test_bent_note_imports_with_curve_through_wire():
"""A GP up-bend imports with bn (peak) + bt + bnv, and survives
convert_track XML → _parse_note → note_to_wire."""
from song import _parse_note, note_to_wire
note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=7)
# quarter @ 120 BPM = 0.5 s; round-trip bend 0 → 2 → 0 semitones.
note.effect.bend = _ct_bend([(0, 0), (6, 4), (12, 0)])
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314
xn = root.findall(".//notes/note")[0]
assert xn.get("bend") == "2.0"
assert xn.get("bendIntent") == "4" # round-trip
import json
assert json.loads(xn.get("bendValues")) == [
{"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}]
wire = note_to_wire(_parse_note(xn))
assert wire["bn"] == 2.0
assert wire["bt"] == 4
assert wire["bnv"] == [
{"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}]
def test_non_bent_note_has_no_curve():
note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5) # bend=None
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314
xn = root.findall(".//notes/note")[0]
assert xn.get("bend") == "0"
assert xn.get("bendIntent") is None
assert xn.get("bendValues") is None
from song import _parse_note
n = _parse_note(xn)
assert n.bend == 0.0
assert n.bend_intent == 0
assert n.bend_values is None
def _ct_multivoice_song(voices_beats):
"""Multi-voice variant of _ct_song. `voices_beats` is a list of beat-lists,
one per voice, all on the same single measure."""
@@ -1100,3 +1178,183 @@ def test_tie_not_extended_across_repeat_boundary():
assert sustain == pytest.approx(0.5, abs=0.01), (
f"sustain should be ~0.5 s (one quarter note), got {sustain:.3f}"
)
# ── convert_track: GP5 chord-diagram fingering extraction (E3) ───────────────
# pyguitarpro exposes the chord-diagram voicing on beat.effect.chord:
# .strings is per-string frets indexed 0 = highest string, .fingerings is the
# parallel Fingering enum list (open=-1, thumb=0, index=1, middle=2, ring=3,
# pinky=4 — already the RS finger integers). A chord beat carrying this data
# must import with per-string fingers; a chord beat without it stays all -1.
def _ct_chord(name, strings, fingerings):
return SimpleNamespace(
name=name, strings=list(strings),
fingerings=list(fingerings), length=len(strings),
)
def test_chord_diagram_fingers_extracted():
# Two-note voicing on high e (fret 3) + B (fret 2). chord.strings is
# indexed 0 = highest string, so strings[0] = high e, strings[1] = B.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3) # high e
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2) # B
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b])
beat.effect.chord = _ct_chord(
"Gtest",
strings=[3, 2, -1, -1, -1, -1],
fingerings=[
guitarpro.Fingering.middle, # high e -> 2
guitarpro.Fingering.index, # B -> 1
],
)
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == "Gtest"
# _gp_string_to_rs(1, 6) = 5 (high e), _gp_string_to_rs(2, 6) = 4 (B).
assert ct.get("fret5") == "3" and ct.get("finger5") == "2"
assert ct.get("fret4") == "2" and ct.get("finger4") == "1"
assert [ct.get(f"finger{i}") for i in range(0, 4)] == ["-1"] * 4
def test_chord_without_diagram_has_blank_fingers():
# A plain two-note chord (effect.chord is None) is unchanged: blank name,
# all-(-1) fingers — no regression for diagram-less charts.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3)
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b]) # chord=None
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
def test_chord_diagram_backfills_template_first_strummed_unannotated():
# The annotated chord must enrich its voicing even when an earlier,
# unannotated beat of the SAME fret pattern created the template first.
plain = _ct_beat(
tick=0, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
) # chord=None, creates the blank template
annotated = _ct_beat(
tick=GP_TICKS_PER_QUARTER, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
)
annotated.effect.chord = _ct_chord(
"Gtest", strings=[3, 2, -1, -1, -1, -1],
fingerings=[guitarpro.Fingering.middle, guitarpro.Fingering.index],
)
xml_str = convert_track(_ct_song([plain, annotated]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1, "same voicing must dedup to one template"
assert cts[0].get("chordName") == "Gtest"
assert cts[0].get("finger5") == "2" and cts[0].get("finger4") == "1"
def test_chord_diagram_mismatch_not_applied():
# The attached diagram describes a DIFFERENT voicing (frets 5/5) than the
# notes actually played (3/2). It must NOT enrich the played template —
# otherwise a mislabeled chord would name/finger the wrong voicing (and the
# back-fill would spread it). Name + fingers stay blank.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3)
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b])
beat.effect.chord = _ct_chord(
"Wrong", strings=[5, 5, -1, -1, -1, -1], # != played 3/2
fingerings=[guitarpro.Fingering.annular, guitarpro.Fingering.annular],
)
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
def test_chord_diagram_name_then_fingers_decoupled():
# First annotated beat carries a NAME but no fingers (all open); a later beat
# of the same voicing carries the fingers. Both must land — a name-only first
# annotation must not block the later fingers (name/fingers back-fill
# independently).
def _beat(tick, name, fingerings):
b = _ct_beat(
tick=tick, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
)
b.effect.chord = _ct_chord(name, strings=[3, 2, -1, -1, -1, -1],
fingerings=fingerings)
return b
first = _beat(0, "Gtest",
[guitarpro.Fingering.open, guitarpro.Fingering.open])
second = _beat(GP_TICKS_PER_QUARTER, "",
[guitarpro.Fingering.middle, guitarpro.Fingering.index])
xml_str = convert_track(_ct_song([first, second]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1
assert cts[0].get("chordName") == "Gtest" # from the first (name-only) beat
# fingers from the second beat — not blocked by the first beat's name
assert cts[0].get("finger5") == "2" and cts[0].get("finger4") == "1"
def test_chord_diagram_barre_higher_position_matches():
# A voicing high on the neck: diagram strings hold ABSOLUTE frets (firstFret
# is display-only), so they match the played absolute frets and the template
# enriches. Guards against an absolute-vs-relative matching regression.
notes = [_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5),
_ct_note(guitarpro.NoteType.normal, gp_string=3, fret=6)]
beat = _ct_beat(tick=0, dur_value=4, notes=notes)
ch = _ct_chord("A", strings=[5, 5, 6, -1, -1, -1],
fingerings=[guitarpro.Fingering.index, guitarpro.Fingering.index,
guitarpro.Fingering.middle])
ch.firstFret = 5 # display base — must not affect matching
beat.effect.chord = ch
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == "A"
assert ct.get("fret5") == "5" and ct.get("finger5") == "1"
assert ct.get("fret4") == "5" and ct.get("finger4") == "1"
assert ct.get("fret3") == "6" and ct.get("finger3") == "2"
def test_chord_diagram_extended_string_outside_played_width_not_applied():
# 7-string track. Played voicing is on strings 2 & 3 only (width 6 — the
# high e / rs6 is unused), but the diagram ALSO frets string 1 (the extended
# rs6). The extra diagram note must make this a MISMATCH, not be silently
# trimmed to a false match — so the played template stays un-enriched.
seven = [64, 59, 55, 50, 45, 40, 35] # low-B 7-string
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=3) # rs5
note_g = _ct_note(guitarpro.NoteType.normal, gp_string=3, fret=2) # rs4
beat = _ct_beat(tick=0, dur_value=4, notes=[note_b, note_g])
# diagram index0 = gp_string1 (rs6) frets 5 (NOT played); index1/2 match.
beat.effect.chord = _ct_chord(
"Bogus", strings=[5, 3, 2, -1, -1, -1, -1],
fingerings=[guitarpro.Fingering.index, guitarpro.Fingering.middle,
guitarpro.Fingering.index],
)
xml_str = convert_track(_ct_song([beat], string_values=seven), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == ""
# played template is width 6 (rs6/high-e unused) -> finger0..finger5
assert all(ct.get(f"finger{i}") == "-1" for i in range(6))
+130
View File
@@ -31,6 +31,7 @@ from gp2rs_gpx import (
_collect_tone_events,
_inject_tones,
_resolve_pending_slides,
_gpx_bend_shape,
)
from gp2rs import RsNote
@@ -55,6 +56,50 @@ def test_safe_filename_stem(name, expected):
assert ".." not in out
# ── _gpx_bend_shape (bn / bt / bnv, §6.2.1) ─────────────────────────────────
def _bend_props(**vals):
"""Build a GPIF property map {name: <Property> element} for the given
bend Float values, e.g. _bend_props(BendOriginValue=0, BendMiddleValue=100)."""
tp = {}
for name, num in vals.items():
tp[name] = ET.fromstring(
f'<Property name="{name}"><Float>{num}</Float></Property>')
return tp
def test_gpx_bend_shape_round_trip_curve():
"""origin/middle/destination value+offset → 3-point bnv; value/divisor=semis."""
tp = _bend_props(
BendOriginValue=0, BendOriginOffset=0,
BendMiddleValue=100, BendMiddleOffset1=50, # 100/50 = 2 semitones
BendDestinationValue=0, BendDestinationOffset=100,
)
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
assert peak == 2.0
assert intent == 4 # round-trip (up then back down)
assert curve == [
{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}, {"t": 1.0, "v": 0.0}]
def test_gpx_bend_shape_falls_back_to_even_spacing_without_offsets():
tp = _bend_props(BendOriginValue=0, BendDestinationValue=100) # no offsets
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
assert peak == 2.0
assert intent == 0 # plain up
# origin defaults to 0%, destination to 100%.
assert curve == [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 2.0}]
def test_gpx_bend_shape_no_props_and_zero_length():
assert _gpx_bend_shape({}, divisor=50.0, sustain=1.0) == (0.0, 0, None)
# Peak + intent still derived for a zero-length note, but no curve.
peak, intent, curve = _gpx_bend_shape(
_bend_props(BendOriginValue=0, BendDestinationValue=100),
divisor=50.0, sustain=0.0)
assert peak == 2.0 and intent == 0 and curve is None
# ── _decompress_bcfz / _parse_bcfs input guards ─────────────────────────────
def test_decompress_bcfz_rejects_bad_magic():
@@ -684,3 +729,88 @@ def test_note_vibrato_ignores_whammy_trembar_property():
'</Properties></Note>')
tp = {p.get('name'): p for p in n.findall('.//Property')}
assert _note_has_vibrato(n, tp) is False
# ── convert_file: GP8 chord-diagram name + fingering extraction (E3) ─────────
# GP7/GP8 GPIF carries authored chord diagrams under a track's
# Property[@name="DiagramCollection"]. Each Item gives the chord name and a
# <Diagram> with per-string fret + finger. A played voicing matching that
# fret pattern must import with the diagram's name + fingers; a chart without
# a DiagramCollection must import with blank name + all-(-1) fingers.
def _gpif_chord_diagram(diagram_block: str) -> str:
# A two-note chord (low E fret 3 + A fret 2) on a low->high tuned guitar.
return f"""
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property>
{diagram_block}
</Track>
</Tracks>
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
<Voices><Voice id="0"><Beats>0</Beats></Voice></Voices>
<Beats><Beat id="0"><Rhythm ref="r0"/><Notes>0 1</Notes></Beat></Beats>
<Notes>
<Note id="0">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>3</Fret></Property></Note>
<Note id="1">
<Property name="String"><String>1</String></Property>
<Property name="Fret"><Fret>2</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
_DIAGRAM_BLOCK = """
<Property name="DiagramCollection"><Items>
<Item id="1" name="G5">
<Diagram stringCount="6" fretCount="5" baseFret="0">
<Fret string="0" fret="3"/>
<Fret string="1" fret="2"/>
<Fingering>
<Position finger="Middle" fret="3" string="0"/>
<Position finger="Index" fret="2" string="1"/>
</Fingering>
</Diagram>
</Item>
</Items></Property>
"""
def _convert_first_chord_template(monkeypatch, tmp_path, gpif):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(gpif))
out_files = convert_file(
"dummy.gp", str(tmp_path),
track_indices=[0], arrangement_names={0: "Lead"},
)
root = ET.parse(out_files[0]).getroot()
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1
return cts[0]
def test_convert_file_gp8_chord_diagram_enriches_template(tmp_path, monkeypatch):
ct = _convert_first_chord_template(
monkeypatch, tmp_path, _gpif_chord_diagram(_DIAGRAM_BLOCK))
# Diagram name + per-string fingering land on the matching voicing.
assert ct.get("chordName") == "G5"
# RS string 0 = low E (fret 3, Middle=2), string 1 = A (fret 2, Index=1).
assert ct.get("fret0") == "3" and ct.get("finger0") == "2"
assert ct.get("fret1") == "2" and ct.get("finger1") == "1"
# Unplayed strings stay -1 for both fret and finger.
assert [ct.get(f"finger{i}") for i in range(2, 6)] == ["-1"] * 4
def test_convert_file_gp8_no_diagram_leaves_template_blank(tmp_path, monkeypatch):
# Same chart, no DiagramCollection -> identical import to before E3.
ct = _convert_first_chord_template(
monkeypatch, tmp_path, _gpif_chord_diagram(""))
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
# Fret pattern itself is unchanged (the join key still works).
assert ct.get("fret0") == "3" and ct.get("fret1") == "2"
+148
View File
@@ -0,0 +1,148 @@
"""Album-art fast path + conditional-caching contract.
Covers the library cover-loading perf fix: `sloppak.read_cover_bytes` reads the
cover WITHOUT unpacking the whole archive, and `GET /api/song/{f}/art` serves it
with a content validator so re-scroll gets bodyless 304s never a stale cover.
Pins, so a future refactor can't silently reintroduce:
- the full-unpack-per-cover regression (covers served straight from the zip),
- the non-canonical manifest cover name (`./cover.jpg`) 404,
- zip-slip / degenerate cover names,
- dir-form sloppaks emitting a stale 304 after an in-place cover edit.
"""
import importlib
import sys
import zipfile
import pytest
import yaml
from fastapi.testclient import TestClient
import sloppak as sloppak_mod
# ── Unit: read_cover_bytes ────────────────────────────────────────────────────
def _zip_sloppak(path, cover_name="cover.jpg", manifest_cover="cover.jpg",
cover_bytes=b"\xff\xd8\xff\xe0JPG", with_stem=True):
with zipfile.ZipFile(path, "w") as zf:
zf.writestr("manifest.yaml", yaml.safe_dump({"cover": manifest_cover}))
zf.writestr(cover_name, cover_bytes)
if with_stem:
# A big-ish stem so a regression that unpacks the whole archive
# would be doing real work, not just touching the cover.
zf.writestr("stems/full.ogg", b"OggS" + b"\x00" * 4096)
def _dir_sloppak(path, cover_bytes=b"\xff\xd8\xff\xe0JPG"):
path.mkdir(parents=True)
(path / "manifest.yaml").write_text(yaml.safe_dump({"cover": "cover.jpg"}))
(path / "cover.jpg").write_bytes(cover_bytes)
return path
def test_read_cover_from_zip(tmp_path):
z = tmp_path / "a.sloppak"
_zip_sloppak(z, cover_bytes=b"\xff\xd8\xff\xe0HELLO")
res = sloppak_mod.read_cover_bytes(z)
assert res is not None
data, mt = res
assert data == b"\xff\xd8\xff\xe0HELLO"
assert mt == "image/jpeg"
def test_read_cover_from_dir(tmp_path):
d = _dir_sloppak(tmp_path / "b.sloppak", cover_bytes=b"\xff\xd8\xff\xe0DIR")
res = sloppak_mod.read_cover_bytes(d)
assert res is not None and res[0] == b"\xff\xd8\xff\xe0DIR" and res[1] == "image/jpeg"
@pytest.mark.parametrize("manifest_cover", ["./cover.jpg", "art/../cover.jpg"])
def test_noncanonical_manifest_cover_resolves(tmp_path, manifest_cover):
"""A valid-but-non-canonical name must resolve to the real member, matching
the old unpack-then-resolve-on-filesystem behavior."""
z = tmp_path / "c.sloppak"
_zip_sloppak(z, manifest_cover=manifest_cover, cover_bytes=b"\xff\xd8\xff\xe0X")
res = sloppak_mod.read_cover_bytes(z)
assert res is not None and res[0] == b"\xff\xd8\xff\xe0X"
@pytest.mark.parametrize("bad", ["../../escape.png", ".", "subdir/..", "/abs.png", ""])
def test_unsafe_or_degenerate_cover_name_rejected(tmp_path, bad):
z = tmp_path / "d.sloppak"
# Put a real cover.jpg in the archive; the manifest points at the bad name.
_zip_sloppak(z, manifest_cover=bad if bad else "cover.jpg")
if bad == "":
# Empty falls back to the default cover.jpg (intended contract).
assert sloppak_mod.read_cover_bytes(z) is not None
else:
assert sloppak_mod.read_cover_bytes(z) is None
def test_webp_media_type(tmp_path):
z = tmp_path / "e.sloppak"
_zip_sloppak(z, cover_name="cover.webp", manifest_cover="cover.webp",
cover_bytes=b"RIFF....WEBP")
res = sloppak_mod.read_cover_bytes(z)
assert res is not None and res[1] == "image/webp"
# ── Endpoint: conditional caching ─────────────────────────────────────────────
@pytest.fixture()
def dlc_client(tmp_path, monkeypatch):
"""TestClient with a temp DLC_DIR; sync startup, no scan, no plugins."""
dlc = tmp_path / "dlc"
dlc.mkdir()
config = tmp_path / "cfg"
config.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("CONFIG_DIR", str(config))
monkeypatch.setenv("SLOPSMITH_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
server.sloppak_mod._source_cache.clear()
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
static_tmp = tmp_path / "static"
static_tmp.mkdir()
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
tc = TestClient(server.app, client=("127.0.0.1", 50000))
try:
yield tc, server, dlc
finally:
tc.close()
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
conn.close()
def test_zip_art_endpoint_conditional_304(dlc_client):
tc, _server, dlc = dlc_client
_zip_sloppak(dlc / "song.sloppak", cover_bytes=b"\xff\xd8\xff\xe0ZIP")
r1 = tc.get("/api/song/song.sloppak/art")
assert r1.status_code == 200
assert r1.content == b"\xff\xd8\xff\xe0ZIP"
assert r1.headers["cache-control"] == "no-cache"
etag = r1.headers["etag"]
assert etag
r2 = tc.get("/api/song/song.sloppak/art", headers={"If-None-Match": etag})
assert r2.status_code == 304
assert r2.content == b""
def test_dir_art_endpoint_no_stale_304_after_inplace_edit(dlc_client):
"""Editing cover.jpg in place must invalidate the validator (the dir-form
staleness bug: a dir-stat ETag would wrongly 304 here)."""
tc, _server, dlc = dlc_client
pak = _dir_sloppak(dlc / "dir.sloppak", cover_bytes=b"\xff\xd8\xff\xe0OLD")
r1 = tc.get("/api/song/dir.sloppak/art")
assert r1.status_code == 200 and r1.content == b"\xff\xd8\xff\xe0OLD"
etag_old = r1.headers["etag"]
# Replace the cover content in place (same path).
(pak / "cover.jpg").write_bytes(b"\xff\xd8\xff\xe0NEW")
r2 = tc.get("/api/song/dir.sloppak/art", headers={"If-None-Match": etag_old})
assert r2.status_code == 200
assert r2.content == b"\xff\xd8\xff\xe0NEW"
+86
View File
@@ -0,0 +1,86 @@
"""feedpak_version (spec §4): read on load + opportunistic stamp on a metadata
write. Core has no create-from-scratch path (RS-free repo); the editor plugin's
create-mode save stamping the version is a separate follow-up."""
from __future__ import annotations
import json
from pathlib import Path
import yaml
import sloppak as sloppak_mod
from sloppak import FEEDPAK_VERSION
from songmeta import write_sloppak_metadata
def _write_dir_sloppak(root: Path, manifest_extras: dict) -> Path:
pak = root / f"{root.name}.sloppak"
pak.mkdir()
arr_dir = pak / "arrangements"
arr_dir.mkdir()
(arr_dir / "lead.json").write_text(json.dumps({
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
"notes": [], "chords": [], "anchors": [], "handshapes": [],
"templates": [], "beats": [], "sections": [],
}))
manifest = {
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
"duration": 10.0,
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
}
manifest.update(manifest_extras)
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
return pak
def _load(pak: Path, tmp_path: Path):
cache = tmp_path / "cache"
cache.mkdir()
return sloppak_mod.load_song(pak.name, pak.parent, cache)
def _manifest(pak: Path) -> dict:
return yaml.safe_load((pak / "manifest.yaml").read_text(encoding="utf-8"))
# ── read ─────────────────────────────────────────────────────────────────────
def test_feedpak_version_read_from_manifest(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"feedpak_version": "1.2.0"})
assert _load(pak, tmp_path).feedpak_version == "1.2.0"
def test_feedpak_version_none_when_absent(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {})
assert _load(pak, tmp_path).feedpak_version is None
def test_feedpak_version_none_when_not_a_string(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"feedpak_version": 12})
assert _load(pak, tmp_path).feedpak_version is None
# ── opportunistic stamp on a metadata write ──────────────────────────────────
def test_metadata_write_stamps_version_when_absent(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {})
assert "feedpak_version" not in _manifest(pak)
assert write_sloppak_metadata(pak, {"title": "New"}) is True
m = _manifest(pak)
assert m["title"] == "New"
assert m["feedpak_version"] == FEEDPAK_VERSION
def test_metadata_write_preserves_existing_version(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"feedpak_version": "9.9.9"})
write_sloppak_metadata(pak, {"artist": "X"})
assert _manifest(pak)["feedpak_version"] == "9.9.9" # not downgraded
def test_metadata_no_change_does_not_add_version(tmp_path: Path):
# A no-op metadata write must NOT stamp a version (no rewrite happens).
pak = _write_dir_sloppak(tmp_path, {})
assert write_sloppak_metadata(pak, {}) is False
assert "feedpak_version" not in _manifest(pak)
+127
View File
@@ -0,0 +1,127 @@
"""End-to-end test for the sloppak loader recognising a `keys:` manifest key
(keys.json the song-level, instrument-independent key/scale track, spec §7.7)
and surfacing the sanitized payload on the LoadedSloppak."""
from __future__ import annotations
import json
from pathlib import Path
import yaml
import sloppak as sloppak_mod
def _write_dir_sloppak(root: Path, manifest_extras: dict, keys_payload) -> Path:
"""Minimal directory-form sloppak; writes keys.json when a payload is given.
Unique filename per test (tmp_path leaf) so the module-level
resolve_source_dir cache isn't poisoned across tests."""
pak = root / f"{root.name}.sloppak"
pak.mkdir()
arr_dir = pak / "arrangements"
arr_dir.mkdir()
arr = {
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
"notes": [], "chords": [], "anchors": [], "handshapes": [],
"templates": [], "beats": [], "sections": [],
}
(arr_dir / "lead.json").write_text(json.dumps(arr))
manifest = {
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
"duration": 10.0,
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
}
manifest.update(manifest_extras)
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
if keys_payload is not None:
(pak / "keys.json").write_text(json.dumps(keys_payload))
return pak
def _load(pak_path: Path, tmp_path: Path):
dlc_root = pak_path.parent
cache = tmp_path / "cache"
cache.mkdir()
return sloppak_mod.load_song(pak_path.name, dlc_root, cache)
# ── Happy path ───────────────────────────────────────────────────────────────
def test_load_song_attaches_keys_when_manifest_opts_in(tmp_path: Path):
payload = {
"version": 1,
"events": [
{"t": 0.0, "key": "Em", "scale": "natural_minor"},
{"t": 2.0, "key": "G", "scale": "major"},
],
}
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.keys is not None
assert loaded.keys["version"] == 1
evs = loaded.keys["events"]
assert len(evs) == 2
assert evs[0] == {"t": 0.0, "key": "Em", "scale": "natural_minor"}
assert evs[1] == {"t": 2.0, "key": "G", "scale": "major"}
# ── Absent / permissive ──────────────────────────────────────────────────────
def test_load_song_keys_absent_when_manifest_silent(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {}, None)
assert _load(pak, tmp_path).keys is None
def test_load_song_keys_absent_when_file_missing(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"keys": "nope.json"}, None)
assert _load(pak, tmp_path).keys is None
def test_load_song_keys_absent_when_invalid_json(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, None)
(pak / "keys.json").write_text("not json {{{")
assert _load(pak, tmp_path).keys is None
def test_load_song_keys_ignored_when_events_not_a_list(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"},
{"version": 1, "events": "nope"})
assert _load(pak, tmp_path).keys is None
# ── Sanitization ─────────────────────────────────────────────────────────────
def test_load_song_keys_sanitizes_and_sorts(tmp_path: Path):
payload = {
"version": 1,
"events": [
{"t": 2.0, "key": "G"}, # no scale -> omitted
{"t": 0.0, "key": "Em", "scale": "major"}, # out of order
{"t": 1.0}, # no key -> dropped
{"foo": "bar"}, # not an event -> dropped
{"t": 3.0, "key": ""}, # empty key -> dropped
{"t": "bad", "key": "X"}, # non-numeric t -> dropped
"garbage", # non-dict -> dropped
],
}
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
evs = _load(pak, tmp_path).keys["events"]
assert evs == [
{"t": 0.0, "key": "Em", "scale": "major"},
{"t": 2.0, "key": "G"}, # scale absent, not null
]
def test_load_song_keys_nonint_version_does_not_abort_load(tmp_path: Path):
# json.loads accepts NaN; a float/NaN version must not raise int(NaN) and
# abort the load of an OPTIONAL side-file — it falls back to version 1.
payload = {"version": float("nan"), "events": [{"t": 0.0, "key": "C"}]}
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.keys is not None
assert loaded.keys["version"] == 1
assert loaded.keys["events"] == [{"t": 0.0, "key": "C"}]
+44
View File
@@ -148,6 +148,50 @@ def test_song_timeline_absent_when_path_escapes_sloppak(tmp_path: Path):
assert loaded.song_timeline is None
# ── tempos + time_signatures (feedpak 1.2.0) ─────────────────────────────────
def test_song_timeline_tempos_and_time_signatures_loaded(tmp_path: Path):
payload = {
"version": 1, "beats": [], "sections": [],
"tempos": [{"time": 0.0, "bpm": 120}, {"time": 4.0, "bpm": 90}],
"time_signatures": [{"time": 0.0, "ts": [4, 4]}, {"time": 8.0, "ts": [6, 8]}],
}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.tempos == [{"time": 0.0, "bpm": 120.0}, {"time": 4.0, "bpm": 90.0}]
assert loaded.time_signatures == [{"time": 0.0, "ts": [4, 4]},
{"time": 8.0, "ts": [6, 8]}]
def test_song_timeline_maps_absent_when_not_provided(tmp_path: Path):
payload = {"version": 1, "beats": [], "sections": []}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.tempos is None and loaded.time_signatures is None
def test_song_timeline_maps_sanitized(tmp_path: Path):
payload = {
"version": 1, "beats": [], "sections": [],
"tempos": [{"time": 1.0, "bpm": 0}, {"time": 0.0, "bpm": 100}], # bpm 0 dropped + sorted
"time_signatures": [{"time": 0.0, "ts": [4, 4, 4]}, # 3-long dropped
{"time": 2.0, "ts": [3, 4]}],
}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.tempos == [{"time": 0.0, "bpm": 100.0}]
assert loaded.time_signatures == [{"time": 2.0, "ts": [3, 4]}]
def test_song_timeline_maps_load_even_without_beats_or_sections(tmp_path: Path):
# tempos/time_signatures are independent of beats/sections — a payload that
# omits beats (invalid for the override path) must still surface the maps.
payload = {"version": 1, "tempos": [{"time": 0.0, "bpm": 100}]}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.tempos == [{"time": 0.0, "bpm": 100.0}]
def test_song_timeline_absent_when_path_is_absolute(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "/etc/passwd"}, None)
loaded = _load(pak, tmp_path)
+112
View File
@@ -18,6 +18,7 @@ from song import (
arrangement_to_wire,
chord_from_wire,
chord_to_wire,
sanitize_tempos,
compute_smart_names,
note_from_wire,
note_to_wire,
@@ -168,6 +169,85 @@ def test_note_bend_nonzero_rounded_to_one_decimal():
assert note_to_wire(n)["bn"] == 1.8
# ── Bend shape (bt / bnv, §6.2.1) ────────────────────────────────────────────
def test_note_bend_shape_round_trip():
"""A note with bend intent + a time-stamped curve survives the wire."""
n = Note(
time=0.5, string=0, fret=7, sustain=1.0,
bend=2.0,
bend_intent=4, # round-trip
bend_values=[
{"t": 0.0, "v": 0.0},
{"t": 0.25, "v": 2.0},
{"t": 0.5, "v": 0.0},
],
)
wire = note_to_wire(n)
assert wire["bt"] == 4
assert wire["bnv"] == [
{"t": 0.0, "v": 0.0},
{"t": 0.25, "v": 2.0},
{"t": 0.5, "v": 0.0},
]
assert note_from_wire(wire) == n
def test_note_bend_shape_omitted_when_default():
"""`bt`/`bnv` are default-omitted; absence decodes to 0 / None (not 0-present
/ not [])."""
wire = note_to_wire(Note(time=0.0, string=0, fret=0, bend=1.0))
assert "bt" not in wire
assert "bnv" not in wire
decoded = note_from_wire(wire)
assert decoded.bend_intent == 0
assert decoded.bend_values is None
def test_note_bend_values_rounded_on_wire():
"""`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision."""
n = Note(
time=0.0, string=0, fret=0, bend=1.0, bend_intent=1,
bend_values=[{"t": 0.123456, "v": 1.749}],
)
assert note_to_wire(n)["bnv"] == [{"t": 0.123, "v": 1.7}]
def test_note_bend_values_sanitized_from_wire():
"""Malformed `bnv` entries are dropped; bad/empty -> None; result sorted by t."""
# NaN / non-dict / non-numeric entries dropped, remaining sorted by t.
n = note_from_wire({
"t": 0.0, "s": 0, "f": 0, "bn": 2.0,
"bnv": [
{"t": 0.5, "v": 2.0},
{"t": 0.0, "v": 0.0},
{"t": "x", "v": 1.0}, # non-numeric t -> dropped
{"t": 0.25, "v": float("nan")}, # non-finite v -> dropped
"garbage", # non-dict -> dropped
],
})
assert n.bend_values == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}]
# Empty / non-list / all-invalid collapse to None (never []).
for bad in (None, [], "nope", [{"t": "a", "v": "b"}], [42]):
assert note_from_wire(
{"t": 0.0, "s": 0, "f": 0, "bnv": bad}).bend_values is None
def test_chord_note_carries_bend_shape():
"""Chord member notes inherit bt/bnv through chord_note_to_wire/chord_from_wire."""
c = Chord(
time=2.0, chord_id=0,
notes=[Note(
time=2.0, string=1, fret=5, bend=1.0, bend_intent=2,
bend_values=[{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}],
)],
)
decoded = chord_from_wire(chord_to_wire(c))
cn = decoded.notes[0]
assert cn.bend_intent == 2
assert cn.bend_values == [{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}]
# ── Chord round-trip ─────────────────────────────────────────────────────────
def test_chord_with_multiple_notes_round_trip():
@@ -927,3 +1007,35 @@ def test_smart_names_arrangement_properties_defaults():
assert arr.path_bass is False
assert arr.bonus_arr is False
assert arr.represent == 0
# ── tempos (per-chart §6.10 + shared sanitizer) ──────────────────────────────
def test_sanitize_tempos_filters_sorts_and_coerces():
assert sanitize_tempos([
{"time": 2.0, "bpm": 90},
{"time": 0.0, "bpm": 120},
{"time": 1.0, "bpm": 0}, # bpm <= 0 -> dropped
{"time": float("nan"), "bpm": 100}, # non-finite time -> dropped
{"bpm": 100}, # missing time -> dropped
{"time": 3.0, "bpm": float("inf")}, # non-finite bpm -> dropped
"x", # non-dict -> dropped
]) == [{"time": 0.0, "bpm": 120.0}, {"time": 2.0, "bpm": 90.0}]
assert sanitize_tempos(None) == []
assert sanitize_tempos("nope") == []
def test_arrangement_tempos_round_trip_and_omitted_when_absent():
arr = arrangement_from_wire({
"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
"tempos": [{"time": 0.0, "bpm": 60}, {"time": 2.0, "bpm": 120}],
})
assert arr.tempos == [{"time": 0.0, "bpm": 60.0}, {"time": 2.0, "bpm": 120.0}]
assert arrangement_to_wire(arr)["tempos"] == \
[{"time": 0.0, "bpm": 60.0}, {"time": 2.0, "bpm": 120.0}]
# Absent per-chart tempos -> None, and the wire key is OMITTED (not []),
# so the chart follows the song-level tempo (spec §6.10).
arr2 = arrangement_from_wire({"name": "Lead", "tuning": [0] * 6, "capo": 0})
assert arr2.tempos is None
assert "tempos" not in arrangement_to_wire(arr2)