mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 22:38:33 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9157e2ecf7 | ||
|
|
51fe217e15 | ||
|
|
9e5e57f048 | ||
|
|
ecf559cd6d | ||
|
|
ad9ad229c9 | ||
|
|
9f8e1e6f61 | ||
|
|
28bfa6ae0b |
@@ -183,6 +183,11 @@ 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
|
||||||
|
|||||||
+9
-225
@@ -12,7 +12,6 @@ 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
|
||||||
@@ -1187,7 +1186,6 @@ 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.
|
||||||
@@ -1198,17 +1196,14 @@ 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
|
This is complementary to convert_vocal_track() which produces arrangement XML.
|
||||||
XML. convert_file() calls both for every vocal track and writes the result
|
NOTE: nothing in this module calls this helper yet — convert_file() does not
|
||||||
as a ``<stem>.vocal_pitch.json`` sidecar next to the vocals XML (see
|
invoke it, so no vocal_pitch.json is emitted automatically. A caller wanting
|
||||||
_emit_vocal_sidecars); the sloppak assembly step then attaches it via
|
the pitch ribbon must call this itself and persist the returned dict (e.g.
|
||||||
attach_vocal_sidecars_to_sloppak.
|
write it as vocal_pitch.json into the sloppak). When wiring vocals into a
|
||||||
|
sloppak, call both:
|
||||||
``require_lyric`` (default True) keeps the feedpak-spec §7.2 alignment:
|
- convert_vocal_track() → vocals arrangement XML (karaoke highway)
|
||||||
only beats carrying a lyric emit a note, so the pitch ribbon mirrors
|
- convert_vocal_track_to_pitch_sidecar() → vocal_pitch.json (pitch ribbon)
|
||||||
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.
|
||||||
@@ -1263,15 +1258,13 @@ 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 require_lyric and not has_lyric:
|
if not has_lyric:
|
||||||
voice_time += dur
|
voice_time += dur
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1689,26 +1682,6 @@ 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
|
||||||
@@ -2430,195 +2403,6 @@ 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
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ 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
|
||||||
|
|
||||||
@@ -329,13 +330,21 @@ 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.
|
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).
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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
|
||||||
@@ -54,6 +55,7 @@ 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 |
|
||||||
@@ -80,7 +82,8 @@ Folder Library started life as a standalone plugin with its own version line, bu
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [ ] Auto play song on hover (with an on/off toggle)
|
- [ ] Compatibility with core settings — respect Accessibility → Interface size
|
||||||
|
- [ ] 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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "folder_library",
|
"id": "folder_library",
|
||||||
"name": "Folder Library",
|
"name": "Folder Library",
|
||||||
"version": "1.8.0",
|
"version": "1.9.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",
|
||||||
|
|||||||
@@ -735,10 +735,11 @@ 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.filename = song.filename;
|
card.dataset.fn = song.filename; // data-fn (raw): song_preview's hover loop finds cards by this
|
||||||
|
|
||||||
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';
|
||||||
@@ -804,10 +805,11 @@ 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.filename = song.filename;
|
row.dataset.fn = song.filename; // data-fn (raw): song_preview's hover loop finds rows by this
|
||||||
|
|
||||||
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';
|
||||||
@@ -1707,8 +1709,16 @@ function createFolderSurface(cfg) {
|
|||||||
init: _init,
|
init: _init,
|
||||||
onScreenChanged: _onScreenChanged,
|
onScreenChanged: _onScreenChanged,
|
||||||
render: _render,
|
render: _render,
|
||||||
// Pure window arithmetic, exposed for tests (no DOM needed).
|
// Helpers exposed for tests. visibleWindow is pure; songCard/songRow
|
||||||
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
|
// need a DOM (the tests supply a minimal element mock) and pin the
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// 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'));
|
||||||
|
});
|
||||||
@@ -7,7 +7,6 @@ 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
|
||||||
|
|
||||||
@@ -30,10 +29,6 @@ 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,
|
||||||
@@ -391,192 +386,6 @@ 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),)):
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user