mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 01:14:30 +00:00
Lyrics: rolling window with more context, bounded height, tunable
The in-game lyric banner had two complaints with one root cause. The
renderer showed the current authored line plus the next only when it
started within 3s, and wrapped overlong lines into unbounded rows. So
line-timed packs showed a terse 1-2 lines with no upcoming context,
while word-timed (WhisperX-transcribed) packs — whose authored lines
break only on 3s gaps — blew up into tall multi-row blobs.
Rework, applied identically to both renderers (static/js/highway-draw.js
and the deliberate duplicate in plugins/highway_3d/screen.js):
- Authored lines are pre-split at word boundaries to the banner width,
so one display line is exactly one rendered row. A giant transcribed
line becomes ordinary lines that scroll through the window instead of
wrapping — no words are ever hidden, and banner height is bounded.
- Rolling window: current line + up to N upcoming lines of context
(default 2), each joining once it starts within a lookahead (default
8s, up from 3s). The lookahead also drives the pre-song preview
(was 2s) and the after-last-line hide rule.
- Live-tunable: localStorage['lyricsDisplay'] JSON, settable in-game via
highway.setLyricsDisplay({upcomingLines, lookaheadSec}) — takes effect
next frame, no reload, shared by both highways. Clamped 0-4 / 1-30s.
- Layout (measureText + splitting) is cached per (lyrics, fontSize,
width); per-frame work is windowing + drawing only. Replaces the 3D
plugin's per-line-pair rows cache.
Karaoke coloring is unchanged (active cyan bold / past grey / upcoming
dark). 7 new behavioural tests pin the window, caps, lookahead gating,
blob splitting, preview, and the config reader; 1125/1125 JS tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
@@ -6954,11 +6954,37 @@
|
||||
return boxH;
|
||||
}
|
||||
|
||||
// Lyrics layout cache — measureText per syllable + row wrapping
|
||||
// only changes when the displayed line(s), font size, or canvas
|
||||
// width change, not per frame. Keyed below; the per-frame work is
|
||||
// just drawing over the cached widths.
|
||||
let _lyrRowsCache = null;
|
||||
// Lyrics layout cache — measureText per syllable + width-splitting
|
||||
// only changes when the lyric set, font size, or canvas width
|
||||
// change, not per frame. Keyed below; the per-frame work is just
|
||||
// windowing + drawing over the cached widths.
|
||||
let _lyrLayoutCache = null;
|
||||
|
||||
// Same live-tunable window config as the 2D highway
|
||||
// (static/js/highway-draw.js getLyricsDisplayCfg) — duplicated
|
||||
// because this plugin deliberately does not import the shared
|
||||
// module. Both read localStorage['lyricsDisplay'], so
|
||||
// highway.setLyricsDisplay() tunes both at once.
|
||||
const LYRICS_DISPLAY_DEFAULTS = { upcomingLines: 2, lookaheadSec: 8 };
|
||||
let _lyrCfgRaw, _lyrCfg = LYRICS_DISPLAY_DEFAULTS;
|
||||
function getLyricsDisplayCfg() {
|
||||
let raw = null;
|
||||
try { raw = localStorage.getItem('lyricsDisplay'); } catch (e) { /* storage denied */ }
|
||||
if (raw === _lyrCfgRaw) return _lyrCfg;
|
||||
_lyrCfgRaw = raw;
|
||||
let parsed = null;
|
||||
try { parsed = raw ? JSON.parse(raw) : null; } catch (e) { /* corrupt -> defaults */ }
|
||||
if (!parsed || typeof parsed !== 'object') parsed = {};
|
||||
const num = (v, dflt, lo, hi) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt;
|
||||
};
|
||||
_lyrCfg = {
|
||||
upcomingLines: num(parsed.upcomingLines, LYRICS_DISPLAY_DEFAULTS.upcomingLines, 0, 4) | 0,
|
||||
lookaheadSec: num(parsed.lookaheadSec, LYRICS_DISPLAY_DEFAULTS.lookaheadSec, 1, 30),
|
||||
};
|
||||
return _lyrCfg;
|
||||
}
|
||||
|
||||
function drawLyrics(lyrics, currentTime, ctx, W, H) {
|
||||
if (!lyrics._lines) {
|
||||
@@ -6985,41 +7011,29 @@
|
||||
const allLines = lyrics._lines;
|
||||
if (!allLines.length) return 0;
|
||||
|
||||
let currentIdx = -1;
|
||||
for (let i = 0; i < allLines.length; i++) {
|
||||
if (allLines[i].start <= currentTime) currentIdx = i;
|
||||
else break;
|
||||
}
|
||||
if (currentIdx === -1) {
|
||||
if (allLines[0].start - currentTime > 2.0) return 0;
|
||||
currentIdx = 0;
|
||||
}
|
||||
const currentLine = allLines[currentIdx];
|
||||
const nextLine = allLines[currentIdx + 1] || null;
|
||||
const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity;
|
||||
if (currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return 0;
|
||||
|
||||
const linesToShow = [currentLine];
|
||||
if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine);
|
||||
|
||||
const cfg = getLyricsDisplayCfg();
|
||||
const fontSize = Math.max(18, H * 0.028) | 0;
|
||||
const lineY = H * 0.04;
|
||||
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
|
||||
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
let rows, spaceWidth, bgWidth;
|
||||
const _lc = _lyrRowsCache;
|
||||
if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx
|
||||
&& _lc.shown === linesToShow.length
|
||||
&& _lc.fontSize === fontSize && _lc.W === W) {
|
||||
rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth;
|
||||
} else {
|
||||
spaceWidth = ctx.measureText(' ').width;
|
||||
const maxWidth = W * 0.8;
|
||||
|
||||
rows = [];
|
||||
for (const authoredLine of linesToShow) {
|
||||
let row = [], rowWidth = 0;
|
||||
// Display lines: authored lines pre-split at word boundaries so
|
||||
// every display line fits maxWidth — one display line is exactly
|
||||
// one rendered row, so a giant transcribed line scrolls through
|
||||
// the window instead of wrapping into an unbounded block.
|
||||
let layout = _lyrLayoutCache;
|
||||
if (!layout || layout.lyricsRef !== lyrics || layout.fontSize !== fontSize || layout.W !== W) {
|
||||
const spaceWidth = ctx.measureText(' ').width;
|
||||
const maxWidth = W * 0.8;
|
||||
const displayLines = [];
|
||||
for (const authoredLine of allLines) {
|
||||
let row = [], rowWidth = 0, start = null, end = null;
|
||||
const flushRow = () => {
|
||||
if (!row.length) return;
|
||||
displayLines.push({ row, width: rowWidth - spaceWidth, start, end });
|
||||
row = []; rowWidth = 0; start = null; end = null;
|
||||
};
|
||||
for (const wordSyls of authoredLine.words) {
|
||||
const parts = [];
|
||||
let wordWidth = 0;
|
||||
@@ -7030,28 +7044,53 @@
|
||||
wordWidth += w;
|
||||
}
|
||||
const advance = wordWidth + spaceWidth;
|
||||
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
|
||||
if (row.length > 0 && rowWidth + advance > maxWidth) flushRow();
|
||||
row.push({ parts, advance });
|
||||
rowWidth += advance;
|
||||
const first = wordSyls[0], last = wordSyls[wordSyls.length - 1];
|
||||
if (start === null) start = first.t;
|
||||
end = end === null ? last.t + last.d : Math.max(end, last.t + last.d);
|
||||
}
|
||||
if (row.length) rows.push(row);
|
||||
flushRow();
|
||||
}
|
||||
layout = _lyrLayoutCache = { lyricsRef: lyrics, fontSize, W, spaceWidth, displayLines };
|
||||
}
|
||||
const displayLines = layout.displayLines;
|
||||
const spaceWidth = layout.spaceWidth;
|
||||
if (!displayLines.length) return 0;
|
||||
|
||||
bgWidth = 0;
|
||||
for (const row of rows) {
|
||||
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
|
||||
if (rw > bgWidth) bgWidth = rw;
|
||||
}
|
||||
bgWidth = Math.min(bgWidth + 30, W * 0.85);
|
||||
_lyrRowsCache = {
|
||||
lyricsRef: lyrics, idx: currentIdx,
|
||||
shown: linesToShow.length, fontSize, W,
|
||||
rows, spaceWidth, bgWidth,
|
||||
};
|
||||
// Rolling window: current line + up to cfg.upcomingLines of
|
||||
// context, each joining once it starts within cfg.lookaheadSec
|
||||
// (also the pre-song preview window).
|
||||
let currentIdx = -1;
|
||||
for (let i = 0; i < displayLines.length; i++) {
|
||||
if (displayLines[i].start <= currentTime) currentIdx = i;
|
||||
else break;
|
||||
}
|
||||
const nextLine = displayLines[currentIdx + 1] || null;
|
||||
if (currentIdx >= 0
|
||||
&& currentTime > displayLines[currentIdx].end + 0.5
|
||||
&& (!nextLine || nextLine.start - currentTime > cfg.lookaheadSec)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const maxLines = 1 + cfg.upcomingLines;
|
||||
const startIdx = currentIdx === -1 ? 0 : currentIdx;
|
||||
const shown = [];
|
||||
for (let i = startIdx; i < displayLines.length && shown.length < maxLines; i++) {
|
||||
if (i !== currentIdx && displayLines[i].start - currentTime > cfg.lookaheadSec) break;
|
||||
shown.push(displayLines[i]);
|
||||
}
|
||||
if (!shown.length) return 0;
|
||||
|
||||
let bgWidth = 0;
|
||||
for (const dl of shown) {
|
||||
if (dl.width > bgWidth) bgWidth = dl.width;
|
||||
}
|
||||
bgWidth = Math.min(bgWidth + 30, W * 0.85);
|
||||
|
||||
const rowHeight = fontSize + 6;
|
||||
const totalHeight = rows.length * rowHeight + 10;
|
||||
const totalHeight = shown.length * rowHeight + 10;
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.beginPath();
|
||||
@@ -7069,10 +7108,9 @@
|
||||
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
|
||||
let xPos = W / 2 - rowWidth / 2;
|
||||
for (let r = 0; r < shown.length; r++) {
|
||||
const row = shown[r].row;
|
||||
let xPos = W / 2 - shown[r].width / 2;
|
||||
const yPos = lineY + r * rowHeight + 2;
|
||||
for (const w of row) {
|
||||
for (const part of w.parts) {
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
bsearchChords,
|
||||
drawChords,
|
||||
drawLyrics,
|
||||
getLyricsDisplayCfg,
|
||||
drawNote,
|
||||
drawNotes,
|
||||
drawStrumGroups,
|
||||
@@ -2749,6 +2750,18 @@ function createHighway() {
|
||||
},
|
||||
setOnLyricsChange(fn) { hwState._onLyricsChange = fn; },
|
||||
|
||||
// Lyric display window (current + upcoming context). Live-tunable:
|
||||
// takes effect on the next drawn frame, shared with the 3D highway
|
||||
// via the same localStorage key. Partial updates merge over the
|
||||
// current values; out-of-range values are clamped by the reader.
|
||||
// highway.setLyricsDisplay({ upcomingLines: 3, lookaheadSec: 12 })
|
||||
getLyricsDisplay() { return { ...getLyricsDisplayCfg() }; },
|
||||
setLyricsDisplay(opts) {
|
||||
const next = { ...getLyricsDisplayCfg(), ...(opts || {}) };
|
||||
localStorage.setItem('lyricsDisplay', JSON.stringify(next));
|
||||
return { ...getLyricsDisplayCfg() };
|
||||
},
|
||||
|
||||
// Teaching marks (§6.2.2): toggle the opt-in sd/ch overlays. The fg
|
||||
// numeral has its own toggle below. Persisted to localStorage.
|
||||
getTeachingMarksVisible() { return hwState._showTeachingMarks; },
|
||||
|
||||
+102
-51
@@ -921,11 +921,42 @@ export function drawChords(hwState, W, H) {
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
// Live-tunable lyric display window, persisted as JSON under
|
||||
// localStorage['lyricsDisplay'] and settable in-game via
|
||||
// highway.setLyricsDisplay({upcomingLines, lookaheadSec}). Read per frame
|
||||
// behind a raw-string compare so a tweak takes effect on the next frame
|
||||
// without a reload.
|
||||
// upcomingLines — how many lines beyond the current one may be shown (0-4)
|
||||
// lookaheadSec — how far ahead a line may start and still be previewed (1-30)
|
||||
const LYRICS_DISPLAY_DEFAULTS = { upcomingLines: 2, lookaheadSec: 8 };
|
||||
let _lyricsCfgRaw;
|
||||
let _lyricsCfg = LYRICS_DISPLAY_DEFAULTS;
|
||||
export function getLyricsDisplayCfg() {
|
||||
let raw = null;
|
||||
try { raw = localStorage.getItem('lyricsDisplay'); } catch (e) { /* storage denied */ }
|
||||
if (raw === _lyricsCfgRaw) return _lyricsCfg;
|
||||
_lyricsCfgRaw = raw;
|
||||
let parsed = null;
|
||||
try { parsed = raw ? JSON.parse(raw) : null; } catch (e) { /* corrupt -> defaults */ }
|
||||
if (!parsed || typeof parsed !== 'object') parsed = {};
|
||||
const num = (v, dflt, lo, hi) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt;
|
||||
};
|
||||
_lyricsCfg = {
|
||||
upcomingLines: num(parsed.upcomingLines, LYRICS_DISPLAY_DEFAULTS.upcomingLines, 0, 4) | 0,
|
||||
lookaheadSec: num(parsed.lookaheadSec, LYRICS_DISPLAY_DEFAULTS.lookaheadSec, 1, 30),
|
||||
};
|
||||
return _lyricsCfg;
|
||||
}
|
||||
|
||||
export function drawLyrics(hwState, W, H) {
|
||||
if (!hwState.lyrics.length) return;
|
||||
|
||||
const fontSize = Math.max(18, H * 0.028) | 0;
|
||||
const lineY = H * 0.04;
|
||||
const cfg = getLyricsDisplayCfg();
|
||||
|
||||
// Vocal markers: a trailing "-" means the syllable joins the
|
||||
// next one into a single word (no space); a trailing "+" marks the end
|
||||
@@ -975,67 +1006,88 @@ export function drawLyrics(hwState, W, H) {
|
||||
const allLines = hwState.lyrics._lines;
|
||||
if (!allLines.length) return;
|
||||
|
||||
// Current line = most recently started line. Before the first line has
|
||||
// started, preview the first line if it's within 2s of starting.
|
||||
let currentIdx = -1;
|
||||
for (let i = 0; i < allLines.length; i++) {
|
||||
if (allLines[i].start <= hwState.currentTime) currentIdx = i;
|
||||
else break;
|
||||
}
|
||||
if (currentIdx === -1) {
|
||||
if (allLines[0].start - hwState.currentTime > 2.0) return;
|
||||
currentIdx = 0;
|
||||
}
|
||||
|
||||
const currentLine = allLines[currentIdx];
|
||||
const nextLine = allLines[currentIdx + 1] || null;
|
||||
const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity;
|
||||
|
||||
// Hide once the current line is clearly over and nothing relevant follows.
|
||||
if (hwState.currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return;
|
||||
|
||||
const linesToShow = [currentLine];
|
||||
if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine);
|
||||
|
||||
const sylText = (s) => {
|
||||
const t = s.w || '';
|
||||
return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t;
|
||||
};
|
||||
|
||||
hwState.ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
const spaceWidth = _measureLyricText(hwState, hwState.ctx, fontSize, ' ');
|
||||
const maxWidth = W * 0.8;
|
||||
|
||||
// Respect authored line breaks; wrap only if a line overflows maxWidth.
|
||||
const rows = [];
|
||||
for (const authoredLine of linesToShow) {
|
||||
let row = [], rowWidth = 0;
|
||||
for (const wordSyls of authoredLine.words) {
|
||||
const parts = [];
|
||||
let wordWidth = 0;
|
||||
for (const s of wordSyls) {
|
||||
const text = sylText(s);
|
||||
const w = _measureLyricText(hwState, hwState.ctx, fontSize, text);
|
||||
parts.push({ syl: s, text, width: w });
|
||||
wordWidth += w;
|
||||
// Display lines: authored lines pre-split at word boundaries so every
|
||||
// display line fits maxWidth — one display line is exactly one rendered
|
||||
// row. This is what keeps a giant transcribed line (word-timed lyrics
|
||||
// break only on long gaps) from blowing up into an unbounded wrap block:
|
||||
// its segments become ordinary lines that scroll through the window
|
||||
// below. Cached per (lyrics, fontSize, W); measure work never runs per
|
||||
// frame.
|
||||
let layout = hwState._lyricLayout;
|
||||
if (!layout || layout.lyricsRef !== hwState.lyrics || layout.fontSize !== fontSize || layout.W !== W) {
|
||||
const spaceWidth = _measureLyricText(hwState, hwState.ctx, fontSize, ' ');
|
||||
const displayLines = [];
|
||||
for (const authoredLine of allLines) {
|
||||
let row = [], rowWidth = 0, start = null, end = null;
|
||||
const flushRow = () => {
|
||||
if (!row.length) return;
|
||||
displayLines.push({ row, width: rowWidth - spaceWidth, start, end });
|
||||
row = []; rowWidth = 0; start = null; end = null;
|
||||
};
|
||||
for (const wordSyls of authoredLine.words) {
|
||||
const parts = [];
|
||||
let wordWidth = 0;
|
||||
for (const s of wordSyls) {
|
||||
const text = sylText(s);
|
||||
const w = _measureLyricText(hwState, hwState.ctx, fontSize, text);
|
||||
parts.push({ syl: s, text, width: w });
|
||||
wordWidth += w;
|
||||
}
|
||||
const advance = wordWidth + spaceWidth;
|
||||
if (row.length > 0 && rowWidth + advance > maxWidth) flushRow();
|
||||
row.push({ parts, advance });
|
||||
rowWidth += advance;
|
||||
const first = wordSyls[0], last = wordSyls[wordSyls.length - 1];
|
||||
if (start === null) start = first.t;
|
||||
end = end === null ? last.t + last.d : Math.max(end, last.t + last.d);
|
||||
}
|
||||
const advance = wordWidth + spaceWidth;
|
||||
if (row.length > 0 && rowWidth + advance > maxWidth) {
|
||||
rows.push(row);
|
||||
row = []; rowWidth = 0;
|
||||
}
|
||||
row.push({ parts, advance });
|
||||
rowWidth += advance;
|
||||
flushRow();
|
||||
}
|
||||
if (row.length) rows.push(row);
|
||||
layout = hwState._lyricLayout = { lyricsRef: hwState.lyrics, fontSize, W, spaceWidth, displayLines };
|
||||
}
|
||||
const displayLines = layout.displayLines;
|
||||
const spaceWidth = layout.spaceWidth;
|
||||
if (!displayLines.length) return;
|
||||
|
||||
// Rolling window: the current line plus up to cfg.upcomingLines of
|
||||
// context. An upcoming line joins the window once it starts within
|
||||
// cfg.lookaheadSec (this also serves as the pre-song preview window).
|
||||
let currentIdx = -1;
|
||||
for (let i = 0; i < displayLines.length; i++) {
|
||||
if (displayLines[i].start <= hwState.currentTime) currentIdx = i;
|
||||
else break;
|
||||
}
|
||||
const nextLine = displayLines[currentIdx + 1] || null;
|
||||
// Hide once the current line is clearly over and nothing upcoming is
|
||||
// close enough to preview.
|
||||
if (currentIdx >= 0
|
||||
&& hwState.currentTime > displayLines[currentIdx].end + 0.5
|
||||
&& (!nextLine || nextLine.start - hwState.currentTime > cfg.lookaheadSec)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxLines = 1 + cfg.upcomingLines;
|
||||
const startIdx = currentIdx === -1 ? 0 : currentIdx;
|
||||
const shown = [];
|
||||
for (let i = startIdx; i < displayLines.length && shown.length < maxLines; i++) {
|
||||
if (i !== currentIdx && displayLines[i].start - hwState.currentTime > cfg.lookaheadSec) break;
|
||||
shown.push(displayLines[i]);
|
||||
}
|
||||
if (!shown.length) return;
|
||||
|
||||
const rowHeight = fontSize + 6;
|
||||
const totalHeight = rows.length * rowHeight + 10;
|
||||
const totalHeight = shown.length * rowHeight + 10;
|
||||
let bgWidth = 0;
|
||||
for (const row of rows) {
|
||||
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
|
||||
if (rw > bgWidth) bgWidth = rw;
|
||||
for (const dl of shown) {
|
||||
if (dl.width > bgWidth) bgWidth = dl.width;
|
||||
}
|
||||
bgWidth = Math.min(bgWidth + 30, W * 0.85);
|
||||
|
||||
@@ -1046,10 +1098,9 @@ export function drawLyrics(hwState, W, H) {
|
||||
hwState.ctx.textAlign = 'left';
|
||||
hwState.ctx.textBaseline = 'top';
|
||||
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
|
||||
let xPos = W/2 - rowWidth/2;
|
||||
for (let r = 0; r < shown.length; r++) {
|
||||
const row = shown[r].row;
|
||||
let xPos = W/2 - shown[r].width/2;
|
||||
const yPos = lineY + r * rowHeight + 2;
|
||||
|
||||
for (const w of row) {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Behavioural tests for the lyric display window (drawLyrics in
|
||||
// static/js/highway-draw.js): width-based pre-splitting of long lines,
|
||||
// the rolling current+upcoming window, its caps, and the live-tunable
|
||||
// config reader. Extraction-by-source pattern per highway_teaching_marks.
|
||||
//
|
||||
// The 3D plugin (plugins/highway_3d/screen.js) carries a deliberate
|
||||
// duplicate of this logic; these tests pin the canonical copy.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SRC = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'js', 'highway-draw.js'), 'utf8');
|
||||
|
||||
function extractFn(src, name) {
|
||||
const start = src.indexOf('function ' + name);
|
||||
assert.ok(start >= 0, `function ${name} must exist`);
|
||||
const open = src.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}' && --depth === 0) return { text: src.slice(start, i + 1), end: i + 1 };
|
||||
}
|
||||
throw new Error(`unbalanced braces extracting ${name}`);
|
||||
}
|
||||
|
||||
// getLyricsDisplayCfg with its module-level state: slice from the defaults
|
||||
// const through the end of the function so the real declarations come along.
|
||||
function loadCfgReader(storage) {
|
||||
const constIdx = SRC.indexOf('const LYRICS_DISPLAY_DEFAULTS');
|
||||
assert.ok(constIdx >= 0);
|
||||
const fn = extractFn(SRC, 'getLyricsDisplayCfg');
|
||||
const body = SRC.slice(constIdx, fn.end).replace(/^export /gm, '');
|
||||
return new Function('localStorage', '"use strict";' + body + '\nreturn getLyricsDisplayCfg;')(storage);
|
||||
}
|
||||
|
||||
function makeStorage(initial) {
|
||||
const store = new Map(Object.entries(initial || {}));
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
};
|
||||
}
|
||||
|
||||
test('cfg reader: defaults, clamping, corrupt JSON, live re-read', () => {
|
||||
const storage = makeStorage();
|
||||
const read = loadCfgReader(storage);
|
||||
assert.deepEqual(read(), { upcomingLines: 2, lookaheadSec: 8 });
|
||||
|
||||
storage.setItem('lyricsDisplay', JSON.stringify({ upcomingLines: 99, lookaheadSec: 0 }));
|
||||
assert.deepEqual(read(), { upcomingLines: 4, lookaheadSec: 1 }); // clamped
|
||||
|
||||
storage.setItem('lyricsDisplay', '{not json');
|
||||
assert.deepEqual(read(), { upcomingLines: 2, lookaheadSec: 8 }); // corrupt -> defaults
|
||||
|
||||
storage.setItem('lyricsDisplay', JSON.stringify({ upcomingLines: 1 }));
|
||||
assert.deepEqual(read(), { upcomingLines: 1, lookaheadSec: 8 }); // partial merges over defaults
|
||||
});
|
||||
|
||||
// ── drawLyrics harness ───────────────────────────────────────────────────
|
||||
// Deps injected: _measureLyricText (10px per char), roundRect (noop),
|
||||
// getLyricsDisplayCfg (test-controlled). ctx records fillText rows.
|
||||
function loadDrawLyrics(cfg) {
|
||||
const fn = extractFn(SRC, 'drawLyrics');
|
||||
return new Function(
|
||||
'_measureLyricText', 'roundRect', 'getLyricsDisplayCfg',
|
||||
'"use strict";' + fn.text + '\nreturn drawLyrics;'
|
||||
)(
|
||||
(hw, ctx, fs_, text) => text.length * 10,
|
||||
() => {},
|
||||
() => cfg
|
||||
);
|
||||
}
|
||||
|
||||
function makeCtx() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
font: '', fillStyle: '', textAlign: '', textBaseline: '',
|
||||
fillText: (text, x, y) => calls.push({ text, x, y }),
|
||||
fill: () => {}, beginPath: () => {},
|
||||
measureText: (t) => ({ width: t.length * 10 }),
|
||||
};
|
||||
}
|
||||
|
||||
function rowsDrawn(ctx) {
|
||||
return new Set(ctx.calls.map(c => c.y)).size;
|
||||
}
|
||||
|
||||
// Word-timed syllables, one per word, `plus` marks authored line ends.
|
||||
function syl(t, w, plus) { return { t, d: 0.4, w: plus ? w + '+' : w }; }
|
||||
|
||||
const H = 1000; // fontSize = max(18, 28) = 28
|
||||
|
||||
test('line-timed lyrics: current + upcoming context lines shown', () => {
|
||||
// Four short authored lines, 2s apart — all inside an 8s lookahead.
|
||||
const lyrics = [
|
||||
syl(10, 'one', true), syl(12, 'two', true),
|
||||
syl(14, 'three', true), syl(16, 'four', true),
|
||||
];
|
||||
const draw = loadDrawLyrics({ upcomingLines: 2, lookaheadSec: 8 });
|
||||
const ctx = makeCtx();
|
||||
draw({ lyrics, ctx, currentTime: 10.1 }, 2000, H);
|
||||
assert.equal(rowsDrawn(ctx), 3, 'current + 2 upcoming');
|
||||
assert.deepEqual(ctx.calls.map(c => c.text), ['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
test('upcomingLines: 0 shows only the current line', () => {
|
||||
const lyrics = [syl(10, 'one', true), syl(12, 'two', true)];
|
||||
const draw = loadDrawLyrics({ upcomingLines: 0, lookaheadSec: 8 });
|
||||
const ctx = makeCtx();
|
||||
draw({ lyrics, ctx, currentTime: 10.1 }, 2000, H);
|
||||
assert.equal(rowsDrawn(ctx), 1);
|
||||
assert.deepEqual(ctx.calls.map(c => c.text), ['one']);
|
||||
});
|
||||
|
||||
test('lookahead gates upcoming lines', () => {
|
||||
// Next line 20s away — outside an 8s lookahead.
|
||||
const lyrics = [syl(10, 'one', true), syl(30, 'far', true)];
|
||||
const draw = loadDrawLyrics({ upcomingLines: 2, lookaheadSec: 8 });
|
||||
const ctx = makeCtx();
|
||||
draw({ lyrics, ctx, currentTime: 10.1 }, 2000, H);
|
||||
assert.deepEqual(ctx.calls.map(c => c.text), ['one']);
|
||||
});
|
||||
|
||||
test('a giant unmarked line splits into rows capped by the window', () => {
|
||||
// 40 words, no "+" anywhere, continuous timing (gaps < 4s): the old
|
||||
// renderer wrapped all of it at once. Narrow canvas (W=300 →
|
||||
// maxWidth=240) forces splits; the window must cap what is drawn at
|
||||
// 1 current + 2 upcoming rows, never the whole blob.
|
||||
const lyrics = [];
|
||||
for (let i = 0; i < 40; i++) lyrics.push(syl(10 + i * 0.5, 'word' + i, false));
|
||||
const draw = loadDrawLyrics({ upcomingLines: 2, lookaheadSec: 8 });
|
||||
const ctx = makeCtx();
|
||||
draw({ lyrics, ctx, currentTime: 10.1 }, 300, H);
|
||||
assert.ok(rowsDrawn(ctx) <= 3, `expected <=3 rows, got ${rowsDrawn(ctx)}`);
|
||||
assert.ok(ctx.calls.length < 40, 'must not draw the entire blob');
|
||||
assert.equal(ctx.calls[0].text, 'word0', 'current segment starts the window');
|
||||
});
|
||||
|
||||
test('pre-song preview appears within lookahead, not before', () => {
|
||||
const lyrics = [syl(10, 'one', true)];
|
||||
const draw = loadDrawLyrics({ upcomingLines: 2, lookaheadSec: 8 });
|
||||
|
||||
const early = makeCtx();
|
||||
draw({ lyrics, ctx: early, currentTime: 0 }, 2000, H); // 10s out > 8s
|
||||
assert.equal(early.calls.length, 0);
|
||||
|
||||
const near = makeCtx();
|
||||
draw({ lyrics, ctx: near, currentTime: 3 }, 2000, H); // 7s out <= 8s
|
||||
assert.deepEqual(near.calls.map(c => c.text), ['one']);
|
||||
});
|
||||
|
||||
test('banner hides after the last line ends with nothing upcoming', () => {
|
||||
const lyrics = [syl(10, 'one', true)];
|
||||
const draw = loadDrawLyrics({ upcomingLines: 2, lookaheadSec: 8 });
|
||||
const ctx = makeCtx();
|
||||
draw({ lyrics, ctx, currentTime: 15 }, 2000, H); // ended at 10.4, +0.5 grace
|
||||
assert.equal(ctx.calls.length, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user