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:
topkoa
2026-07-19 13:52:54 -04:00
co-authored by Claude Fable 5
parent 35c0d0ea0d
commit 5a35d1c0b4
4 changed files with 367 additions and 103 deletions
+90 -52
View File
@@ -6954,11 +6954,37 @@
return boxH; return boxH;
} }
// Lyrics layout cache — measureText per syllable + row wrapping // Lyrics layout cache — measureText per syllable + width-splitting
// only changes when the displayed line(s), font size, or canvas // only changes when the lyric set, font size, or canvas width
// width change, not per frame. Keyed below; the per-frame work is // change, not per frame. Keyed below; the per-frame work is just
// just drawing over the cached widths. // windowing + drawing over the cached widths.
let _lyrRowsCache = null; 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) { function drawLyrics(lyrics, currentTime, ctx, W, H) {
if (!lyrics._lines) { if (!lyrics._lines) {
@@ -6985,41 +7011,29 @@
const allLines = lyrics._lines; const allLines = lyrics._lines;
if (!allLines.length) return 0; if (!allLines.length) return 0;
let currentIdx = -1; const cfg = getLyricsDisplayCfg();
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 fontSize = Math.max(18, H * 0.028) | 0; const fontSize = Math.max(18, H * 0.028) | 0;
const lineY = H * 0.04; const lineY = H * 0.04;
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; }; const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
ctx.font = `bold ${fontSize}px sans-serif`; 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 = []; // Display lines: authored lines pre-split at word boundaries so
for (const authoredLine of linesToShow) { // every display line fits maxWidth — one display line is exactly
let row = [], rowWidth = 0; // 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) { for (const wordSyls of authoredLine.words) {
const parts = []; const parts = [];
let wordWidth = 0; let wordWidth = 0;
@@ -7030,28 +7044,53 @@
wordWidth += w; wordWidth += w;
} }
const advance = wordWidth + spaceWidth; 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 }); row.push({ parts, advance });
rowWidth += 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; // Rolling window: current line + up to cfg.upcomingLines of
for (const row of rows) { // context, each joining once it starts within cfg.lookaheadSec
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; // (also the pre-song preview window).
if (rw > bgWidth) bgWidth = rw; let currentIdx = -1;
} for (let i = 0; i < displayLines.length; i++) {
bgWidth = Math.min(bgWidth + 30, W * 0.85); if (displayLines[i].start <= currentTime) currentIdx = i;
_lyrRowsCache = { else break;
lyricsRef: lyrics, idx: currentIdx, }
shown: linesToShow.length, fontSize, W, const nextLine = displayLines[currentIdx + 1] || null;
rows, spaceWidth, bgWidth, 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 rowHeight = fontSize + 6;
const totalHeight = rows.length * rowHeight + 10; const totalHeight = shown.length * rowHeight + 10;
ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.beginPath(); ctx.beginPath();
@@ -7069,10 +7108,9 @@
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
for (let r = 0; r < rows.length; r++) { for (let r = 0; r < shown.length; r++) {
const row = rows[r]; const row = shown[r].row;
const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; let xPos = W / 2 - shown[r].width / 2;
let xPos = W / 2 - rowWidth / 2;
const yPos = lineY + r * rowHeight + 2; const yPos = lineY + r * rowHeight + 2;
for (const w of row) { for (const w of row) {
for (const part of w.parts) { for (const part of w.parts) {
+13
View File
@@ -60,6 +60,7 @@ import {
bsearchChords, bsearchChords,
drawChords, drawChords,
drawLyrics, drawLyrics,
getLyricsDisplayCfg,
drawNote, drawNote,
drawNotes, drawNotes,
drawStrumGroups, drawStrumGroups,
@@ -2749,6 +2750,18 @@ function createHighway() {
}, },
setOnLyricsChange(fn) { hwState._onLyricsChange = fn; }, 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 // Teaching marks (§6.2.2): toggle the opt-in sd/ch overlays. The fg
// numeral has its own toggle below. Persisted to localStorage. // numeral has its own toggle below. Persisted to localStorage.
getTeachingMarksVisible() { return hwState._showTeachingMarks; }, getTeachingMarksVisible() { return hwState._showTeachingMarks; },
+102 -51
View File
@@ -921,11 +921,42 @@ export function drawChords(hwState, W, H) {
} }
// ── Helpers ─────────────────────────────────────────────────────────── // ── 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) { export function drawLyrics(hwState, W, H) {
if (!hwState.lyrics.length) return; if (!hwState.lyrics.length) return;
const fontSize = Math.max(18, H * 0.028) | 0; const fontSize = Math.max(18, H * 0.028) | 0;
const lineY = H * 0.04; const lineY = H * 0.04;
const cfg = getLyricsDisplayCfg();
// Vocal markers: a trailing "-" means the syllable joins the // Vocal markers: a trailing "-" means the syllable joins the
// next one into a single word (no space); a trailing "+" marks the end // 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; const allLines = hwState.lyrics._lines;
if (!allLines.length) return; 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 sylText = (s) => {
const t = s.w || ''; const t = s.w || '';
return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t;
}; };
hwState.ctx.font = `bold ${fontSize}px sans-serif`; hwState.ctx.font = `bold ${fontSize}px sans-serif`;
const spaceWidth = _measureLyricText(hwState, hwState.ctx, fontSize, ' ');
const maxWidth = W * 0.8; const maxWidth = W * 0.8;
// Respect authored line breaks; wrap only if a line overflows maxWidth. // Display lines: authored lines pre-split at word boundaries so every
const rows = []; // display line fits maxWidth — one display line is exactly one rendered
for (const authoredLine of linesToShow) { // row. This is what keeps a giant transcribed line (word-timed lyrics
let row = [], rowWidth = 0; // break only on long gaps) from blowing up into an unbounded wrap block:
for (const wordSyls of authoredLine.words) { // its segments become ordinary lines that scroll through the window
const parts = []; // below. Cached per (lyrics, fontSize, W); measure work never runs per
let wordWidth = 0; // frame.
for (const s of wordSyls) { let layout = hwState._lyricLayout;
const text = sylText(s); if (!layout || layout.lyricsRef !== hwState.lyrics || layout.fontSize !== fontSize || layout.W !== W) {
const w = _measureLyricText(hwState, hwState.ctx, fontSize, text); const spaceWidth = _measureLyricText(hwState, hwState.ctx, fontSize, ' ');
parts.push({ syl: s, text, width: w }); const displayLines = [];
wordWidth += w; 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; flushRow();
if (row.length > 0 && rowWidth + advance > maxWidth) {
rows.push(row);
row = []; rowWidth = 0;
}
row.push({ parts, advance });
rowWidth += advance;
} }
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 rowHeight = fontSize + 6;
const totalHeight = rows.length * rowHeight + 10; const totalHeight = shown.length * rowHeight + 10;
let bgWidth = 0; let bgWidth = 0;
for (const row of rows) { for (const dl of shown) {
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; if (dl.width > bgWidth) bgWidth = dl.width;
if (rw > bgWidth) bgWidth = rw;
} }
bgWidth = Math.min(bgWidth + 30, W * 0.85); bgWidth = Math.min(bgWidth + 30, W * 0.85);
@@ -1046,10 +1098,9 @@ export function drawLyrics(hwState, W, H) {
hwState.ctx.textAlign = 'left'; hwState.ctx.textAlign = 'left';
hwState.ctx.textBaseline = 'top'; hwState.ctx.textBaseline = 'top';
for (let r = 0; r < rows.length; r++) { for (let r = 0; r < shown.length; r++) {
const row = rows[r]; const row = shown[r].row;
const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; let xPos = W/2 - shown[r].width/2;
let xPos = W/2 - rowWidth/2;
const yPos = lineY + r * rowHeight + 2; const yPos = lineY + r * rowHeight + 2;
for (const w of row) { for (const w of row) {
+162
View File
@@ -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);
});