mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 21:28:32 +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
|
||||||
|
|||||||
+2
-436
@@ -1,6 +1,6 @@
|
|||||||
"""MIDI file import — list tracks and convert tracks to sloppak payloads.
|
"""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`):
|
- **Keys path** (`list_midi_tracks` + `convert_midi_track_to_keys_wire`):
|
||||||
filters channel-9 out and emits a standard guitar-style arrangement that
|
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
|
`docs/sloppak-spec.md` §5.3, ready to drop alongside the sloppak
|
||||||
manifest's `drum_tab:` key.
|
manifest's `drum_tab:` key.
|
||||||
|
|
||||||
- **Lyrics path** (`extract_midi_lyrics`): reads SMF Lyric (0x05) meta events
|
The editor's track picker uses both for the +Drums and +Keys modals.
|
||||||
(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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import re
|
|
||||||
from bisect import bisect_right
|
from bisect import bisect_right
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -784,430 +777,3 @@ def convert_drum_track_from_midi(
|
|||||||
],
|
],
|
||||||
"hits": out_hits,
|
"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
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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)
|
|
||||||
Reference in New Issue
Block a user