Compare commits

..
Author SHA1 Message Date
ChrisBeWithYouandClaude Fable 5 30f10c8045 feat(gp2rs): emit lyrics + vocal_pitch sidecars from GP vocal tracks
GP files with a vocal track previously produced only the legacy vocals
arrangement XML; the melody sidecar converter existed but was never
wired, so imported paks had no karaoke-ready pitch data.

- convert_file() now calls _emit_vocal_sidecars() after writing the
  vocals XML (best-effort: a sidecar failure never breaks conversion,
  matching the notation-sidecar posture)
- lyrics sidecar is projected from the just-built vocals XML so timing
  stays in lockstep; the XML lyric convention's trailing '+' (join) is
  mapped to the spec 7.1 '-' (join) - a passthrough would have turned
  every joined word into a line break
- convert_vocal_track_to_pitch_sidecar() gains require_lyric=False for
  lyric-less melody tracks (pitch sidecar only)
- attach_vocal_sidecars_to_sloppak() added as the assembly-step helper
  (companion to attach_notation_to_sloppak): writes the sidecars into a
  directory-form pak and sets manifest lyrics/lyrics_source/vocal_pitch
  with no-clobber guards
- gp2rs_gpx registered in the spec-conformance READERS list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: ChrisBeWithYou <chris@rifflarr.local>
2026-07-23 00:32:55 -05:00
9 changed files with 425 additions and 160 deletions
-5
View File
@@ -183,11 +183,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
at `default: off`, drops the key); the fallback and the aliases are removed once at `default: off`, drops the key); the fallback and the aliases are removed once
they are migrated (#945). they are migrated (#945).
- **Folder Library previews on hover, like the grid and list views.** The Folders
view's cards and rows now carry the standard `data-fn` / `data-v3-play` markup,
so the existing **Song Preview** plugin previews them on hover exactly like the
other views (same audio, same behaviour) — Folder Library ships no preview code
of its own.
### Added ### Added
- **Genres fall back to MusicBrainz enrichment** — the effective genre now - **Genres fall back to MusicBrainz enrichment** — the effective genre now
+225 -9
View File
@@ -12,6 +12,7 @@ Both are called transparently by gp2rs.py when the file extension is .gpx.
Do not call this module directly; use gp2rs.list_tracks / gp2rs.convert_file. Do not call this module directly; use gp2rs.list_tracks / gp2rs.convert_file.
""" """
import json
import logging import logging
import re import re
import struct import struct
@@ -1186,6 +1187,7 @@ def convert_vocal_track_to_pitch_sidecar(
*, *,
tempo_bpm: float = 120.0, tempo_bpm: float = 120.0,
audio_offset: float = 0.0, audio_offset: float = 0.0,
require_lyric: bool = True,
) -> dict: ) -> dict:
""" """
Extract per-syllable pitch from a GPX vocal track as a vocal_pitch.json dict. Extract per-syllable pitch from a GPX vocal track as a vocal_pitch.json dict.
@@ -1196,14 +1198,17 @@ def convert_vocal_track_to_pitch_sidecar(
{"version": 1, "notes": [{"t": float, "d": float, "midi": int}, ...]} {"version": 1, "notes": [{"t": float, "d": float, "midi": int}, ...]}
This is complementary to convert_vocal_track() which produces arrangement XML. This is complementary to convert_vocal_track() which produces arrangement
NOTE: nothing in this module calls this helper yet — convert_file() does not XML. convert_file() calls both for every vocal track and writes the result
invoke it, so no vocal_pitch.json is emitted automatically. A caller wanting as a ``<stem>.vocal_pitch.json`` sidecar next to the vocals XML (see
the pitch ribbon must call this itself and persist the returned dict (e.g. _emit_vocal_sidecars); the sloppak assembly step then attaches it via
write it as vocal_pitch.json into the sloppak). When wiring vocals into a attach_vocal_sidecars_to_sloppak.
sloppak, call both:
- convert_vocal_track() → vocals arrangement XML (karaoke highway) ``require_lyric`` (default True) keeps the feedpak-spec §7.2 alignment:
- convert_vocal_track_to_pitch_sidecar() → vocal_pitch.json (pitch ribbon) only beats carrying a lyric emit a note, so the pitch ribbon mirrors
lyrics.json token-for-token. Pass False for a lyric-less vocal track
(authored melody, no lyric text) to emit every pitched beat instead —
there are no lyric tokens to stay aligned with.
Pitch source is the tab author's authored notes (exact), not AI audio Pitch source is the tab author's authored notes (exact), not AI audio
analysis — so this is more accurate than pYIN/CREPE for well-authored tabs. analysis — so this is more accurate than pYIN/CREPE for well-authored tabs.
@@ -1258,13 +1263,15 @@ def convert_vocal_track_to_pitch_sidecar(
# Only emit notes that have a lyric — unvoiced beats # Only emit notes that have a lyric — unvoiced beats
# (rests, instrumental fills) are excluded so the # (rests, instrumental fills) are excluded so the
# pitch ribbon stays aligned with lyric tokens. # pitch ribbon stays aligned with lyric tokens.
# (Relaxed via require_lyric=False for lyric-less
# vocal tracks, where every pitched beat counts.)
lyric_el = beat_el.find('Lyrics') lyric_el = beat_el.find('Lyrics')
has_lyric = ( has_lyric = (
lyric_el is not None lyric_el is not None
and lyric_el.find('Line') is not None and lyric_el.find('Line') is not None
and (lyric_el.find('Line').text or '').strip() and (lyric_el.find('Line').text or '').strip()
) )
if not has_lyric: if require_lyric and not has_lyric:
voice_time += dur voice_time += dur
continue continue
@@ -1682,6 +1689,26 @@ def convert_file(
raise ValueError(f"unsafe output filename from track name: {track['name']!r}") raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
filepath.write_text(xml_str, encoding="utf-8") filepath.write_text(xml_str, encoding="utf-8")
output_files.append(str(filepath)) output_files.append(str(filepath))
# Vocal tracks additionally get karaoke sidecars next to the XML
# (`<stem>.lyrics.json` + `<stem>.vocal_pitch.json`, feedpak spec
# §7.1/§7.2) so the sloppak assembly step can attach the `lyrics` /
# `vocal_pitch` manifest keys without re-walking the GP file —
# same pattern as the keys notation sidecar below. Best-effort: a
# sidecar bug must never break the vocals XML conversion itself.
try:
_emit_vocal_sidecars(
filepath, xml_str,
root, track, raw_idx,
masterbars, bars_by_id, voices_dict, beats_dict,
notes_dict, rhythms_dict,
tempo_bpm=tempo_bpm, audio_offset=audio_offset,
)
except Exception:
_log.exception(
"gp2rs_gpx: vocal sidecar emission failed for track %r "
"— vocals XML is unaffected", track['name'],
)
continue continue
# Iterate all masterbars and collect notes for this track # Iterate all masterbars and collect notes for this track
@@ -2403,6 +2430,195 @@ def _build_vocals_xml(
return dom.toprettyxml(indent=' ', encoding=None) return dom.toprettyxml(indent=' ', encoding=None)
# ---------------------------------------------------------------------------
# Vocal karaoke sidecars + manifest wiring (feedpak spec §7.1/§7.2)
# Mirrors the gp2notation sidecar pattern: convert_file writes the payloads
# next to the vocals XML (arrangement ids / the pak don't exist yet at convert
# time), and the sloppak assembly step moves them into the pak root + manifest
# via attach_vocal_sidecars_to_sloppak.
# ---------------------------------------------------------------------------
def lyrics_sidecar_path(xml_path: str | Path) -> Path:
"""``Voice_Vocals.xml`` → ``Voice_Vocals.lyrics.json`` (next to the XML)."""
p = Path(xml_path)
return p.with_name(p.stem + ".lyrics.json")
def vocal_pitch_sidecar_path(xml_path: str | Path) -> Path:
"""``Voice_Vocals.xml`` → ``Voice_Vocals.vocal_pitch.json`` (next to the XML)."""
p = Path(xml_path)
return p.with_name(p.stem + ".vocal_pitch.json")
def _vocals_xml_to_lyrics(xml_str: str) -> list[dict]:
"""Project a ``<vocals>`` arrangement XML into the flat lyrics.json shape
(feedpak spec §7.1): ``[{"t": float, "d": float, "w": str}, ...]``.
Deriving from the XML (rather than re-walking the GP tree) guarantees the
two stay in lockstep — same tie extension, same rounding, same beats.
Suffix conversion: the XML lyric convention and feedpak disagree on ``+``.
In the vocals XML a trailing ``+`` means "connect to next token" (a join),
while feedpak ``+`` marks the last syllable of a LINE — so a pass-through
would turn every joined syllable into a line break. Joins map to feedpak's
trailing ``-`` instead; a trailing ``-`` already means the same thing in
both. Line-end ``+`` markers are never emitted: GP stores lyrics per beat
with no line structure, so there is nothing to derive them from.
"""
try:
root = ET.fromstring(xml_str)
except ET.ParseError:
return []
lyrics: list[dict] = []
for v in root.iter('vocal'):
w = (v.get('lyric') or '').strip()
if w.endswith('+'):
w = w[:-1] + '-'
# A bare joiner token isn't a syllable (spec: suffixes ride on real
# syllables, never standalone entries) — skip it.
if not w or w in ('-', '+'):
continue
try:
t = float(v.get('time', ''))
d = float(v.get('length', ''))
except (TypeError, ValueError):
continue
lyrics.append({'t': round(t, 3), 'd': round(d, 3), 'w': w})
return lyrics
def _emit_vocal_sidecars(
xml_path: Path,
xml_str: str,
root: ET.Element,
track: dict,
raw_idx: int,
masterbars: list,
bars_by_id: dict,
voices_dict: dict,
beats_dict: dict,
notes_dict: dict,
rhythms_dict: dict,
*,
tempo_bpm: float = 120.0,
audio_offset: float = 0.0,
) -> list[Path]:
"""Write the karaoke sidecars for one converted vocal track.
``<stem>.lyrics.json`` — only when the track actually carries lyric text
(derived from the vocals XML just built, so timings match exactly).
``<stem>.vocal_pitch.json`` — whenever the track has pitched beats. With
lyrics present the notes stay lyric-aligned (spec §7.2: one entry per
syllable); for a lyric-less melody track the lyric gate is dropped so the
authored pitch still ships.
Returns the sidecar paths written (possibly empty).
"""
written: list[Path] = []
lyrics = _vocals_xml_to_lyrics(xml_str)
if lyrics:
side = lyrics_sidecar_path(xml_path)
side.write_text(json.dumps(lyrics, separators=(",", ":")),
encoding="utf-8")
written.append(side)
pitch = convert_vocal_track_to_pitch_sidecar(
root, track, raw_idx,
masterbars, bars_by_id, voices_dict, beats_dict,
notes_dict, rhythms_dict,
tempo_bpm=tempo_bpm, audio_offset=audio_offset,
require_lyric=bool(lyrics),
)
if pitch.get('notes'):
side = vocal_pitch_sidecar_path(xml_path)
side.write_text(json.dumps(pitch, separators=(",", ":")),
encoding="utf-8")
written.append(side)
return written
def attach_vocal_sidecars_to_sloppak(
sloppak_dir: str | Path,
*,
lyrics: list | None = None,
vocal_pitch: dict | None = None,
lyrics_source: str = "authored",
) -> list[Path]:
"""Write ``lyrics.json`` / ``vocal_pitch.json`` into a directory-form
sloppak and point the top-level manifest ``lyrics`` / ``lyrics_source`` /
``vocal_pitch`` keys at them (feedpak spec §7.1/§7.2).
Vocal companion to gp2notation.attach_notation_to_sloppak, with the same
manifest round-trip caveat (PyYAML ``safe_load`` + ``safe_dump`` — key
order survives, comments don't). GP-derived payloads are ``authored``
provenance, so no ``lyric_transcription`` / ``pitch_extraction`` blocks
are written (the spec reserves those for automated engines).
Never clobbers: a payload whose manifest key is already set (or whose
target file already exists) is skipped, so a pak that already carries
lyrics/pitch — hand-edited or machine-extracted — is left alone.
Raises ``ValueError`` on a malformed payload, an unknown
``lyrics_source``, or a manifest that isn't a mapping. Returns the paths
actually written.
"""
import yaml
if lyrics_source not in ("authored", "transcribed", "user"):
raise ValueError(
f"lyrics_source must be authored/transcribed/user, got {lyrics_source!r}")
if lyrics is not None and not (
isinstance(lyrics, list) and all(
isinstance(e, dict)
and isinstance(e.get('w'), str)
and isinstance(e.get('t'), (int, float))
and isinstance(e.get('d'), (int, float))
for e in lyrics
)
):
raise ValueError("lyrics must be a list of {t, d, w} syllable dicts")
if vocal_pitch is not None and not (
isinstance(vocal_pitch, dict)
and isinstance(vocal_pitch.get('notes'), list)
):
raise ValueError("vocal_pitch must be a dict with a `notes` list")
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")
written: list[Path] = []
if lyrics and not manifest.get("lyrics") and not (pak / "lyrics.json").exists():
(pak / "lyrics.json").write_text(
json.dumps(lyrics, separators=(",", ":")), encoding="utf-8")
manifest["lyrics"] = "lyrics.json"
manifest["lyrics_source"] = lyrics_source
written.append(pak / "lyrics.json")
if (vocal_pitch and vocal_pitch.get("notes")
and not manifest.get("vocal_pitch")
and not (pak / "vocal_pitch.json").exists()):
(pak / "vocal_pitch.json").write_text(
json.dumps(vocal_pitch, separators=(",", ":")), encoding="utf-8")
manifest["vocal_pitch"] = "vocal_pitch.json"
written.append(pak / "vocal_pitch.json")
if written:
# Stamp the format version while we're rewriting the manifest (spec
# §4), without downgrading an existing 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",
)
return written
def _gpx_tuning(track: dict) -> list[int]: def _gpx_tuning(track: dict) -> list[int]:
"""Compute RS tuning offsets (semitones from standard) from GPX string pitches.""" """Compute RS tuning offsets (semitones from standard) from GPX string pitches."""
from gp2rs import STANDARD_TUNING_GUITAR, STANDARD_TUNING_BASS from gp2rs import STANDARD_TUNING_GUITAR, STANDARD_TUNING_BASS
+2 -11
View File
@@ -160,7 +160,6 @@ Each song object (built by `_meta()`):
- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`. - `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`.
- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit. - `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit.
- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects. - `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects.
- Hover-preview isn't resolved here at all — the cards just carry `data-fn`/`data-v3-play` markup and the `song_preview` plugin handles it (see Preview on Hover).
### extract_meta returns arrangements/stems as objects, not strings ### extract_meta returns arrangements/stems as objects, not strings
@@ -330,21 +329,13 @@ Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTM
- **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song) - **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song)
- **Enter confirms** — submits, equivalent to OK - **Enter confirms** — submits, equivalent to OK
## Preview on Hover
The Folder Library does **not** implement hover-preview itself — the `song_preview` plugin does, for the whole app. Its hover loop finds song elements by the selector `#v3-songs [data-fn]` (with a `[data-v3-play]` playable surface) and its `MutationObserver` watches the `#v3-songs` subtree — and the folder view (`#lib-folder-tree`) renders **inside** `#v3-songs`. So all folder_library has to do is give each card/row the standard markup:
- **`_songCard`** — `card.dataset.fn = song.filename` (raw filename) + `data-v3-play` on the art wrap (the surface `song_preview` overlays its indicator on).
- **`_songRow`** — `row.dataset.fn = song.filename` + `data-v3-play` on the thumb.
`song_preview` then previews folder view exactly like the grid/list — same audio (its `/audio` endpoint), same availability/404 handling, same indicator — with **zero preview code here**. If you change the card/row structure, keep `data-fn` (raw, not URL-encoded) and a `[data-v3-play]` descendant, or `song_preview` will stop recognising the cards.
## Roadmap ## Roadmap
Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache. Hover-preview is provided by the `song_preview` plugin via the `data-fn` markup above (not implemented here). Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache.
Not yet implemented, in rough priority order: Not yet implemented, in rough priority order:
- **Auto-play on hover** — with an on/off toggle saved to localStorage.
- **Bulk move** — multi-select songs and move them all at once. - **Bulk move** — multi-select songs and move them all at once.
- **Thumbnail performance** — faster loading and smoother scrolling with large libraries. - **Thumbnail performance** — faster loading and smoother scrolling with large libraries.
- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows. - **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows.
+1 -4
View File
@@ -30,7 +30,6 @@ A FeedBack (fee[dB]ack) plugin that organizes your `.sloppak` / `.feedpak` DLC s
- **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid - **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid
- **Album art** — pulls art automatically for every song in both views - **Album art** — pulls art automatically for every song in both views
- **One-click playback** — click any song to start playing immediately - **One-click playback** — click any song to start playing immediately
- **Preview song on hover** — hover a song to hear a quick preview of its audio (powered by the Song Preview plugin, the same way the grid and list views work)
- **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle - **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle
- **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support - **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support
- **Folder management** — create, rename, and delete folders without leaving the plugin - **Folder management** — create, rename, and delete folders without leaving the plugin
@@ -55,7 +54,6 @@ Folder Library ships bundled with FeedBack as a core plugin (`"bundled": true`),
| Switch to grid view | Click the grid icon in the toolbar | | Switch to grid view | Click the grid icon in the toolbar |
| Switch to list view | Click the list icon in the toolbar | | Switch to list view | Click the list icon in the toolbar |
| Play a song | Click any song row or card | | Play a song | Click any song row or card |
| Preview song on hover | Hover a song — the Song Preview plugin plays a quick clip (same as the grid/list views) |
| Sort songs | Use the sort dropdown in the toolbar | | Sort songs | Use the sort dropdown in the toolbar |
| Toggle sort direction | Click the arrow button next to the sort dropdown | | Toggle sort direction | Click the arrow button next to the sort dropdown |
| Open filters | Click the filter icon in the toolbar | | Open filters | Click the filter icon in the toolbar |
@@ -82,8 +80,7 @@ Folder Library started life as a standalone plugin with its own version line, bu
## Roadmap ## Roadmap
- [ ] Compatibility with core settings — respect Accessibility → Interface size - [ ] Auto play song on hover (with an on/off toggle)
- [ ] Core parity — Metadata, Unmatched, and Upload should work in the folder view too
- [ ] Bulk move — select multiple songs and move them at once - [ ] Bulk move — select multiple songs and move them at once
- [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries - [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries
- [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference - [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "folder_library", "id": "folder_library",
"name": "Folder Library", "name": "Folder Library",
"version": "1.9.0", "version": "1.8.0",
"bundled": true, "bundled": true,
"nav": { "label": "Folders", "screen": "plugin-folder_library" }, "nav": { "label": "Folders", "screen": "plugin-folder_library" },
"screen": "screen.html", "screen": "screen.html",
+4 -14
View File
@@ -735,11 +735,10 @@ function createFolderSurface(cfg) {
var card = document.createElement('div'); var card = document.createElement('div');
card.className = 'flex flex-col rounded-lg overflow-hidden cursor-pointer group transition-transform duration-100 hover:scale-105'; card.className = 'flex flex-col rounded-lg overflow-hidden cursor-pointer group transition-transform duration-100 hover:scale-105';
card.style.background = '#1a1d2e'; card.style.background = '#1a1d2e';
card.dataset.fn = song.filename; // data-fn (raw): song_preview's hover loop finds cards by this card.dataset.filename = song.filename;
var artWrap = document.createElement('div'); var artWrap = document.createElement('div');
artWrap.style.cssText = 'position:relative; width:100%; padding-bottom:100%; background:#111827; overflow:hidden;'; artWrap.style.cssText = 'position:relative; width:100%; padding-bottom:100%; background:#111827; overflow:hidden;';
artWrap.setAttribute('data-v3-play', ''); // the playable surface song_preview overlays its indicator on
var img = document.createElement('img'); var img = document.createElement('img');
img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover;'; img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover;';
img.alt = ''; img.loading = 'lazy'; img.alt = ''; img.loading = 'lazy';
@@ -805,11 +804,10 @@ function createFolderSurface(cfg) {
function _songRow(song, folderName) { function _songRow(song, folderName) {
var row = document.createElement('div'); var row = document.createElement('div');
row.className = 'flex items-center gap-3 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 group transition-colors duration-100'; row.className = 'flex items-center gap-3 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 group transition-colors duration-100';
row.dataset.fn = song.filename; // data-fn (raw): song_preview's hover loop finds rows by this row.dataset.filename = song.filename;
var thumb = document.createElement('div'); var thumb = document.createElement('div');
thumb.style.cssText = 'width:36px; height:36px; border-radius:4px; overflow:hidden; background:#111827; flex-shrink:0; position:relative;'; thumb.style.cssText = 'width:36px; height:36px; border-radius:4px; overflow:hidden; background:#111827; flex-shrink:0; position:relative;';
thumb.setAttribute('data-v3-play', ''); // marks the row previewable for song_preview
var tImg = document.createElement('img'); var tImg = document.createElement('img');
tImg.loading = 'lazy'; tImg.loading = 'lazy';
tImg.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art'; tImg.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art';
@@ -1709,16 +1707,8 @@ function createFolderSurface(cfg) {
init: _init, init: _init,
onScreenChanged: _onScreenChanged, onScreenChanged: _onScreenChanged,
render: _render, render: _render,
// Helpers exposed for tests. visibleWindow is pure; songCard/songRow // Pure window arithmetic, exposed for tests (no DOM needed).
// need a DOM (the tests supply a minimal element mock) and pin the __test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
// song_preview integration markup (data-fn + a data-v3-play surface).
__test: {
visibleWindow: _visibleWindow,
VIRTUAL_MIN: VIRTUAL_MIN,
VIRTUAL_BUFFER: VIRTUAL_BUFFER,
songCard: _songCard,
songRow: _songRow,
},
}; };
} }
@@ -1,116 +0,0 @@
// song_preview integration markup (feedBack — Folders view hover preview).
//
// The Folder Library does NOT implement hover-preview itself. It relies on the
// separate `song_preview` plugin, exactly like the grid and list views. That
// plugin's host adapter finds previewable elements with the selector
// `#v3-songs [data-fn]` and requires each to contain a `[data-v3-play]`
// descendant (the surface it overlays its indicator on), reading the raw
// filename from `data-fn`.
//
// So the ENTIRE contract Folder Library owns is: every song card and row it
// renders must carry `data-fn` (raw filename) and expose a `[data-v3-play]`
// surface. If a refactor drops either, folder cards silently stop previewing
// while grid/list keep working — a regression that's invisible without a live
// song_preview install. These tests pin the markup so that can't happen.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
// A minimal DOM element mock — just enough for _songCard / _songRow to run.
// No jsdom in this repo (see virtual_list.test.js); the element tracks the few
// things the contract cares about: dataset, attributes, and a child tree that
// querySelector('[data-v3-play]') can walk.
function makeEl(tag) {
const attrs = {};
const el = {
tagName: String(tag || '').toUpperCase(),
style: {}, // supports .cssText and arbitrary props
dataset: {},
className: '',
children: [],
parentNode: null,
addEventListener() {},
removeEventListener() {},
setAttribute(k, v) { attrs[k] = String(v); },
getAttribute(k) { return k in attrs ? attrs[k] : null; },
hasAttribute(k) { return k in attrs; },
appendChild(child) { el.children.push(child); if (child) child.parentNode = el; return child; },
classList: { add() {}, remove() {}, contains() { return false; }, toggle() {} },
remove() {},
// Only the '[data-v3-play]'-style attribute selector is needed.
querySelector(sel) {
const attr = sel.replace(/^\[|\]$/g, '');
const stack = el.children.slice();
while (stack.length) {
const n = stack.shift();
if (n && n.hasAttribute && n.hasAttribute(attr)) return n;
if (n && n.children) stack.push(...n.children);
}
return null;
},
};
return el;
}
function load() {
const window = {
console,
document: {
readyState: 'complete',
addEventListener() {},
getElementById() { return null; },
querySelector() { return null; },
querySelectorAll() { return []; },
createElement(tag) { return makeEl(tag); },
},
addEventListener() {},
localStorage: { getItem() { return null; }, setItem() {} },
performance: { now: () => 0 },
setInterval() { return 0; },
clearInterval() {},
requestAnimationFrame() { return 0; },
cancelAnimationFrame() {},
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
innerHeight: 800,
};
window.window = window;
window.globalThis = window;
const ctx = vm.createContext(window);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
return window.folderLibrary.__test;
}
const { songCard, songRow } = load();
// A raw filename with a subfolder + spaces — the kind of value song_preview
// URL-encodes downstream, so it must reach data-fn verbatim, not pre-encoded.
const FILENAME = 'Some Artist/A Song.sloppak';
const SONG = { filename: FILENAME, title: 'A Song', artist: 'Some Artist' };
test('song_preview helpers are exposed for the markup contract', () => {
assert.equal(typeof songCard, 'function');
assert.equal(typeof songRow, 'function');
});
test('grid card carries data-fn (raw) and a data-v3-play surface', () => {
const card = songCard(SONG, 'Unsorted');
assert.equal(card.dataset.fn, FILENAME, 'data-fn must be the raw, un-encoded filename');
assert.ok(card.querySelector('[data-v3-play]'), 'card must contain a [data-v3-play] surface');
});
test('list row carries data-fn (raw) and a data-v3-play surface', () => {
const row = songRow(SONG, 'Unsorted');
assert.equal(row.dataset.fn, FILENAME, 'data-fn must be the raw, un-encoded filename');
assert.ok(row.querySelector('[data-v3-play]'), 'row must contain a [data-v3-play] surface');
});
test('card renders without depending on any optional song metadata', () => {
// song_preview only needs filename; the card must build from a bare song
// (no duration/arrangements/stems/lyrics/tuning/year) without throwing.
assert.doesNotThrow(() => songCard({ filename: FILENAME }, 'Unsorted'));
assert.doesNotThrow(() => songRow({ filename: FILENAME }, 'Unsorted'));
});
+191
View File
@@ -7,6 +7,7 @@ the PR; here we pin the input-validation guards and the conversion helpers
that are easy to drive without a fixture. that are easy to drive without a fixture.
""" """
import json
import struct import struct
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -29,6 +30,10 @@ from gp2rs_gpx import (
_GPX_MAX_DECOMPRESSED, _GPX_MAX_DECOMPRESSED,
_find_piano_pairs, _find_piano_pairs,
convert_vocal_track_to_pitch_sidecar, convert_vocal_track_to_pitch_sidecar,
_vocals_xml_to_lyrics,
attach_vocal_sidecars_to_sloppak,
lyrics_sidecar_path,
vocal_pitch_sidecar_path,
_collect_tone_events, _collect_tone_events,
_inject_tones, _inject_tones,
_resolve_pending_slides, _resolve_pending_slides,
@@ -386,6 +391,192 @@ def test_vocal_pitch_sidecar_skips_beat_without_lyric():
assert out == {"version": 1, "notes": []} assert out == {"version": 1, "notes": []}
def test_vocal_pitch_sidecar_require_lyric_false_emits_melody():
# Lyric-less vocal track: with the gate relaxed the authored pitch still
# ships (there are no lyric tokens to stay aligned with).
out = convert_vocal_track_to_pitch_sidecar(
**_vocal_sidecar_args(with_lyric=False), require_lyric=False)
assert out == {"version": 1, "notes": [{"t": 0.0, "d": 0.5, "midi": 60}]}
# ── _vocals_xml_to_lyrics ───────────────────────────────────────────────────
def test_vocals_xml_to_lyrics_shape_and_suffixes():
xml = (
'<vocals count="4">'
'<vocal time="0.000" note="60" length="0.500" lyric="Hel-"/>'
'<vocal time="0.500" note="62" length="0.500" lyric="lo"/>'
'<vocal time="1.000" note="64" length="0.250" lyric="sing+"/>'
'<vocal time="1.250" note="64" length="0.250" lyric="ing"/>'
'</vocals>'
)
out = _vocals_xml_to_lyrics(xml)
assert out == [
# "-" means the same join in both conventions — passed through.
{"t": 0.0, "d": 0.5, "w": "Hel-"},
{"t": 0.5, "d": 0.5, "w": "lo"},
# XML "+" is a JOIN; feedpak "+" is a LINE END — joins become "-".
{"t": 1.0, "d": 0.25, "w": "sing-"},
{"t": 1.25, "d": 0.25, "w": "ing"},
]
def test_vocals_xml_to_lyrics_skips_bare_joiners_and_bad_xml():
xml = (
'<vocals count="2">'
'<vocal time="0.000" note="0" length="0.500" lyric="+"/>'
'<vocal time="0.500" note="60" length="0.500" lyric="la"/>'
'</vocals>'
)
assert _vocals_xml_to_lyrics(xml) == [{"t": 0.5, "d": 0.5, "w": "la"}]
assert _vocals_xml_to_lyrics("not xml <<<") == []
# ── convert_file end-to-end: vocal karaoke sidecars ─────────────────────────
# A vocal track must emit `<stem>.lyrics.json` + `<stem>.vocal_pitch.json`
# next to the vocals XML; non-vocal tracks must not; a lyric-less vocal track
# emits the pitch sidecar only.
_GPIF_VOCAL = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Vocals</Name>
<Property name="Tuning"><Pitches>60</Pitches></Property></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 1</Beats></Voice></Voices>
<Beats>
<Beat id="0"><Rhythm ref="r0"/><Lyrics><Line>Hel-</Line></Lyrics><Notes>0</Notes></Beat>
<Beat id="1"><Rhythm ref="r0"/><Lyrics><Line>lo</Line></Lyrics><Notes>1</Notes></Beat>
</Beats>
<Notes>
<Note id="0">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="1">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>2</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
# Same melody, no <Lyrics> anywhere.
_GPIF_VOCAL_NO_LYRICS = _GPIF_VOCAL.replace(
"<Lyrics><Line>Hel-</Line></Lyrics>", "").replace(
"<Lyrics><Line>lo</Line></Lyrics>", "")
def test_convert_file_vocal_track_emits_both_sidecars(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_VOCAL))
out_files = convert_file("dummy.gpx", str(tmp_path), track_indices=[0])
assert len(out_files) == 1
assert ET.parse(out_files[0]).getroot().tag == "vocals"
# lyrics.json sidecar: flat [{t, d, w}] (spec §7.1), timings from the XML.
lyr = json.loads(lyrics_sidecar_path(out_files[0]).read_text(encoding="utf-8"))
assert lyr == [
{"t": 0.0, "d": 0.5, "w": "Hel-"},
{"t": 0.5, "d": 0.5, "w": "lo"},
]
# vocal_pitch.json sidecar: {version, notes:[{t, d, midi}]} (spec §7.2),
# lyric-aligned (one note per syllable) at the authored pitches.
pitch = json.loads(vocal_pitch_sidecar_path(out_files[0]).read_text(encoding="utf-8"))
assert pitch == {"version": 1, "notes": [
{"t": 0.0, "d": 0.5, "midi": 60},
{"t": 0.5, "d": 0.5, "midi": 62},
]}
def test_convert_file_no_vocal_track_no_sidecars(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_GUITAR_ASCENDING))
convert_file("dummy.gp", str(tmp_path),
track_indices=[0], arrangement_names={0: "Lead"})
assert not list(tmp_path.glob("*.lyrics.json"))
assert not list(tmp_path.glob("*.vocal_pitch.json"))
def test_convert_file_lyricless_vocal_track_pitch_sidecar_only(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_VOCAL_NO_LYRICS))
out_files = convert_file("dummy.gpx", str(tmp_path), track_indices=[0])
assert len(out_files) == 1
# No lyric text anywhere -> no lyrics.json; the authored melody still
# ships as vocal_pitch.json (lyric gate relaxed for lyric-less tracks).
assert not lyrics_sidecar_path(out_files[0]).exists()
pitch = json.loads(vocal_pitch_sidecar_path(out_files[0]).read_text(encoding="utf-8"))
assert pitch == {"version": 1, "notes": [
{"t": 0.0, "d": 0.5, "midi": 60},
{"t": 0.5, "d": 0.5, "midi": 62},
]}
# ── attach_vocal_sidecars_to_sloppak ────────────────────────────────────────
_LYRICS_PAYLOAD = [{"t": 0.0, "d": 0.5, "w": "Hel-"}, {"t": 0.5, "d": 0.5, "w": "lo"}]
_PITCH_PAYLOAD = {"version": 1, "notes": [{"t": 0.0, "d": 0.5, "midi": 60}]}
def _make_pak(tmp_path, manifest: dict):
import yaml
pak = tmp_path / "pak"
pak.mkdir()
(pak / "manifest.yaml").write_text(
yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8")
return pak
def test_attach_vocal_sidecars_writes_files_and_manifest(tmp_path):
import yaml
pak = _make_pak(tmp_path, {"title": "T", "arrangements": []})
written = attach_vocal_sidecars_to_sloppak(
pak, lyrics=_LYRICS_PAYLOAD, vocal_pitch=_PITCH_PAYLOAD)
assert written == [pak / "lyrics.json", pak / "vocal_pitch.json"]
assert json.loads((pak / "lyrics.json").read_text(encoding="utf-8")) == _LYRICS_PAYLOAD
assert json.loads((pak / "vocal_pitch.json").read_text(encoding="utf-8")) == _PITCH_PAYLOAD
manifest = yaml.safe_load((pak / "manifest.yaml").read_text(encoding="utf-8"))
assert manifest["lyrics"] == "lyrics.json"
assert manifest["lyrics_source"] == "authored" # GP tab = authored chart
assert manifest["vocal_pitch"] == "vocal_pitch.json"
# No automated-engine provenance for authored payloads (spec §7.1.1/§7.2.1).
assert "lyric_transcription" not in manifest
assert "pitch_extraction" not in manifest
assert "feedpak_version" in manifest
def test_attach_vocal_sidecars_never_clobbers(tmp_path):
import yaml
pak = _make_pak(tmp_path, {
"title": "T",
"lyrics": "existing_lyrics.json",
"lyrics_source": "user",
})
written = attach_vocal_sidecars_to_sloppak(
pak, lyrics=_LYRICS_PAYLOAD, vocal_pitch=_PITCH_PAYLOAD)
# lyrics already claimed by the manifest -> skipped entirely; pitch is new.
assert written == [pak / "vocal_pitch.json"]
assert not (pak / "lyrics.json").exists()
manifest = yaml.safe_load((pak / "manifest.yaml").read_text(encoding="utf-8"))
assert manifest["lyrics"] == "existing_lyrics.json"
assert manifest["lyrics_source"] == "user"
def test_attach_vocal_sidecars_rejects_bad_payloads(tmp_path):
pak = _make_pak(tmp_path, {"title": "T"})
with pytest.raises(ValueError):
attach_vocal_sidecars_to_sloppak(pak, lyrics=[{"t": 0.0}]) # missing d/w
with pytest.raises(ValueError):
attach_vocal_sidecars_to_sloppak(pak, vocal_pitch={"version": 1}) # no notes
with pytest.raises(ValueError):
attach_vocal_sidecars_to_sloppak(
pak, lyrics=_LYRICS_PAYLOAD, lyrics_source="whisperx") # not spec enum
# ── _collect_tone_events ──────────────────────────────────────────────────── # ── _collect_tone_events ────────────────────────────────────────────────────
def _tone_args(banks, tempo_map=((0, 120.0),)): def _tone_args(banks, tempo_map=((0, 120.0),)):
+1
View File
@@ -58,6 +58,7 @@ READERS = [
"lib/enrichment.py", "lib/enrichment.py",
"lib/songmeta.py", "lib/songmeta.py",
"lib/gp2notation.py", # rewrites manifest.yaml; stamps feedpak_version "lib/gp2notation.py", # rewrites manifest.yaml; stamps feedpak_version
"lib/gp2rs_gpx.py", # attach_vocal_sidecars_to_sloppak: lyrics/vocal_pitch keys
"lib/routers/ws_highway.py", # reads `authors` off a feedpak manifest "lib/routers/ws_highway.py", # reads `authors` off a feedpak manifest
"lib/routers/chart.py", # Get-info panel: binds `m = load_manifest(...)` "lib/routers/chart.py", # Get-info panel: binds `m = load_manifest(...)`
"lib/routers/song.py", # enrichment gap-fill: reads the manifest directly "lib/routers/song.py", # enrichment gap-fill: reads the manifest directly