Compare commits

..
Author SHA1 Message Date
Kyle 9157e2ecf7 Remove hover-preview; delegate to song_preview plugin
Folder Library now uses the standard data-fn/data-v3-play markup so the song_preview plugin handles hover-previews, the same way it does for grid and list views. Removes ~200 lines of preview-specific code: dedicated audio element, indicator rendering, toolbar toggle, and backend manifest checking. The preview experience is now unified across all library surfaces.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:15 -04:00
Kyle 51fe217e15 Remove outdated screen.js caching note
Remove the developer note about manually bumping plugin.json version when changing screen.js

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:15 -04:00
Kyle 9e5e57f048 Use manifest preview flag to avoid hover 404s
Expose a has_preview boolean from pack manifests and use it to gate hover previews. backend: plugins/folder_library/routes.py now (optionally) reads sloppak.load_manifest to set m['has_preview'] (guarded so plugin still loads without sloppak). frontend: plugins/folder_library/screen.js skips preview requests when song.has_preview is false and removes the HEAD-probe + previewMissing cache. docs: plugins/folder_library/CLAUDE.md updated to document has_preview and the preview behavior. This prevents unnecessary HEAD/audio 404s and console noise.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:15 -04:00
Kyle ecf559cd6d Refactor folder_library preview to delegate to song_preview
Remove audio member resolution from folder_library and delegate hover-preview audio to the canonical song_preview plugin endpoint. This separates concerns: folder_library now only builds the preview URL with the filename, while song_preview handles manifest resolution (preview: key, stem fallback) and Range support.

Changes:
- Remove _audio_member() resolver and audio_member field from pack metadata
- Update _previewUrl() to point to /api/plugins/song_preview/audio?file=<filename>
- Add _previewMissing cache to avoid re-requesting packs with no preview
- Add HEAD probe to check preview availability before playing
- Add test coverage for _previewUrl with special character encoding

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:14 -04:00
Kyle ad9ad229c9 Folder Library: enable hover preview by default
Make the hover-preview feature opt-out instead of opt-in (defaults to on). Increase dwell delay from 500ms to 800ms to prevent accidental triggers while clicking/dragging. Replace 4-bar equalizer indicator with 9-bar waveform with staggered animation and fade-in entrance. Add guards to prevent preview during drag operations. Update button styling and all user-facing docs to reflect new defaults.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:14 -04:00
Kyle 9f8e1e6f61 Resolve in-pack audio on backend
Move audio member selection logic from frontend to backend. The frontend was probing multiple candidate audio files with error/retry logic, often resulting in 404s. Now the backend resolves the best available audio file (preview.ogg, stems/full.ogg, or any stem) during metadata extraction and includes it in song metadata as `audio_member`. The frontend makes a single, guaranteed request instead of multiple probes. Simplifies the preview flow and improves reliability.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:13 -04:00
Kyle 28bfa6ae0b Add preview-on-hover to Folder Library
Implement optional audio preview when hovering over songs in the Folders view. Features include:
- Toolbar toggle button (off by default) to enable/disable hover preview
- ~0.5s dwell delay to avoid accidental audio playback while scrolling
- Dedicated <audio> element (never touches main player)
- Equalizer indicator animation overlay on song art while playing
- Per-surface localStorage persistence
- Automatic fallback through preview.ogg → stems/full.ogg → stems/audio.mp3

Bump Folder Library from 1.8.0 to 1.9.0. Remove 'auto play on hover' from roadmap as it's now implemented.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:13 -04:00
8 changed files with 153 additions and 726 deletions
+5
View File
@@ -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
at `default: off`, drops the key); the fallback and the aliases are removed once
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
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
+2 -436
View File
@@ -1,6 +1,6 @@
"""MIDI file import — list tracks and convert tracks to sloppak payloads.
Three parallel flows live here:
Two parallel flows live here:
- **Keys path** (`list_midi_tracks` + `convert_midi_track_to_keys_wire`):
filters channel-9 out and emits a standard guitar-style arrangement that
@@ -11,19 +11,12 @@ Three parallel flows live here:
`docs/sloppak-spec.md` §5.3, ready to drop alongside the sloppak
manifest's `drum_tab:` key.
- **Lyrics path** (`extract_midi_lyrics`): reads SMF Lyric (0x05) meta events
(with a Text-event fallback on vocal-ish tracks, covering karaoke `.kar`
files) and emits the `lyrics.json` / `vocal_pitch.json` sidecar payloads
documented in feedpak-spec §7.1 / §7.2, ready to drop alongside the
manifest's `lyrics:` / `lyrics_source:` / `vocal_pitch:` keys.
The editor's track picker uses the first two for the +Drums and +Keys modals.
The editor's track picker uses both for the +Drums and +Keys modals.
"""
from __future__ import annotations
import math
import re
from bisect import bisect_right
from collections import deque
from typing import Callable
@@ -784,430 +777,3 @@ def convert_drum_track_from_midi(
],
"hits": out_hits,
}
# ── Lyrics + vocal-melody extraction ─────────────────────────────────────────
# Vocal-track detection, mirroring the idiom in `lib/gp2rs_gpx.py`'s
# `_is_vocal_track` (GM voice/choir/lead-voice programs + name keywords).
# Kept as a local copy because that helper consumes gp2rs_gpx's own GP track
# dicts, not raw MIDI tracks. "melody" is added to the name hints: karaoke
# MIDIs commonly label the sung line "Melody" rather than "Vocals".
_VOCAL_MIDI_PROGRAMS = {52, 53, 54, 85, 86, 87} # Choir Aahs, Voice Oohs, Synth Voice, Lead 5-7 (voice)
_VOCAL_NAME_HINTS = ("vocal", "voice", "vox", "sing", "lyric", "choir", "melody")
# A dedicated karaoke *text* track (SMF 0x01 Text events, `.kar` convention)
# is usually noteless and named "Words" or "Soft Karaoke" — names the vocal
# hints above don't catch. Only the Text-event fallback consults this wider
# set; note-track detection sticks to the gp2rs_gpx idiom.
_LYRIC_TEXT_TRACK_HINTS = _VOCAL_NAME_HINTS + ("words", "karaoke")
# A lyric event pairs with a vocal note-on when their onsets sit within this
# window. Karaoke files place the lyric event at (or a hair before) the
# note-on tick, so real matches are ~0; the window only absorbs sloppy
# authoring, and staying well under a typical syllable gap keeps a melisma's
# extra notes from being stolen by the next syllable.
_LYRIC_PAIR_TOLERANCE_S = 0.30
# Duration bounds for lyric entries with no pairable note (spoken lines,
# lyrics-only files). "Until the next lyric event" is the natural display
# duration, capped so a verse-final syllable before a long instrumental
# break doesn't linger on screen, and floored so simultaneous/out-of-order
# events can't produce a zero or negative duration.
_UNPAIRED_LYRIC_MAX_D = 2.0
_UNPAIRED_LYRIC_MIN_D = 0.1
# Leading/word/trailing whitespace splitter for lyric tokens. DOTALL so
# embedded newlines land in a group rather than killing the match.
_LYRIC_TOKEN_RE = re.compile(r"^(\s*)(.*?)(\s*)$", re.S)
def _scan_tracks_for_lyrics(midi: mido.MidiFile) -> list[dict]:
"""One pass per track collecting the raw material `extract_midi_lyrics`
needs: name, per-channel programs, melodic (non-drum) notes with their
on/off ticks, and Lyric/Text meta events.
Each item: {name, channel_programs: {ch: program}, notes:
[(start_tick, end_tick, pitch, channel)], lyric_events: [(tick, text)],
text_events: [(tick, text)]}. Note pairing uses the same FIFO
note_on/note_off matching as the keys converter so retriggers don't
cross-wire durations.
"""
out: list[dict] = []
for track in midi.tracks:
name = ""
channel_programs: dict[int, int] = {}
lyric_events: list[tuple[int, str]] = []
text_events: list[tuple[int, str]] = []
notes: list[tuple[int, int, int, int]] = []
active: dict[tuple[int, int], deque[int]] = {}
abs_tick = 0
for msg in track:
abs_tick += msg.time
if msg.type == "track_name" and not name:
name = msg.name or ""
elif msg.type == "lyrics":
lyric_events.append((abs_tick, msg.text or ""))
elif msg.type == "text":
text_events.append((abs_tick, msg.text or ""))
elif msg.type == "program_change":
ch = int(getattr(msg, "channel", -1))
if ch != 9 and ch not in channel_programs:
channel_programs[ch] = int(msg.program)
elif msg.type == "note_on" and int(getattr(msg, "velocity", 0)) > 0:
ch = int(getattr(msg, "channel", -1))
if ch == 9:
continue
active.setdefault((ch, int(msg.note)), deque()).append(abs_tick)
elif msg.type == "note_off" or (
msg.type == "note_on" and int(getattr(msg, "velocity", 0)) == 0
):
ch = int(getattr(msg, "channel", -1))
pitch = int(msg.note)
stack = active.get((ch, pitch))
if not stack:
continue
start_tick = stack.popleft()
if not stack:
active.pop((ch, pitch), None)
notes.append((start_tick, abs_tick, pitch, ch))
# Close anything left hanging at end-of-track, mirroring the keys
# converter's end-of-track sweep.
for (ch, pitch), starts in active.items():
for start_tick in starts:
notes.append((start_tick, abs_tick, pitch, ch))
notes.sort(key=lambda n: n[0])
out.append({
"name": name,
"channel_programs": channel_programs,
"notes": notes,
"lyric_events": lyric_events,
"text_events": text_events,
})
return out
def _name_matches(name: str, hints: tuple[str, ...]) -> bool:
name_l = (name or "").lower()
return any(h in name_l for h in hints)
def _normalize_lyric_tokens(events: list[tuple[int, str]]) -> list[dict]:
"""Turn raw lyric/text meta events into clean syllable tokens.
Handles both encodings seen in the wild:
- **`.kar` / karaoke convention**: `/` prefix = new line, `\\` prefix =
new paragraph (both mean "the previous syllable ended a line"),
`-` suffix = syllable joins the next one, `@`-prefixed tokens are
file metadata (`@KMIDI`, `@T<title>`, ...) and are dropped.
- **Plain Lyric-event convention**: word boundaries carried by leading
or trailing spaces; line breaks carried by embedded CR/LF.
Each token: {tick, word, lead_ws, trail_ws, line_end}. Spacing-only and
newline-only events don't emit a token — they fold their meaning
(word-break / line-end) onto the previous one.
"""
toks: list[dict] = []
for tick, raw in events:
text = "" if raw is None else str(raw)
if not text:
continue
if text.lstrip().startswith(("@", "%")):
# .kar metadata / sequencer directives, not sung text.
continue
kar_break = text[0] in ("/", "\\")
if kar_break:
text = text[1:]
m = _LYRIC_TOKEN_RE.match(text)
head, body, tail = m.group(1), m.group(2), m.group(3)
nl_before = ("\n" in head) or ("\r" in head)
nl_after = ("\n" in tail) or ("\r" in tail)
if "\n" in body or "\r" in body:
# Rare multi-line event: keep it one token, treat the break as
# trailing so the line ends after this token.
body = re.sub(r"[\r\n]+", " ", body).strip()
nl_after = True
if (kar_break or nl_before) and toks:
toks[-1]["line_end"] = True
if not body:
# Pure spacing/newline token: fold onto the previous syllable.
if nl_after and toks:
toks[-1]["line_end"] = True
if toks:
toks[-1]["trail_ws"] = True
continue
toks.append({
"tick": tick,
"word": body,
"lead_ws": bool(head),
"trail_ws": bool(tail),
"line_end": nl_after,
})
return toks
def _apply_word_conventions(toks: list[dict]) -> list[str]:
"""Map tokens to spec §7.1 `w` strings: trailing ``-`` joins to the next
syllable, trailing ``+`` ends a line.
Which join convention the source used is detected per stream:
- Any token already carrying a ``-`` suffix the stream is
hyphen-delimited (`.kar` style); those suffixes are the spec's own
join marker and pass through untouched.
- Otherwise, if the stream carries any spacing at all space-delimited:
a token with no trailing space followed by a token with no leading
space is a mid-word syllable and gains a ``-``.
- No hyphens and no spacing anywhere the tokens are whole words
(common for Text-event lyrics); no joins are synthesized.
"""
has_hyphens = any(t["word"].endswith("-") for t in toks)
has_spacing = any(t["lead_ws"] or t["trail_ws"] for t in toks)
words: list[str] = []
for i, tk in enumerate(toks):
w = tk["word"]
nxt = toks[i + 1] if i + 1 < len(toks) else None
if tk["line_end"]:
# A join can't cross a line break — the line marker wins.
if w.endswith("-"):
w = w[:-1]
if w and not w.endswith("+"):
w += "+"
elif nxt is not None and not has_hyphens and has_spacing:
if not tk["trail_ws"] and not nxt["lead_ws"] and not w.endswith("-"):
w += "-"
words.append(w)
return words
def _select_vocal_notes(
scans: list[dict],
lyric_track_index: int,
midi_type: int,
) -> tuple[int, list[tuple[int, int, int, int]]] | None:
"""Pick the note pool the lyric syllables should be pitch-paired with.
Returns ``(track_index, notes)`` or ``None`` when no vocal melody is
identifiable ( lyrics-only import). Selection order:
1. The lyric-carrying track itself, when it has notes:
- channels with a vocal GM program only those channels' notes
(isolates the sung line inside a format-0 everything-in-one-track
file);
- vocal-ish track name all its non-drum notes;
- SMF type 1/2 with neither still trusted: a track that interleaves
per-syllable Lyric events with its own notes *is* the karaoke
melody by construction. Format-0 files don't get this benefit of
the doubt there the single track holds every instrument, so
without a vocal program/name there is no way to isolate the melody
and we fall back to lyrics-only.
2. Otherwise (dedicated noteless "Words" track), the vocal-ish track
by name hint or vocal GM program, mirroring gp2rs_gpx with the
most notes; within it, vocal-program channels only when present.
"""
def _vocal_channels(scan: dict) -> set[int]:
return {
ch for ch, prog in scan["channel_programs"].items()
if prog in _VOCAL_MIDI_PROGRAMS
}
def _pool(scan: dict) -> list[tuple[int, int, int, int]]:
chans = _vocal_channels(scan)
if chans:
return [n for n in scan["notes"] if n[3] in chans]
return scan["notes"]
src = scans[lyric_track_index]
if src["notes"]:
if _vocal_channels(src) or _name_matches(src["name"], _VOCAL_NAME_HINTS):
return lyric_track_index, _pool(src)
if midi_type != 0:
return lyric_track_index, src["notes"]
return None
best: tuple[int, list] | None = None
for i, scan in enumerate(scans):
if not scan["notes"]:
continue
if not (_vocal_channels(scan)
or _name_matches(scan["name"], _VOCAL_NAME_HINTS)):
continue
pool = _pool(scan)
if pool and (best is None or len(pool) > len(best[1])):
best = (i, pool)
return best
def extract_midi_lyrics(midi_path: str, audio_offset: float = 0.0) -> dict | None:
"""Extract lyrics (and, when pairable, the vocal melody) from a `.mid`.
Returns ``None`` when the file carries no usable lyric events callers
then change nothing, leaving any existing manifest keys and sidecar
files untouched. Otherwise returns::
{
"lyrics": [{"t": float, "d": float, "w": str}, ...],
"lyrics_source": "authored",
"vocal_pitch": {"version": 1,
"notes": [{"t", "d", "midi"}, ...]} | None,
}
``lyrics`` is the feedpak `lyrics.json` payload (spec §7.1: flat list,
no version field; ``w`` uses trailing ``-`` for syllable joins and
trailing ``+`` for line ends). ``vocal_pitch`` is the `vocal_pitch.json`
payload (spec §7.2, same shape as gp2rs_gpx's
``convert_vocal_track_to_pitch_sidecar`` and the lyrics-karaoke
plugin's ``_persist_pitch``) — ``None`` when no vocal note track could
be identified, in which case the caller writes `lyrics.json` only.
``lyrics_source`` is always ``"authored"`` (spec §7.1 vocabulary):
lyric meta events are chart-author data, not machine transcription.
Callers assembling a pack write ``lyrics.json`` /
``vocal_pitch.json`` and set the manifest ``lyrics`` /
``lyrics_source`` / ``vocal_pitch`` keys and should do so only for
keys not already present, so an import never clobbers lyrics that
arrived from another source.
Sourcing rules:
- Lyric text comes from SMF Lyric (0x05) meta events the track with
the most of them wins when several carry some. When the file has
none at all, Text (0x01) events are accepted as a fallback, but only
from a vocal-ish track (gp2rs_gpx-style name/program detection,
widened with "words"/"karaoke" for `.kar` text tracks) Text events
elsewhere are copyright notices / markers, not lyrics.
- `.kar` conventions are normalized (see ``_normalize_lyric_tokens`` /
``_apply_word_conventions``): ``/`` and ``\\`` line-break prefixes
become the spec's ``+`` suffix on the previous syllable, ``@``
metadata tokens are dropped, ``-`` hyphen joins pass through.
- Each syllable is paired with the vocal note (see
``_select_vocal_notes``) whose onset falls within
``_LYRIC_PAIR_TOLERANCE_S`` of the lyric event, greedily in time
order, one note per syllable. Paired syllables snap ``t``/``d`` to
the note (the authored melody is timing-authoritative, and keeps
`lyrics.json` and `vocal_pitch.json` mirrored per §7.2); a melisma's
extra notes are skipped. Unpaired syllables (talkies) keep the lyric
event's own time and run until the next syllable, clamped to
[``_UNPAIRED_LYRIC_MIN_D``, ``_UNPAIRED_LYRIC_MAX_D``] they appear
in ``lyrics`` only, which spec §7.2 explicitly allows
(`vocal_pitch.notes` MAY be shorter than `lyrics.json`).
``audio_offset`` (seconds) shifts every emitted time, same handle as
the keys/drums converters. Tempo-map scope per SMF type also matches
them (type 2 reads only the involved track's tempo events).
"""
offset = float(audio_offset)
if not math.isfinite(offset):
raise ValueError(f"audio_offset must be a finite number, got {audio_offset!r}")
midi = mido.MidiFile(midi_path)
midi_type = getattr(midi, "type", 1)
scans = _scan_tracks_for_lyrics(midi)
# ── choose the lyric event stream ────────────────────────────────────
lyric_idx = -1
best_count = 0
for i, scan in enumerate(scans):
if len(scan["lyric_events"]) > best_count:
lyric_idx = i
best_count = len(scan["lyric_events"])
if lyric_idx >= 0:
toks = _normalize_lyric_tokens(scans[lyric_idx]["lyric_events"])
else:
# Text-event fallback: vocal-ish tracks only (plus .kar "Words" /
# "Soft Karaoke" text tracks). Normalize before counting so a track
# of @-metadata can't outscore a real lyric track.
toks = []
for i, scan in enumerate(scans):
if not scan["text_events"]:
continue
vocal_prog = any(
p in _VOCAL_MIDI_PROGRAMS
for p in scan["channel_programs"].values()
)
if not (vocal_prog
or _name_matches(scan["name"], _LYRIC_TEXT_TRACK_HINTS)):
continue
cand = _normalize_lyric_tokens(scan["text_events"])
if len(cand) > len(toks):
lyric_idx = i
toks = cand
if lyric_idx < 0 or not toks:
return None
words = _apply_word_conventions(toks)
lyric_tick_to_seconds = _build_tick_to_seconds(midi, lyric_idx)
# ── pick + time the vocal note pool ──────────────────────────────────
picked = _select_vocal_notes(scans, lyric_idx, midi_type)
vocal_notes: list[dict] = []
if picked is not None:
note_idx, pool = picked
# Type-2 tracks own independent timelines — time the notes through
# their own track's tempo scope (same map as the lyric track for
# type 0/1, where tempo is merged across tracks anyway).
note_tick_to_seconds = (
lyric_tick_to_seconds if note_idx == lyric_idx
else _build_tick_to_seconds(midi, note_idx)
)
for start_tick, end_tick, pitch, _ch in pool:
t = note_tick_to_seconds(start_tick)
vocal_notes.append({
"t": t,
"d": max(0.0, note_tick_to_seconds(end_tick) - t),
"midi": int(pitch),
})
vocal_notes.sort(key=lambda n: n["t"])
# ── pair syllables with notes (greedy, time-ordered) ─────────────────
entries: list[dict] = [] # {t, d (None until resolved), w, paired}
j = 0
for tk, w in zip(toks, words):
t_lyric = lyric_tick_to_seconds(tk["tick"])
while (j < len(vocal_notes)
and vocal_notes[j]["t"] < t_lyric - _LYRIC_PAIR_TOLERANCE_S):
j += 1
if (j < len(vocal_notes)
and vocal_notes[j]["t"] <= t_lyric + _LYRIC_PAIR_TOLERANCE_S):
note = vocal_notes[j]
j += 1
entries.append({
"t": note["t"], "d": note["d"], "w": w,
"midi": note["midi"], "paired": True,
})
else:
entries.append({"t": t_lyric, "d": None, "w": w, "paired": False})
# Snapping can nudge a paired syllable past an unpaired neighbour;
# sort so both sidecars stay chronological for downstream consumers.
entries.sort(key=lambda e: e["t"])
# Unpaired durations: until the next syllable, clamped. Resolved after
# the sort so "next" is the true chronological neighbour.
for i, e in enumerate(entries):
if e["d"] is None:
if i + 1 < len(entries):
gap = entries[i + 1]["t"] - e["t"]
d = min(gap, _UNPAIRED_LYRIC_MAX_D)
else:
d = _UNPAIRED_LYRIC_MAX_D
e["d"] = max(d, _UNPAIRED_LYRIC_MIN_D)
lyrics_out = [
{"t": round(e["t"] + offset, 3), "d": round(e["d"], 3), "w": e["w"]}
for e in entries
]
pitch_notes = [
{"t": round(e["t"] + offset, 3), "d": round(e["d"], 3),
"midi": int(e["midi"])}
for e in entries if e["paired"]
]
return {
"lyrics": lyrics_out,
"lyrics_source": "authored",
"vocal_pitch": (
{"version": 1, "notes": pitch_notes} if pitch_notes else None
),
}
+11 -2
View File
@@ -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()`.
- `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.
- 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
@@ -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)
- **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
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:
- **Auto-play on hover** — with an on/off toggle saved to localStorage.
- **Bulk move** — multi-select songs and move them all at once.
- **Thumbnail performance** — faster loading and smoother scrolling with large libraries.
- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows.
+4 -1
View File
@@ -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
- **Album art** — pulls art automatically for every song in both views
- **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
- **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
@@ -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 list view | Click the list icon in the toolbar |
| 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 |
| Toggle sort direction | Click the arrow button next to the sort dropdown |
| 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
- [ ] 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
- [ ] 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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "folder_library",
"name": "Folder Library",
"version": "1.8.0",
"version": "1.9.0",
"bundled": true,
"nav": { "label": "Folders", "screen": "plugin-folder_library" },
"screen": "screen.html",
+14 -4
View File
@@ -735,10 +735,11 @@ function createFolderSurface(cfg) {
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.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');
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');
img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover;';
img.alt = ''; img.loading = 'lazy';
@@ -804,10 +805,11 @@ function createFolderSurface(cfg) {
function _songRow(song, folderName) {
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.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');
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');
tImg.loading = 'lazy';
tImg.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art';
@@ -1707,8 +1709,16 @@ function createFolderSurface(cfg) {
init: _init,
onScreenChanged: _onScreenChanged,
render: _render,
// Pure window arithmetic, exposed for tests (no DOM needed).
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
// Helpers exposed for tests. visibleWindow is pure; songCard/songRow
// 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'));
});
-282
View File
@@ -1,282 +0,0 @@
"""Tests for lib/midi_import.py — extract_midi_lyrics (lyrics + vocal melody).
Synthetic mido.MidiFile objects are built in-memory and saved to tmp_path,
same style as test_midi_import.py.
Covers:
- Lyric (0x05) events on a vocal track lyrics.json + vocal_pitch.json payloads
- lyric/note pairing snaps t/d to the note; midi pitch carried through
- lyrics with no identifiable vocal track lyrics payload only
- no lyric events at all None (import behavior unchanged)
- .kar '/' line-break prefixes spec trailing '+' on the previous syllable
- .kar '-' hyphen joins pass through untouched
- space-delimited syllable streams gain '-' joins
- '@'-metadata tokens dropped; Text-event (0x01) fallback on vocal-ish tracks
- Text events on non-vocal tracks are NOT treated as lyrics
- unpaired lyric durations run to the next syllable, capped at 2.0 s
- format-0 mixed-channel file pairs only the vocal-program channel
- vocal GM program (52-54 / 85-87) detection without a track name
- audio_offset applied to both payloads
"""
import pytest
import mido
from midi_import import extract_midi_lyrics
TPB = 480 # ticks per beat; default 120 BPM → 480 ticks = 0.5 s
def _save(mid: mido.MidiFile, tmp_path, name: str = "test.mid") -> str:
p = tmp_path / name
mid.save(str(p))
return str(p)
def _vocal_file(tmp_path, *, track_name="Vocals", program=None, meta="lyrics",
syllables=("Hel-", "lo", "world")):
"""Type-1 file: conductor + one melody track carrying notes with a lyric
event at each note-on. Notes: 60, 62, 64, each one beat (0.5 s) long,
back to back."""
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
conductor = mido.MidiTrack()
mid.tracks.append(conductor)
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
tr = mido.MidiTrack()
mid.tracks.append(tr)
if track_name:
tr.append(mido.MetaMessage("track_name", name=track_name, time=0))
if program is not None:
tr.append(mido.Message("program_change", channel=0, program=program, time=0))
for i, syl in enumerate(syllables):
tr.append(mido.MetaMessage(meta, text=syl, time=0))
tr.append(mido.Message("note_on", channel=0, note=60 + 2 * i,
velocity=90, time=0))
tr.append(mido.Message("note_off", channel=0, note=60 + 2 * i,
velocity=0, time=TPB))
return _save(mid, tmp_path)
# ── both sidecars from a lyric+vocal-track file ──────────────────────────────
def test_vocal_track_emits_both_payloads(tmp_path):
result = extract_midi_lyrics(_vocal_file(tmp_path))
assert result is not None
assert result["lyrics_source"] == "authored"
lyr = result["lyrics"]
assert [e["w"] for e in lyr] == ["Hel-", "lo", "world"]
assert [e["t"] for e in lyr] == pytest.approx([0.0, 0.5, 1.0])
# Paired syllables snap d to the note duration (1 beat = 0.5 s).
assert [e["d"] for e in lyr] == pytest.approx([0.5, 0.5, 0.5])
vp = result["vocal_pitch"]
assert vp is not None
assert vp["version"] == 1
assert [n["midi"] for n in vp["notes"]] == [60, 62, 64]
# vocal_pitch t/d mirror the matching lyrics entries (spec §7.2).
assert [(n["t"], n["d"]) for n in vp["notes"]] == \
[(e["t"], e["d"]) for e in lyr]
def test_vocal_program_detection_without_name(tmp_path):
"""GM program 53 (Voice Oohs) marks the track vocal even with no name."""
path = _vocal_file(tmp_path, track_name="", program=53)
result = extract_midi_lyrics(path)
assert result is not None
assert result["vocal_pitch"] is not None
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [60, 62, 64]
# ── lyrics-only fallbacks ────────────────────────────────────────────────────
def test_no_vocal_track_emits_lyrics_only(tmp_path):
"""Lyric events on a noteless track + only a piano note track → the
lyrics payload is emitted but vocal_pitch is None (talkies path)."""
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
conductor = mido.MidiTrack()
mid.tracks.append(conductor)
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
words = mido.MidiTrack()
mid.tracks.append(words)
words.append(mido.MetaMessage("lyrics", text="Hello ", time=0))
words.append(mido.MetaMessage("lyrics", text="there ", time=TPB))
piano = mido.MidiTrack()
mid.tracks.append(piano)
piano.append(mido.MetaMessage("track_name", name="Piano", time=0))
piano.append(mido.Message("program_change", channel=0, program=0, time=0))
piano.append(mido.Message("note_on", channel=0, note=48, velocity=90, time=0))
piano.append(mido.Message("note_off", channel=0, note=48, velocity=0, time=TPB))
result = extract_midi_lyrics(_save(mid, tmp_path))
assert result is not None
assert [e["w"] for e in result["lyrics"]] == ["Hello", "there"]
assert result["vocal_pitch"] is None
def test_no_lyrics_returns_none(tmp_path):
"""A file without lyric events changes nothing — extraction reports None."""
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
tr = mido.MidiTrack()
mid.tracks.append(tr)
tr.append(mido.Message("note_on", channel=0, note=60, velocity=90, time=0))
tr.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=TPB))
assert extract_midi_lyrics(_save(mid, tmp_path)) is None
def test_unpaired_duration_next_event_capped_at_2s(tmp_path):
"""Unpaired lyric entries last until the next syllable, capped at 2.0 s."""
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
conductor = mido.MidiTrack()
mid.tracks.append(conductor)
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
words = mido.MidiTrack()
mid.tracks.append(words)
words.append(mido.MetaMessage("lyrics", text="one ", time=0))
words.append(mido.MetaMessage("lyrics", text="two ", time=TPB)) # +0.5 s
words.append(mido.MetaMessage("lyrics", text="three ", time=TPB * 8)) # +4.0 s
result = extract_midi_lyrics(_save(mid, tmp_path))
lyr = result["lyrics"]
assert lyr[0]["d"] == pytest.approx(0.5) # gap to next syllable
assert lyr[1]["d"] == pytest.approx(2.0) # 4.0 s gap capped
assert lyr[2]["d"] == pytest.approx(2.0) # last entry: cap value
# ── .kar conventions ─────────────────────────────────────────────────────────
def test_kar_slash_line_break_maps_to_plus(tmp_path):
"""A '/' prefix on a syllable marks the END of the previous line — the
previous syllable gains the spec's trailing '+'."""
path = _vocal_file(
tmp_path, syllables=("Hel-", "lo", "/world"))
result = extract_midi_lyrics(path)
words = [e["w"] for e in result["lyrics"]]
assert words == ["Hel-", "lo+", "world"]
def test_kar_backslash_paragraph_break_maps_to_plus(tmp_path):
path = _vocal_file(tmp_path, syllables=("one", "\\two", "three"))
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
assert words == ["one+", "two", "three"]
def test_kar_hyphen_joins_pass_through(tmp_path):
""".kar hyphen suffixes already ARE the spec join marker — untouched,
and no extra '-' is synthesized onto hyphenless word-final syllables."""
path = _vocal_file(tmp_path, syllables=("beau-", "ti-", "ful", "day"))
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
assert words == ["beau-", "ti-", "ful", "day"]
def test_space_delimited_stream_gains_hyphen_joins(tmp_path):
"""Space-delimited Lyric streams ('Hel' 'lo ' 'world') carry word
boundaries in whitespace mid-word syllables gain the '-' join."""
path = _vocal_file(tmp_path, syllables=("Hel", "lo ", "world "))
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
assert words == ["Hel-", "lo", "world"]
def test_newline_in_lyric_event_ends_line(tmp_path):
path = _vocal_file(tmp_path, syllables=("one \n", "two ", "three "))
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
assert words == ["one+", "two", "three"]
# ── Text-event (0x01) fallback ───────────────────────────────────────────────
def test_text_event_fallback_on_vocal_track(tmp_path):
"""With no 0x05 events anywhere, Text events on a vocal-ish track are
accepted as lyrics; '@'-prefixed .kar metadata tokens are dropped."""
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
conductor = mido.MidiTrack()
mid.tracks.append(conductor)
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
tr = mido.MidiTrack()
mid.tracks.append(tr)
tr.append(mido.MetaMessage("track_name", name="Melody", time=0))
tr.append(mido.MetaMessage("text", text="@KMIDI KARAOKE FILE", time=0))
tr.append(mido.MetaMessage("text", text="@T A Song", time=0))
for i, syl in enumerate(("Some ", "words ")):
tr.append(mido.MetaMessage("text", text=syl, time=0))
tr.append(mido.Message("note_on", channel=0, note=64 + i, velocity=90,
time=0))
tr.append(mido.Message("note_off", channel=0, note=64 + i, velocity=0,
time=TPB))
result = extract_midi_lyrics(_save(mid, tmp_path))
assert result is not None
assert [e["w"] for e in result["lyrics"]] == ["Some", "words"]
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [64, 65]
def test_text_events_on_non_vocal_track_ignored(tmp_path):
"""Text events on a plain instrument track (copyright notices, markers)
are not lyrics extraction returns None."""
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
conductor = mido.MidiTrack()
mid.tracks.append(conductor)
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
tr = mido.MidiTrack()
mid.tracks.append(tr)
tr.append(mido.MetaMessage("track_name", name="Guitar", time=0))
tr.append(mido.MetaMessage("text", text="Copyright 2026", time=0))
tr.append(mido.Message("note_on", channel=0, note=52, velocity=90, time=0))
tr.append(mido.Message("note_off", channel=0, note=52, velocity=0, time=TPB))
assert extract_midi_lyrics(_save(mid, tmp_path)) is None
# ── format-0 channel isolation ───────────────────────────────────────────────
def test_format0_pairs_only_vocal_program_channel(tmp_path):
"""Format-0 file mixing a vocal-program channel with an accompaniment
channel: only the vocal channel's notes feed vocal_pitch."""
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
tr = mido.MidiTrack()
mid.tracks.append(tr)
tr.append(mido.Message("program_change", channel=0, program=53, time=0)) # Voice Oohs
tr.append(mido.Message("program_change", channel=1, program=0, time=0)) # Piano
# Simultaneous piano note that must NOT be paired.
tr.append(mido.Message("note_on", channel=1, note=40, velocity=90, time=0))
tr.append(mido.MetaMessage("lyrics", text="la ", time=0))
tr.append(mido.Message("note_on", channel=0, note=67, velocity=90, time=0))
tr.append(mido.Message("note_off", channel=0, note=67, velocity=0, time=TPB))
tr.append(mido.Message("note_off", channel=1, note=40, velocity=0, time=0))
result = extract_midi_lyrics(_save(mid, tmp_path))
assert result is not None
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [67]
def test_format0_without_vocal_channel_is_lyrics_only(tmp_path):
"""Format-0 with lyrics but no vocal program/name: the merged note soup
cannot be trusted as a melody lyrics.json only."""
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
tr = mido.MidiTrack()
mid.tracks.append(tr)
tr.append(mido.Message("program_change", channel=0, program=0, time=0))
tr.append(mido.MetaMessage("lyrics", text="la ", time=0))
tr.append(mido.Message("note_on", channel=0, note=60, velocity=90, time=0))
tr.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=TPB))
result = extract_midi_lyrics(_save(mid, tmp_path))
assert result is not None
assert len(result["lyrics"]) == 1
assert result["vocal_pitch"] is None
# ── audio_offset ─────────────────────────────────────────────────────────────
def test_audio_offset_applied_to_both_payloads(tmp_path):
result = extract_midi_lyrics(_vocal_file(tmp_path), audio_offset=1.5)
assert result["lyrics"][0]["t"] == pytest.approx(1.5)
assert result["vocal_pitch"]["notes"][0]["t"] == pytest.approx(1.5)