mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 05:04:30 +00:00
refactor(h3d-carve-7): extract O-section (lyrics + HUD overlay) → src/overlay.js
Move longestConsecutiveRun, drawChordDiagram, _drawDiagramCached,
drawSectionHud, drawToneHud, drawLyrics out of screen.js IIFE into
plugins/highway_3d/src/overlay.js as export function createOverlay({ diagRenderCache }).
Surprises vs approved contract:
• longestConsecutiveRun (lines 4338–4352) co-moved — called only by drawChordDiagram
• DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX DELETED from screen.js (lines 573–575);
only users were inside the O-section; moved not copied to avoid drift
• _DIAG_CACHE_MAX DELETED from screen.js factory scope (line 3256); moved not copied
Beyond-subst rewires (2):
1. Factory wrapper createOverlay({ diagRenderCache })
2. _diagRenderCache → diagRenderCache (DI param) in 3 sites in _drawDiagramCached
Base run stated: 253/253 on a993d2b
Post-cut: 262/262 (+9 tests — 8 overlay class-killers + 1 panel-controls stub)
Named mutations verified RED:
• createOverlay({ diagRenderCache: new Map() }) → test 6 RED (ref-severance)
• void _DIAG_CACHE_MAX in screen.js → test 4 RED (bare private)
plugin.json: 3.42.0 → 3.43.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a993d2b291
commit
0a91f4f1b1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "highway_3d",
|
"id": "highway_3d",
|
||||||
"name": "3D Highway",
|
"name": "3D Highway",
|
||||||
"version": "3.42.0",
|
"version": "3.43.0",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"scriptType": "module",
|
"scriptType": "module",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
|
|||||||
+13
-885
@@ -12,6 +12,7 @@ import { _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt, resolveStringCount
|
|||||||
import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './src/bc-panel.js'; // h3d-carve-4
|
import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './src/bc-panel.js'; // h3d-carve-4
|
||||||
import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
|
import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
|
||||||
import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
|
import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
|
||||||
|
import { createOverlay } from './src/overlay.js'; // h3d-carve-7
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -570,9 +571,6 @@ import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
|
|||||||
const DIAG_LINGER_S = 0.55;
|
const DIAG_LINGER_S = 0.55;
|
||||||
const DIAG_ENTRANCE_S = 0.20;
|
const DIAG_ENTRANCE_S = 0.20;
|
||||||
const DIAG_CROSSFADE_S = 0.15;
|
const DIAG_CROSSFADE_S = 0.15;
|
||||||
const DIAG_SIZE_MIN = 0.08;
|
|
||||||
const DIAG_SIZE_MAX = 0.16;
|
|
||||||
const DIAG_CELL_MAX = 34;
|
|
||||||
// 'bl' and 'br' removed — diagram is top-only. Legacy localStorage values
|
// 'bl' and 'br' removed — diagram is top-only. Legacy localStorage values
|
||||||
// that contain 'bl'/'br' will fall back to BG_DEFAULTS.chordDiagramPosition
|
// that contain 'bl'/'br' will fall back to BG_DEFAULTS.chordDiagramPosition
|
||||||
// via _bgCoerce (which rejects values not in this list).
|
// via _bgCoerce (which rejects values not in this list).
|
||||||
@@ -3248,12 +3246,6 @@ import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
|
|||||||
// Cleared on canvas resize (bx/by depend on canvasW/H/lyricsBottom)
|
// Cleared on canvas resize (bx/by depend on canvasW/H/lyricsBottom)
|
||||||
// and on teardown/destroy.
|
// and on teardown/destroy.
|
||||||
const _diagRenderCache = new Map();
|
const _diagRenderCache = new Map();
|
||||||
// Cap chosen to cover the ~5–6 active chord shapes per phrase while
|
|
||||||
// keeping the cached-OffscreenCanvas footprint bounded (~50 MB per
|
|
||||||
// panel at typical 1920×1080). A structural fix — caching a
|
|
||||||
// tightly-sized box surface instead of the full overlay canvas —
|
|
||||||
// is tracked as a follow-up.
|
|
||||||
const _DIAG_CACHE_MAX = 6;
|
|
||||||
let pSusRail = null, gSusRail = null, mSusRailBase = null;
|
let pSusRail = null, gSusRail = null, mSusRailBase = null;
|
||||||
let pSusRailBloom = null, gSusRailBloom = null, mSusRailBloomBase = null, _bloomGaussTex = null;
|
let pSusRailBloom = null, gSusRailBloom = null, mSusRailBloomBase = null, _bloomGaussTex = null;
|
||||||
let pTechPlane = null, gTechPlane = null;
|
let pTechPlane = null, gTechPlane = null;
|
||||||
@@ -4329,884 +4321,20 @@ import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Returns indices of the longest consecutive run in a sorted integer
|
// ── h3d-carve-7: lyrics + HUD overlay ──────────────────────────────────────
|
||||||
// array as { start, len } — `sorted[start..start+len)` is the run.
|
// longestConsecutiveRun, drawChordDiagram, _drawDiagramCached,
|
||||||
// Avoids the two per-call sub-array allocations of the previous
|
// drawSectionHud, drawToneHud, drawLyrics → src/overlay.js (createOverlay)
|
||||||
// implementation (best + cur arrays grown via .push), at the cost
|
|
||||||
// of one small 2-key result object. Net: callers in the chord-
|
|
||||||
// diagram render path no longer churn arrays per visible chord.
|
|
||||||
function longestConsecutiveRun(sorted) {
|
|
||||||
let bestStart = -1, bestLen = 0;
|
|
||||||
let curStart = -1, curLen = 0;
|
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
|
||||||
if (curLen === 0 || sorted[i] === sorted[curStart + curLen - 1] + 1) {
|
|
||||||
if (curLen === 0) curStart = i;
|
|
||||||
curLen++;
|
|
||||||
} else {
|
|
||||||
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
|
|
||||||
curStart = i; curLen = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
|
|
||||||
return { start: bestStart, len: bestLen };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Lyrics overlay (2D canvas on top of WebGL) ─────────────────── */
|
|
||||||
function drawChordDiagram(ctx, opts) {
|
|
||||||
const {
|
|
||||||
name, frets,
|
|
||||||
opacity = 1,
|
|
||||||
entranceT = 1.0,
|
|
||||||
canvasW = 600, canvasH = 400,
|
|
||||||
inverted = false,
|
|
||||||
sizeSlider = 0.5,
|
|
||||||
position = 'tl',
|
|
||||||
nStr = 6,
|
|
||||||
lyricsBottom = 0,
|
|
||||||
stackOffset = 0,
|
|
||||||
} = opts;
|
|
||||||
|
|
||||||
// Responsive sizing — CELL derived from panel height + user slider.
|
|
||||||
// COLS is the resolved string count from the caller (via resolveStringCount)
|
|
||||||
// so bass (4), extended (7/8) arrangements render correctly.
|
|
||||||
const COLS = nStr, ROWS = 4;
|
|
||||||
// Minimum column span required for PATH B (bracket extension / detection).
|
|
||||||
// Math.min(COLS-1, 4) scales with string count:
|
|
||||||
// 4-string bass → 3 (max possible span, so 2-4-4-2 shapes qualify)
|
|
||||||
// 6-string → 4 (excludes D major span=2 / common 2-string coincidences)
|
|
||||||
// 8-string → 4 (muted outer strings still leave span ≥ 4 for real barres)
|
|
||||||
const MIN_BARRE_SPAN = Math.min(COLS - 1, 4);
|
|
||||||
// Maps diagram column index → chord-template frets-array index.
|
|
||||||
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
|
|
||||||
// Non-inverted display (col 0 = high e): getStrIdx(0) = 0 → frets[0] = high e.
|
|
||||||
// Inverted display (col 0 = low E): getStrIdx(0) = COLS-1 → frets[COLS-1] = low E.
|
|
||||||
const getStrIdx = col => inverted ? (COLS - 1 - col) : col;
|
|
||||||
const sizeF = DIAG_SIZE_MIN + (DIAG_SIZE_MAX - DIAG_SIZE_MIN) * sizeSlider;
|
|
||||||
|
|
||||||
// startFret / isFirstPos must be known before CELL so that fretLabelW
|
|
||||||
// can be measured and factored into the width cap. The old
|
|
||||||
// canvasW/(COLS+1.5) guard only approximated 2*PAD and ignored the
|
|
||||||
// extra left padding reserved for non-first-position "Nfr" labels.
|
|
||||||
const playedFrets = frets.filter(f => f > 0);
|
|
||||||
const minFret = playedFrets.length > 0 ? Math.min(...playedFrets) : 1;
|
|
||||||
const startFret = Math.max(1, minFret);
|
|
||||||
const isFirstPos = startFret === 1;
|
|
||||||
|
|
||||||
// Phase 1 — height + hard-cap estimate, used only to size the label font.
|
|
||||||
// Cap against the vertical space available below lyricsBottom so that the
|
|
||||||
// diagram does not overflow into the lyrics banner on short split panels with
|
|
||||||
// wrapped lyric rows. Only top-corner positions can overlap the lyrics banner,
|
|
||||||
// so lyricsBottom is only subtracted when position is 'tl' or 'tr'; for 'bl'
|
|
||||||
// and 'br' the full canvas height is available.
|
|
||||||
// Clamp to at least 1 so font/box calculations never receive 0-px input
|
|
||||||
// on very short panels (e.g. tiny split cells < 44 px tall).
|
|
||||||
const isTopCorner = position === 'tl' || position === 'tr';
|
|
||||||
const availH = canvasH - (isTopCorner ? lyricsBottom : 0);
|
|
||||||
const cellEst = Math.max(1, Math.min(
|
|
||||||
Math.round(availH * sizeF / (ROWS + 3)),
|
|
||||||
DIAG_CELL_MAX,
|
|
||||||
));
|
|
||||||
// Extra left padding for the "Nfr" label on non-first-position chords.
|
|
||||||
// Measured with ctx.measureText at cellEst so the estimate is exact.
|
|
||||||
let fretLabelW = 0;
|
|
||||||
if (!isFirstPos) {
|
|
||||||
// Measure inside a save/restore so this font assignment does not
|
|
||||||
// leak to the caller (the outer ctx.save() happens after CELL is derived).
|
|
||||||
ctx.save();
|
|
||||||
ctx.font = `italic ${Math.round(cellEst * 0.55)}px sans-serif`;
|
|
||||||
fretLabelW = Math.ceil(ctx.measureText(startFret + 'fr').width) + 6;
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2 — final CELL: cap against panel height, hard max, and panel width.
|
|
||||||
// Two width constraints are needed because PAD has a hard floor of 6:
|
|
||||||
// A) when PAD = CELL*0.65 (large CELL): CELL*(COLS+0.3) + fretLabelW ≤ canvasW
|
|
||||||
// B) when PAD = 6 floor (small CELL): CELL*(COLS-1) + 12 + fretLabelW ≤ canvasW
|
|
||||||
// Both are included so boxW ≤ canvasW in every regime.
|
|
||||||
// fretLabelW was measured at cellEst ≥ CELL, so the cap is conservative.
|
|
||||||
const CELL = Math.max(1, Math.min(
|
|
||||||
cellEst,
|
|
||||||
Math.floor((canvasW - fretLabelW) / (COLS + 0.3)),
|
|
||||||
Math.floor((canvasW - 2 * 6 - fretLabelW) / Math.max(1, COLS - 1)),
|
|
||||||
));
|
|
||||||
const HEADER = Math.round(CELL * 1.6);
|
|
||||||
const MARKER = Math.round(CELL * 0.7);
|
|
||||||
const DOT_R = CELL * 0.3;
|
|
||||||
const PAD = Math.max(6, Math.round(CELL * 0.65));
|
|
||||||
const gridW = CELL * (COLS - 1);
|
|
||||||
const gridH = CELL * ROWS;
|
|
||||||
|
|
||||||
const PAD_L = PAD + fretLabelW;
|
|
||||||
|
|
||||||
const boxW = gridW + PAD_L + PAD;
|
|
||||||
const boxH = HEADER + MARKER + gridH + PAD;
|
|
||||||
|
|
||||||
// Anchor to chosen corner. Top positions get extra vertical offset
|
|
||||||
// to clear the timeline plugin and song name displayed at the top.
|
|
||||||
// lyricsBottom is the actual bottom Y of the lyrics banner (returned by
|
|
||||||
// drawLyrics), so TOP_Y steps down past all lyric rows regardless of
|
|
||||||
// how many wrap lines the current panel width produces.
|
|
||||||
const E = PAD;
|
|
||||||
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
|
|
||||||
let bx, by;
|
|
||||||
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
|
|
||||||
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
|
|
||||||
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
|
|
||||||
else { bx = E; by = TOP_Y + stackOffset; }
|
|
||||||
|
|
||||||
// Clamp so the box never bleeds off-canvas on narrow panels or wide string counts.
|
|
||||||
bx = Math.max(0, Math.min(canvasW - boxW, bx));
|
|
||||||
by = Math.max(0, Math.min(canvasH - boxH, by));
|
|
||||||
|
|
||||||
// Guard: the canvasH–boxH clamp above can push `by` above lyricsBottom when
|
|
||||||
// wrapped lyrics consume nearly the full panel height. This applies to ALL
|
|
||||||
// corner positions: a bottom-corner diagram anchored near the canvas bottom can
|
|
||||||
// still reach up into the lyrics banner on very short or narrow panels where
|
|
||||||
// boxH is larger than the space below the lyrics. In those cases skip drawing
|
|
||||||
// entirely rather than painting on top of the lyrics banner.
|
|
||||||
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
|
|
||||||
|
|
||||||
const gx = bx + PAD_L, gy = by + HEADER + MARKER;
|
|
||||||
|
|
||||||
// Ease-out quadratic entrance scale: 0.85 → 1.0.
|
|
||||||
const scale = 1 - 0.15 * (1 - entranceT) * (1 - entranceT);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = opacity;
|
|
||||||
|
|
||||||
if (scale !== 1.0) {
|
|
||||||
const cx = bx + boxW / 2, cy = by + boxH / 2;
|
|
||||||
ctx.translate(cx, cy);
|
|
||||||
ctx.scale(scale, scale);
|
|
||||||
ctx.translate(-cx, -cy);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background + border.
|
|
||||||
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
|
|
||||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
|
|
||||||
|
|
||||||
// Split-root typography: "Dm7" → "D" large bold + "m7" smaller.
|
|
||||||
const rootMatch = name.match(/^([A-G][#b]?)(.*)/);
|
|
||||||
const root = rootMatch ? rootMatch[1] : name;
|
|
||||||
const quality = rootMatch ? rootMatch[2] : '';
|
|
||||||
const rootSize = Math.round(CELL * 1.25);
|
|
||||||
const qualSize = Math.round(rootSize * 0.65);
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
const nameY = by + HEADER * 0.55;
|
|
||||||
ctx.font = `bold ${rootSize}px sans-serif`;
|
|
||||||
const rootW = ctx.measureText(root).width;
|
|
||||||
ctx.font = `${qualSize}px sans-serif`;
|
|
||||||
const qualW = quality ? ctx.measureText(quality).width : 0;
|
|
||||||
const nameBlockW = rootW + (quality ? qualW + 2 : 0);
|
|
||||||
const nameStartX = bx + boxW / 2 - nameBlockW / 2;
|
|
||||||
ctx.fillStyle = '#e8d080';
|
|
||||||
ctx.font = `bold ${rootSize}px sans-serif`;
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.fillText(root, nameStartX, nameY);
|
|
||||||
if (quality) {
|
|
||||||
ctx.font = `${qualSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = 'rgba(232,208,128,0.75)';
|
|
||||||
ctx.fillText(quality, nameStartX + rootW + 2, nameY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nut: CELL-proportional filled rect + subtle highlight line.
|
|
||||||
// Thickness is 40% of CELL, floored at 2 px so it stays visible on
|
|
||||||
// the smallest diagrams (CELL=1 on compact split panels).
|
|
||||||
const NUT_H = Math.round(Math.max(2, CELL * 0.4));
|
|
||||||
if (isFirstPos) {
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fillRect(gx, gy - NUT_H, gridW, NUT_H);
|
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
|
||||||
ctx.fillRect(gx, gy - NUT_H, gridW, Math.max(1, Math.round(NUT_H * 0.25)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fret label for non-first-position chords.
|
|
||||||
if (!isFirstPos) {
|
|
||||||
ctx.fillStyle = 'rgba(220,200,120,0.9)';
|
|
||||||
ctx.font = `italic ${Math.round(CELL * 0.55)}px sans-serif`;
|
|
||||||
ctx.textAlign = 'right';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(startFret + 'fr', gx - 4, gy + CELL * 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fret lines.
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1;
|
|
||||||
for (let r = (isFirstPos ? 1 : 0); r <= ROWS; r++) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(gx, gy + r * CELL);
|
|
||||||
ctx.lineTo(gx + gridW, gy + r * CELL);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// String lines with varying weight: low E heavier, high e lighter.
|
|
||||||
// With getStrIdx(col) = col (non-inverted): col 0 (high e) → strIdx=0 → t=0 thin;
|
|
||||||
// col COLS-1 (low E) → strIdx=COLS-1 → t=1 thick. Inverted mode naturally mirrors.
|
|
||||||
// Weights scale with CELL so strings never bleed into adjacent columns on
|
|
||||||
// small-CELL diagrams (e.g. CELL=1 on compact split panels).
|
|
||||||
for (let col = 0; col < COLS; col++) {
|
|
||||||
const strIdx = getStrIdx(col);
|
|
||||||
const t = COLS > 1 ? strIdx / (COLS - 1) : 1; // 1=low E (thick), 0=high e (thin); guard COLS=1
|
|
||||||
ctx.lineWidth = Math.max(0.5, CELL * (0.05 + t * 0.10));
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(gx + col * CELL, gy);
|
|
||||||
ctx.lineTo(gx + col * CELL, gy + ROWS * CELL);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Barre detection — two complementary paths:
|
|
||||||
//
|
|
||||||
// PATH A (F-shape / mini-barre): at least two ADJACENT columns are at startFret.
|
|
||||||
// Bracket is initially set to the consecutive run's own endpoints (not the full
|
|
||||||
// startFretCols range) so isolated bass notes at the same fret can't pull the
|
|
||||||
// bracket across an open gap (e.g. "2 0 2 2 0 0" stays bracketed at cols 2..3).
|
|
||||||
//
|
|
||||||
// PATH B (full-span barre / extension):
|
|
||||||
// When PATH A fired: extend the bracket outward to the full outer startFret span
|
|
||||||
// if the span ≥ MIN_BARRE_SPAN and every column between the outer startFret
|
|
||||||
// columns is fretted (f > 0).
|
|
||||||
// When PATH A did NOT fire: detect standalone full barres (e.g. x24442, x46654)
|
|
||||||
// where only the two outermost strings sit at startFret. An additional check
|
|
||||||
// ensures that no intermediate column is itself at startFret — this rules out
|
|
||||||
// alternating-fret voicings like "1 3 1 3 1 0" (col 2 at startFret would fire
|
|
||||||
// incorrectly) while still catching B-major-style shapes where the barre
|
|
||||||
// finger covers only the outer two strings.
|
|
||||||
//
|
|
||||||
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
|
|
||||||
// Examples (6-string, MIN_BARRE_SPAN=4):
|
|
||||||
// F major [1,1,2,3,3,1]: PATH A run=[4,5] → bracket 4..5; PATH B span=5, all fretted → extends to 0..5 ✓
|
|
||||||
// B major x24442: PATH A no run; PATH B span=4, all fretted, no inner at startFret → 1..5 ✓
|
|
||||||
// mini-A x02220: PATH A run=[2,3,4] → bracket 2..4; PATH B span=2<4 → no extension ✓
|
|
||||||
// D major xx0232: PATH A run length=1 → no PATH A; PATH B span<4 → no bracket ✓
|
|
||||||
// 2 0 2 2 0 0: PATH A run=[2,3] → bracket 2..3; PATH B span=3<4 → no extension ✓
|
|
||||||
// 1 3 1 3 1 0: PATH A no run; PATH B: inner col 2 at startFret → no bracket ✓
|
|
||||||
const startFretCols = [];
|
|
||||||
for (let col = 0; col < COLS; col++) {
|
|
||||||
if (frets[getStrIdx(col)] === startFret) startFretCols.push(col);
|
|
||||||
}
|
|
||||||
const barreRun = longestConsecutiveRun(startFretCols);
|
|
||||||
let hasBarreArc = barreRun.len >= 2; // PATH A
|
|
||||||
let barreMinCol = hasBarreArc ? startFretCols[barreRun.start] : -1;
|
|
||||||
let barreMaxCol = hasBarreArc ? startFretCols[barreRun.start + barreRun.len - 1] : -1;
|
|
||||||
|
|
||||||
if (startFretCols.length >= 2) { // PATH B
|
|
||||||
const minC = startFretCols[0];
|
|
||||||
const maxC = startFretCols[startFretCols.length - 1];
|
|
||||||
if (maxC - minC >= MIN_BARRE_SPAN) {
|
|
||||||
let allFretted = true;
|
|
||||||
for (let col = minC; col <= maxC; col++) {
|
|
||||||
if (frets[getStrIdx(col)] <= 0) { allFretted = false; break; }
|
|
||||||
}
|
|
||||||
if (allFretted) {
|
|
||||||
if (hasBarreArc) {
|
|
||||||
// PATH A fired: always safe to extend to full outer span.
|
|
||||||
barreMinCol = minC;
|
|
||||||
barreMaxCol = maxC;
|
|
||||||
} else {
|
|
||||||
// PATH A did not fire: only draw a bracket when no intermediate
|
|
||||||
// column sits at startFret. Intermediate startFret columns would
|
|
||||||
// indicate a scattered/alternating voicing rather than a clean
|
|
||||||
// outer-edge barre (e.g. "1 3 1 3 1 0" has col 2 at startFret).
|
|
||||||
let noInnerAtStartFret = true;
|
|
||||||
for (let col = minC + 1; col < maxC; col++) {
|
|
||||||
if (frets[getStrIdx(col)] === startFret) { noInnerAtStartFret = false; break; }
|
|
||||||
}
|
|
||||||
if (noInnerAtStartFret) {
|
|
||||||
hasBarreArc = true;
|
|
||||||
barreMinCol = minC;
|
|
||||||
barreMaxCol = maxC;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasBarreArc) {
|
|
||||||
const barreY = gy + CELL * 0.5;
|
|
||||||
const capH = CELL * 0.22; // vertical offset from barreY to the bracket line
|
|
||||||
const capHalf = Math.max(1, Math.round(CELL * 0.3)); // half-height of the vertical end caps
|
|
||||||
// Straight bracket: a horizontal line with short vertical end caps.
|
|
||||||
// Stroke scales with CELL so it doesn't swamp tiny cells (floor at 1 px).
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = Math.max(1, CELL * 0.2);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH);
|
|
||||||
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH - capHalf);
|
|
||||||
ctx.lineTo(gx + barreMinCol * CELL, barreY - capH + capHalf);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(gx + barreMaxCol * CELL, barreY - capH - capHalf);
|
|
||||||
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH + capHalf);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open/muted markers + finger dots.
|
|
||||||
// Non-inverted: col 0 = high e → getStrIdx(0)=0 → frets[0]; col COLS-1 = low E → frets[COLS-1].
|
|
||||||
// Inverted: col 0 = low E → getStrIdx(0)=COLS-1 → frets[COLS-1]; col COLS-1 = high e → frets[0].
|
|
||||||
for (let col = 0; col < COLS; col++) {
|
|
||||||
const f = frets[getStrIdx(col)];
|
|
||||||
const sx = gx + col * CELL;
|
|
||||||
const markerY = gy - MARKER * 0.5;
|
|
||||||
if (f < 0) {
|
|
||||||
const r = CELL * 0.20;
|
|
||||||
ctx.strokeStyle = '#cc4444'; ctx.lineWidth = 1.5;
|
|
||||||
ctx.beginPath(); ctx.moveTo(sx - r, markerY - r); ctx.lineTo(sx + r, markerY + r); ctx.stroke();
|
|
||||||
ctx.beginPath(); ctx.moveTo(sx + r, markerY - r); ctx.lineTo(sx - r, markerY + r); ctx.stroke();
|
|
||||||
} else if (f === 0) {
|
|
||||||
ctx.strokeStyle = '#88bbff'; ctx.lineWidth = 1.5;
|
|
||||||
ctx.beginPath(); ctx.arc(sx, markerY, CELL * 0.22, 0, Math.PI * 2); ctx.stroke();
|
|
||||||
} else {
|
|
||||||
const row = f - startFret;
|
|
||||||
if (row >= 0 && row < ROWS) {
|
|
||||||
const isBarreCol = hasBarreArc && f === startFret &&
|
|
||||||
col >= barreMinCol && col <= barreMaxCol;
|
|
||||||
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
|
||||||
ctx.shadowBlur = Math.min(4, CELL * 0.4);
|
|
||||||
ctx.shadowOffsetX = Math.max(0.5, CELL * 0.1);
|
|
||||||
ctx.shadowOffsetY = Math.max(0.5, CELL * 0.1);
|
|
||||||
ctx.fillStyle = isBarreCol ? 'rgba(255,255,255,0.85)' : '#ffffff';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(sx, gy + row * CELL + CELL * 0.5, DOT_R, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
|
|
||||||
ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
return boxH;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached wrapper for drawChordDiagram. When entranceT === 1 (scale
|
|
||||||
// transform is identity) the diagram is rendered once to an
|
|
||||||
// OffscreenCanvas and reused every subsequent frame via drawImage +
|
|
||||||
// globalAlpha. During the 0.2 s entrance animation (entranceT < 1)
|
|
||||||
// the scale transform is non-trivial so we fall through to a fresh
|
|
||||||
// render — that window is ~12 frames at 60 fps, negligible.
|
|
||||||
//
|
//
|
||||||
// Returns boxH (diagram card height in px) so the draw loop can
|
// Surprises vs contract:
|
||||||
// accumulate per-corner stack offsets when multiple overlays share
|
// • longestConsecutiveRun (lines 4338–4352) co-moved — only called by drawChordDiagram
|
||||||
// the same corner position.
|
// • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX deleted from screen.js lines 573–575
|
||||||
function _drawDiagramCached(ctx, opts) {
|
// • _DIAG_CACHE_MAX deleted from factory scope line 3256 (moved to overlay.js)
|
||||||
const { opacity = 1, entranceT = 1.0, canvasW, canvasH } = opts;
|
|
||||||
if (opacity <= 0) return 0;
|
|
||||||
if (entranceT < 1.0) {
|
|
||||||
return drawChordDiagram(ctx, opts) || 0;
|
|
||||||
}
|
|
||||||
const { name, frets, nStr, inverted, sizeSlider, position, lyricsBottom = 0, stackOffset = 0 } = opts;
|
|
||||||
const key = name + '|' + (frets || []).join(',') + '|' + nStr + '|' +
|
|
||||||
(inverted ? 1 : 0) + '|' + sizeSlider + '|' + position + '|' +
|
|
||||||
canvasW + '|' + canvasH + '|' + lyricsBottom + '|' + stackOffset;
|
|
||||||
let entry = _diagRenderCache.get(key);
|
|
||||||
if (!entry) {
|
|
||||||
let oc;
|
|
||||||
try { oc = new OffscreenCanvas(canvasW, canvasH); }
|
|
||||||
catch (_) { oc = document.createElement('canvas'); oc.width = canvasW; oc.height = canvasH; }
|
|
||||||
const boxH = drawChordDiagram(oc.getContext('2d'), { ...opts, opacity: 1, entranceT: 1 }) || 0;
|
|
||||||
if (_diagRenderCache.size >= _DIAG_CACHE_MAX) {
|
|
||||||
_diagRenderCache.delete(_diagRenderCache.keys().next().value);
|
|
||||||
}
|
|
||||||
entry = { oc, boxH };
|
|
||||||
_diagRenderCache.set(key, entry);
|
|
||||||
}
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = opacity;
|
|
||||||
ctx.drawImage(entry.oc, 0, 0);
|
|
||||||
ctx.restore();
|
|
||||||
return entry.boxH;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two-line section card. Top line is "Now: <current>", bottom line
|
|
||||||
// is "Up Next: <next> in <countdown>". Explicit labels disambiguate
|
|
||||||
// current vs upcoming — earlier single-line variant rendered both
|
|
||||||
// states with the same word and was confusing during playback.
|
|
||||||
//
|
//
|
||||||
// Returns boxH on draw, 0 when nothing rendered. Position / size
|
// Beyond-subst (2):
|
||||||
// mirror the chord-diagram contract: 'tl' / 'tr' / 'bl' / 'br'
|
// 1. Factory wrapper createOverlay({ diagRenderCache })
|
||||||
// anchor corners, sizeSlider in [0,1] scales card height.
|
// 2. _diagRenderCache → diagRenderCache (3 sites in _drawDiagramCached)
|
||||||
//
|
const { drawChordDiagram, _drawDiagramCached, drawSectionHud, drawToneHud, drawLyrics } =
|
||||||
// Hidden when:
|
createOverlay({ diagRenderCache: _diagRenderCache });
|
||||||
// - no sections array, or
|
|
||||||
// - playback has not yet reached the first section AND there's
|
|
||||||
// no upcoming-only fallback rendered (we still show "Up Next"
|
|
||||||
// during the pre-roll so the user sees what's coming).
|
|
||||||
function drawSectionHud(ctx, opts) {
|
|
||||||
const {
|
|
||||||
sections, currentTime,
|
|
||||||
canvasW, canvasH,
|
|
||||||
position = 'tr',
|
|
||||||
sizeSlider = 0.5,
|
|
||||||
lyricsBottom = 0,
|
|
||||||
stackOffset = 0,
|
|
||||||
} = opts;
|
|
||||||
if (!sections || !sections.length) return 0;
|
|
||||||
|
|
||||||
// sections are time-ordered server-side; single forward scan.
|
|
||||||
let curIdx = -1;
|
|
||||||
for (let i = 0; i < sections.length; i++) {
|
|
||||||
if (sections[i].time <= currentTime) curIdx = i;
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
const cur = curIdx >= 0 ? sections[curIdx] : null;
|
|
||||||
const next = (curIdx + 1 < sections.length) ? sections[curIdx + 1] : null;
|
|
||||||
// Pre-first-section: nothing playing yet but next is coming —
|
|
||||||
// still useful to render "Up Next" alone so the user gets the
|
|
||||||
// anticipatory cue during the song's intro silence.
|
|
||||||
if (!cur && !next) return 0;
|
|
||||||
|
|
||||||
const nowName = cur ? cur.name : '';
|
|
||||||
// Render countdown as a separate span so it can take a calmer
|
|
||||||
// grey-white treatment while the section name itself stays
|
|
||||||
// cyan. Combining them into one string would inherit the cyan
|
|
||||||
// fill across both, defeating the visual hierarchy promised
|
|
||||||
// in the FR.
|
|
||||||
let nextName = '';
|
|
||||||
let nextCountdown = '';
|
|
||||||
if (next) {
|
|
||||||
const dt = next.time - currentTime;
|
|
||||||
nextName = next.name;
|
|
||||||
nextCountdown = dt > 10
|
|
||||||
? 'in ' + Math.round(dt) + 's'
|
|
||||||
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeF = 0.65 + 0.85 * sizeSlider; // 0.65 .. 1.5
|
|
||||||
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
|
|
||||||
const PAD_X = Math.round(baseH * 0.45);
|
|
||||||
const PAD_Y = Math.round(baseH * 0.20);
|
|
||||||
// Per-text-element scale applied to nameSize / tagSize / lineH
|
|
||||||
// when the unscaled card would overflow a narrow panel
|
|
||||||
// (splitscreen quad layout, ultra-tall portrait). Computed
|
|
||||||
// below from the measured contentW vs the available width.
|
|
||||||
let textScale = 1.0;
|
|
||||||
const baseLineH = Math.round(baseH * 0.46);
|
|
||||||
const baseNameSize = Math.round(baseH * 0.36);
|
|
||||||
const baseTagSize = Math.round(baseH * 0.24);
|
|
||||||
const baseTagGap = Math.round(baseH * 0.14);
|
|
||||||
|
|
||||||
const TAG_NOW = 'Now:';
|
|
||||||
const TAG_NEXT = 'Up Next:';
|
|
||||||
|
|
||||||
// Phase-1 measurement at the unscaled font sizes — used to
|
|
||||||
// decide whether textScale needs to drop, and to lay out the
|
|
||||||
// final draw at whatever scale we land on.
|
|
||||||
ctx.save();
|
|
||||||
ctx.font = `${baseTagSize}px sans-serif`;
|
|
||||||
const tagNowWBase = ctx.measureText(TAG_NOW).width;
|
|
||||||
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
|
|
||||||
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
|
||||||
ctx.font = `bold ${baseNameSize}px sans-serif`;
|
|
||||||
const nowNameWBase = nowName ? ctx.measureText(nowName).width : 0;
|
|
||||||
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
const lineNowWBase = nowName ? tagNowWBase + baseTagGap + nowNameWBase : 0;
|
|
||||||
const lineNextWBase = nextName
|
|
||||||
? tagNextWBase + baseTagGap + nextNameWBase
|
|
||||||
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
|
|
||||||
: 0;
|
|
||||||
const contentWBase = Math.max(lineNowWBase, lineNextWBase);
|
|
||||||
const numLines = (nowName ? 1 : 0) + (nextName ? 1 : 0);
|
|
||||||
if (numLines === 0) return 0;
|
|
||||||
|
|
||||||
// Target width budget: cap at canvasW - 16 and reserve PAD_X
|
|
||||||
// either side. If contentWBase exceeds the budget, scale the
|
|
||||||
// font proportionally — clamped to 0.55 so labels stay legible
|
|
||||||
// even on extreme split-panel widths.
|
|
||||||
const maxBoxW = Math.max(40, canvasW - 16);
|
|
||||||
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
|
|
||||||
if (contentWBase > availContentW) {
|
|
||||||
textScale = Math.max(0.55, availContentW / contentWBase);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lineH = Math.max(1, Math.round(baseLineH * textScale));
|
|
||||||
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
|
|
||||||
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
|
|
||||||
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
|
|
||||||
|
|
||||||
// Phase-2 re-measurement at the scaled font sizes for the
|
|
||||||
// final layout. measureText doesn't scale linearly with font
|
|
||||||
// size on every glyph, so re-measuring is cheaper than
|
|
||||||
// multiplying the base widths by textScale and risking a
|
|
||||||
// half-pixel overflow.
|
|
||||||
ctx.save();
|
|
||||||
ctx.font = `${tagSize}px sans-serif`;
|
|
||||||
const tagNowW = ctx.measureText(TAG_NOW).width;
|
|
||||||
const tagNextW = ctx.measureText(TAG_NEXT).width;
|
|
||||||
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
|
||||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
|
||||||
const nowNameW = nowName ? ctx.measureText(nowName).width : 0;
|
|
||||||
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
const lineNowW = nowName ? tagNowW + TAG_GAP + nowNameW : 0;
|
|
||||||
const lineNextW = nextName
|
|
||||||
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
|
|
||||||
: 0;
|
|
||||||
const contentW = Math.max(lineNowW, lineNextW);
|
|
||||||
|
|
||||||
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
|
|
||||||
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
|
|
||||||
|
|
||||||
const E = Math.round(baseH * 0.25);
|
|
||||||
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
|
|
||||||
let bx, by;
|
|
||||||
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
|
|
||||||
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
|
|
||||||
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
|
|
||||||
else { bx = E; by = TOP_Y + stackOffset; }
|
|
||||||
bx = Math.max(0, Math.min(canvasW - boxW, bx));
|
|
||||||
by = Math.max(0, Math.min(canvasH - boxH, by));
|
|
||||||
// Suppress overlap with the wrapped lyrics banner regardless
|
|
||||||
// of corner. Bottom-corner cards on short panels can still
|
|
||||||
// reach up into the banner once boxH exceeds the space below
|
|
||||||
// the lyrics — same shape the chord diagram uses.
|
|
||||||
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
|
|
||||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
|
|
||||||
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
|
|
||||||
// Layout each line with tag left-aligned, name in cyan after a
|
|
||||||
// small gap. Both lines share the same x origin (bx + PAD_X)
|
|
||||||
// so the tag column visually aligns vertically.
|
|
||||||
const lineX = bx + PAD_X;
|
|
||||||
let lineY = by + PAD_Y + lineH / 2;
|
|
||||||
const TAG_COLOR = 'rgba(180,190,205,0.85)';
|
|
||||||
const NAME_COLOR = '#00cccc';
|
|
||||||
const TIME_COLOR = 'rgba(220,225,235,0.9)';
|
|
||||||
|
|
||||||
if (nowName) {
|
|
||||||
ctx.font = `${tagSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = TAG_COLOR;
|
|
||||||
ctx.fillText(TAG_NOW, lineX, lineY);
|
|
||||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = NAME_COLOR;
|
|
||||||
ctx.fillText(nowName, lineX + tagNowW + TAG_GAP, lineY);
|
|
||||||
lineY += lineH;
|
|
||||||
}
|
|
||||||
if (nextName) {
|
|
||||||
ctx.font = `${tagSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = TAG_COLOR;
|
|
||||||
ctx.fillText(TAG_NEXT, lineX, lineY);
|
|
||||||
const nextX = lineX + tagNextW + TAG_GAP;
|
|
||||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = NAME_COLOR;
|
|
||||||
ctx.fillText(nextName, nextX, lineY);
|
|
||||||
if (nextCountdown) {
|
|
||||||
ctx.font = `${tagSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = TIME_COLOR;
|
|
||||||
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
return boxH;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tone-change HUD — card showing the active tone and the next upcoming
|
|
||||||
// tone with a countdown. Mirrors drawSectionHud's layout contract
|
|
||||||
// (position, size slider, lyricsBottom) but uses an amber accent colour
|
|
||||||
// so it reads as distinct from the cyan section card.
|
|
||||||
function drawToneHud(ctx, opts) {
|
|
||||||
const {
|
|
||||||
toneChanges, toneBase = '',
|
|
||||||
currentTime,
|
|
||||||
canvasW, canvasH,
|
|
||||||
position = 'tl',
|
|
||||||
sizeSlider = 0.5,
|
|
||||||
lyricsBottom = 0,
|
|
||||||
stackOffset = 0,
|
|
||||||
} = opts;
|
|
||||||
|
|
||||||
// Resolve active tone: toneBase before all changes, else the most
|
|
||||||
// recent change at or before currentTime.
|
|
||||||
// toneChanges items use { t, name } (not { time, name }) — both
|
|
||||||
// the legacy import path (server.py xml_tone_changes) and the sloppak
|
|
||||||
// path (lib/tones.py sloppak_tone_changes) emit "t" as the key.
|
|
||||||
let curName = toneBase;
|
|
||||||
let nextChange = null;
|
|
||||||
if (toneChanges && toneChanges.length) {
|
|
||||||
for (let i = 0; i < toneChanges.length; i++) {
|
|
||||||
if (toneChanges[i].t <= currentTime) {
|
|
||||||
curName = toneChanges[i].name;
|
|
||||||
} else {
|
|
||||||
nextChange = toneChanges[i];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!curName && !nextChange) return 0;
|
|
||||||
|
|
||||||
let nextName = '';
|
|
||||||
let nextCountdown = '';
|
|
||||||
if (nextChange) {
|
|
||||||
const dt = nextChange.t - currentTime;
|
|
||||||
nextName = nextChange.name;
|
|
||||||
nextCountdown = dt > 10
|
|
||||||
? 'in ' + Math.round(dt) + 's'
|
|
||||||
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeF = 0.65 + 0.85 * sizeSlider;
|
|
||||||
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
|
|
||||||
const PAD_X = Math.round(baseH * 0.45);
|
|
||||||
const PAD_Y = Math.round(baseH * 0.20);
|
|
||||||
let textScale = 1.0;
|
|
||||||
const baseLineH = Math.round(baseH * 0.46);
|
|
||||||
const baseNameSize = Math.round(baseH * 0.36);
|
|
||||||
const baseTagSize = Math.round(baseH * 0.24);
|
|
||||||
const baseTagGap = Math.round(baseH * 0.14);
|
|
||||||
|
|
||||||
const TAG_CUR = 'Tone:';
|
|
||||||
const TAG_NEXT = 'Next:';
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.font = `${baseTagSize}px sans-serif`;
|
|
||||||
const tagCurWBase = ctx.measureText(TAG_CUR).width;
|
|
||||||
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
|
|
||||||
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
|
||||||
ctx.font = `bold ${baseNameSize}px sans-serif`;
|
|
||||||
const curNameWBase = curName ? ctx.measureText(curName).width : 0;
|
|
||||||
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
const lineCurWBase = curName ? tagCurWBase + baseTagGap + curNameWBase : 0;
|
|
||||||
const lineNextWBase = nextName
|
|
||||||
? tagNextWBase + baseTagGap + nextNameWBase
|
|
||||||
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
|
|
||||||
: 0;
|
|
||||||
const contentWBase = Math.max(lineCurWBase, lineNextWBase);
|
|
||||||
const numLines = (curName ? 1 : 0) + (nextName ? 1 : 0);
|
|
||||||
if (numLines === 0) return 0;
|
|
||||||
|
|
||||||
const maxBoxW = Math.max(40, canvasW - 16);
|
|
||||||
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
|
|
||||||
if (contentWBase > availContentW) {
|
|
||||||
textScale = Math.max(0.55, availContentW / contentWBase);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lineH = Math.max(1, Math.round(baseLineH * textScale));
|
|
||||||
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
|
|
||||||
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
|
|
||||||
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.font = `${tagSize}px sans-serif`;
|
|
||||||
const tagCurW = ctx.measureText(TAG_CUR).width;
|
|
||||||
const tagNextW = ctx.measureText(TAG_NEXT).width;
|
|
||||||
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
|
||||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
|
||||||
const curNameW = curName ? ctx.measureText(curName).width : 0;
|
|
||||||
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
const lineCurW = curName ? tagCurW + TAG_GAP + curNameW : 0;
|
|
||||||
const lineNextW = nextName
|
|
||||||
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
|
|
||||||
: 0;
|
|
||||||
const contentW = Math.max(lineCurW, lineNextW);
|
|
||||||
|
|
||||||
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
|
|
||||||
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
|
|
||||||
|
|
||||||
const E = Math.round(baseH * 0.25);
|
|
||||||
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
|
|
||||||
let bx, by;
|
|
||||||
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
|
|
||||||
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
|
|
||||||
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
|
|
||||||
else { bx = E; by = TOP_Y + stackOffset; } // 'tl' default
|
|
||||||
bx = Math.max(0, Math.min(canvasW - boxW, bx));
|
|
||||||
by = Math.max(0, Math.min(canvasH - boxH, by));
|
|
||||||
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
|
|
||||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
|
|
||||||
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
|
|
||||||
const lineX = bx + PAD_X;
|
|
||||||
let lineY = by + PAD_Y + lineH / 2;
|
|
||||||
const TAG_COLOR = 'rgba(180,190,205,0.85)';
|
|
||||||
const NAME_COLOR = '#ff9a3c'; // amber — distinct from section cyan
|
|
||||||
const TIME_COLOR = 'rgba(220,225,235,0.9)';
|
|
||||||
|
|
||||||
if (curName) {
|
|
||||||
ctx.font = `${tagSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = TAG_COLOR;
|
|
||||||
ctx.fillText(TAG_CUR, lineX, lineY);
|
|
||||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = NAME_COLOR;
|
|
||||||
ctx.fillText(curName, lineX + tagCurW + TAG_GAP, lineY);
|
|
||||||
lineY += lineH;
|
|
||||||
}
|
|
||||||
if (nextName) {
|
|
||||||
ctx.font = `${tagSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = TAG_COLOR;
|
|
||||||
ctx.fillText(TAG_NEXT, lineX, lineY);
|
|
||||||
const nextX = lineX + tagNextW + TAG_GAP;
|
|
||||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = NAME_COLOR;
|
|
||||||
ctx.fillText(nextName, nextX, lineY);
|
|
||||||
if (nextCountdown) {
|
|
||||||
ctx.font = `${tagSize}px sans-serif`;
|
|
||||||
ctx.fillStyle = TIME_COLOR;
|
|
||||||
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
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;
|
|
||||||
|
|
||||||
function drawLyrics(lyrics, currentTime, ctx, W, H) {
|
|
||||||
if (!lyrics._lines) {
|
|
||||||
const lines = [];
|
|
||||||
let line = null, word = null;
|
|
||||||
const flushWord = () => { if (word && word.length) line.words.push(word); word = null; };
|
|
||||||
const flushLine = () => { flushWord(); if (line && line.words.length) lines.push(line); line = null; };
|
|
||||||
for (let i = 0; i < lyrics.length; i++) {
|
|
||||||
const l = lyrics[i];
|
|
||||||
const raw = l.w || '';
|
|
||||||
const endsLine = raw.endsWith('+');
|
|
||||||
const continuesWord = raw.endsWith('-');
|
|
||||||
if (line && i > 0 && l.t - (lyrics[i - 1].t + lyrics[i - 1].d) > 4.0) flushLine();
|
|
||||||
if (!line) line = { words: [], start: l.t, end: l.t + l.d };
|
|
||||||
if (!word) word = [];
|
|
||||||
word.push(l);
|
|
||||||
line.end = Math.max(line.end, l.t + l.d);
|
|
||||||
if (!continuesWord) flushWord();
|
|
||||||
if (endsLine) flushLine();
|
|
||||||
}
|
|
||||||
flushLine();
|
|
||||||
lyrics._lines = lines;
|
|
||||||
}
|
|
||||||
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 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;
|
|
||||||
for (const wordSyls of authoredLine.words) {
|
|
||||||
const parts = [];
|
|
||||||
let wordWidth = 0;
|
|
||||||
for (const s of wordSyls) {
|
|
||||||
const text = sylText(s);
|
|
||||||
const w = ctx.measureText(text).width;
|
|
||||||
parts.push({ syl: s, text, width: w });
|
|
||||||
wordWidth += w;
|
|
||||||
}
|
|
||||||
const advance = wordWidth + spaceWidth;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowHeight = fontSize + 6;
|
|
||||||
const totalHeight = rows.length * rowHeight + 10;
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
||||||
ctx.beginPath();
|
|
||||||
const bx = W / 2 - bgWidth / 2, by = lineY - 4, br = 8;
|
|
||||||
ctx.moveTo(bx + br, by); ctx.lineTo(bx + bgWidth - br, by);
|
|
||||||
ctx.quadraticCurveTo(bx + bgWidth, by, bx + bgWidth, by + br);
|
|
||||||
ctx.lineTo(bx + bgWidth, by + totalHeight - br);
|
|
||||||
ctx.quadraticCurveTo(bx + bgWidth, by + totalHeight, bx + bgWidth - br, by + totalHeight);
|
|
||||||
ctx.lineTo(bx + br, by + totalHeight);
|
|
||||||
ctx.quadraticCurveTo(bx, by + totalHeight, bx, by + totalHeight - br);
|
|
||||||
ctx.lineTo(bx, by + br);
|
|
||||||
ctx.quadraticCurveTo(bx, by, bx + br, by);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
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;
|
|
||||||
const yPos = lineY + r * rowHeight + 2;
|
|
||||||
for (const w of row) {
|
|
||||||
for (const part of w.parts) {
|
|
||||||
const l = part.syl;
|
|
||||||
const isActive = currentTime >= l.t && currentTime < l.t + l.d;
|
|
||||||
const isPast = currentTime >= l.t + l.d;
|
|
||||||
ctx.fillStyle = isActive ? '#4ae0ff' : isPast ? '#8899aa' : '#556677';
|
|
||||||
ctx.font = `${isActive ? 'bold' : 'normal'} ${fontSize}px sans-serif`;
|
|
||||||
ctx.fillText(part.text, xPos, yPos);
|
|
||||||
xPos += part.width;
|
|
||||||
}
|
|
||||||
xPos += spaceWidth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Return the actual bottom Y of the rendered background box so callers
|
|
||||||
// (e.g. drawChordDiagram) can avoid overlapping it.
|
|
||||||
return Math.round(by + totalHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Scene initialisation ─────────────────────────────────────────── */
|
/* ── Scene initialisation ─────────────────────────────────────────── */
|
||||||
function initScene() {
|
function initScene() {
|
||||||
|
|||||||
@@ -0,0 +1,924 @@
|
|||||||
|
// src/overlay.js — h3d-carve-7
|
||||||
|
// Lyrics + HUD overlay extracted from screen.js O-section (lines 4338–5209
|
||||||
|
// post-cut-6). Pure 2D canvas drawing — no Three.js dependency.
|
||||||
|
//
|
||||||
|
// screen.js usage (factory scope, once per createFactory() invocation):
|
||||||
|
// const { drawChordDiagram, _drawDiagramCached, drawSectionHud,
|
||||||
|
// drawToneHud, drawLyrics } = createOverlay({
|
||||||
|
// diagRenderCache, // stable const Map ref (screen.js factory-scope)
|
||||||
|
// // teardown calls .clear() on it directly; the shared
|
||||||
|
// // ref makes that visible to the overlay without a callback
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// Surprises vs contract:
|
||||||
|
// • longestConsecutiveRun (lines 4338–4352) co-moved — only called by drawChordDiagram
|
||||||
|
// • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX moved here and DELETED from
|
||||||
|
// screen.js lines 573–575 (only used inside O-section)
|
||||||
|
// • _DIAG_CACHE_MAX moved inside createOverlay body and deleted from
|
||||||
|
// screen.js factory scope (line 3256) — same factory-scope level, different file
|
||||||
|
//
|
||||||
|
// Beyond-subst rewires (2):
|
||||||
|
// 1. Factory wrapper createOverlay({ diagRenderCache })
|
||||||
|
// 2. _diagRenderCache → diagRenderCache (DI param) in 3 sites in _drawDiagramCached
|
||||||
|
|
||||||
|
// ── Constants moved from screen.js module scope (lines 573–575) ─────────────
|
||||||
|
// Only users were inside the O-section; no other callers remain in screen.js.
|
||||||
|
const DIAG_SIZE_MIN = 0.08;
|
||||||
|
const DIAG_SIZE_MAX = 0.16;
|
||||||
|
const DIAG_CELL_MAX = 34;
|
||||||
|
|
||||||
|
export function createOverlay({ diagRenderCache }) {
|
||||||
|
// ── Moved from screen.js factory scope (line 3256) ───────────────────────
|
||||||
|
// Cap chosen to cover the ~5–6 active chord shapes per phrase while
|
||||||
|
// keeping the cached-OffscreenCanvas footprint bounded (~50 MB per
|
||||||
|
// panel at typical 1920×1080). A structural fix — caching a
|
||||||
|
// tightly-sized box surface instead of the full overlay canvas —
|
||||||
|
// is tracked as a follow-up.
|
||||||
|
const _DIAG_CACHE_MAX = 6;
|
||||||
|
|
||||||
|
// ── Lyrics layout cache — moved from factory scope (line 5077) ───────────
|
||||||
|
// measureText per syllable + row wrapping only changes when the displayed
|
||||||
|
// line(s), font size, or canvas width change, not per frame.
|
||||||
|
let _lyrRowsCache = null;
|
||||||
|
|
||||||
|
// Returns indices of the longest consecutive run in a sorted integer
|
||||||
|
// array as { start, len } — `sorted[start..start+len)` is the run.
|
||||||
|
// Avoids the two per-call sub-array allocations of the previous
|
||||||
|
// implementation (best + cur arrays grown via .push), at the cost
|
||||||
|
// of one small 2-key result object. Net: callers in the chord-
|
||||||
|
// diagram render path no longer churn arrays per visible chord.
|
||||||
|
function longestConsecutiveRun(sorted) {
|
||||||
|
let bestStart = -1, bestLen = 0;
|
||||||
|
let curStart = -1, curLen = 0;
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
if (curLen === 0 || sorted[i] === sorted[curStart + curLen - 1] + 1) {
|
||||||
|
if (curLen === 0) curStart = i;
|
||||||
|
curLen++;
|
||||||
|
} else {
|
||||||
|
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
|
||||||
|
curStart = i; curLen = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
|
||||||
|
return { start: bestStart, len: bestLen };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lyrics overlay (2D canvas on top of WebGL) ─────────────────── */
|
||||||
|
function drawChordDiagram(ctx, opts) {
|
||||||
|
const {
|
||||||
|
name, frets,
|
||||||
|
opacity = 1,
|
||||||
|
entranceT = 1.0,
|
||||||
|
canvasW = 600, canvasH = 400,
|
||||||
|
inverted = false,
|
||||||
|
sizeSlider = 0.5,
|
||||||
|
position = 'tl',
|
||||||
|
nStr = 6,
|
||||||
|
lyricsBottom = 0,
|
||||||
|
stackOffset = 0,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
// Responsive sizing — CELL derived from panel height + user slider.
|
||||||
|
// COLS is the resolved string count from the caller (via resolveStringCount)
|
||||||
|
// so bass (4), extended (7/8) arrangements render correctly.
|
||||||
|
const COLS = nStr, ROWS = 4;
|
||||||
|
// Minimum column span required for PATH B (bracket extension / detection).
|
||||||
|
// Math.min(COLS-1, 4) scales with string count:
|
||||||
|
// 4-string bass → 3 (max possible span, so 2-4-4-2 shapes qualify)
|
||||||
|
// 6-string → 4 (excludes D major span=2 / common 2-string coincidences)
|
||||||
|
// 8-string → 4 (muted outer strings still leave span ≥ 4 for real barres)
|
||||||
|
const MIN_BARRE_SPAN = Math.min(COLS - 1, 4);
|
||||||
|
// Maps diagram column index → chord-template frets-array index.
|
||||||
|
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
|
||||||
|
// Non-inverted display (col 0 = high e): getStrIdx(0) = 0 → frets[0] = high e.
|
||||||
|
// Inverted display (col 0 = low E): getStrIdx(0) = COLS-1 → frets[COLS-1] = low E.
|
||||||
|
const getStrIdx = col => inverted ? (COLS - 1 - col) : col;
|
||||||
|
const sizeF = DIAG_SIZE_MIN + (DIAG_SIZE_MAX - DIAG_SIZE_MIN) * sizeSlider;
|
||||||
|
|
||||||
|
// startFret / isFirstPos must be known before CELL so that fretLabelW
|
||||||
|
// can be measured and factored into the width cap. The old
|
||||||
|
// canvasW/(COLS+1.5) guard only approximated 2*PAD and ignored the
|
||||||
|
// extra left padding reserved for non-first-position "Nfr" labels.
|
||||||
|
const playedFrets = frets.filter(f => f > 0);
|
||||||
|
const minFret = playedFrets.length > 0 ? Math.min(...playedFrets) : 1;
|
||||||
|
const startFret = Math.max(1, minFret);
|
||||||
|
const isFirstPos = startFret === 1;
|
||||||
|
|
||||||
|
// Phase 1 — height + hard-cap estimate, used only to size the label font.
|
||||||
|
// Cap against the vertical space available below lyricsBottom so that the
|
||||||
|
// diagram does not overflow into the lyrics banner on short split panels with
|
||||||
|
// wrapped lyric rows. Only top-corner positions can overlap the lyrics banner,
|
||||||
|
// so lyricsBottom is only subtracted when position is 'tl' or 'tr'; for 'bl'
|
||||||
|
// and 'br' the full canvas height is available.
|
||||||
|
// Clamp to at least 1 so font/box calculations never receive 0-px input
|
||||||
|
// on very short panels (e.g. tiny split cells < 44 px tall).
|
||||||
|
const isTopCorner = position === 'tl' || position === 'tr';
|
||||||
|
const availH = canvasH - (isTopCorner ? lyricsBottom : 0);
|
||||||
|
const cellEst = Math.max(1, Math.min(
|
||||||
|
Math.round(availH * sizeF / (ROWS + 3)),
|
||||||
|
DIAG_CELL_MAX,
|
||||||
|
));
|
||||||
|
// Extra left padding for the "Nfr" label on non-first-position chords.
|
||||||
|
// Measured with ctx.measureText at cellEst so the estimate is exact.
|
||||||
|
let fretLabelW = 0;
|
||||||
|
if (!isFirstPos) {
|
||||||
|
// Measure inside a save/restore so this font assignment does not
|
||||||
|
// leak to the caller (the outer ctx.save() happens after CELL is derived).
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `italic ${Math.round(cellEst * 0.55)}px sans-serif`;
|
||||||
|
fretLabelW = Math.ceil(ctx.measureText(startFret + 'fr').width) + 6;
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 — final CELL: cap against panel height, hard max, and panel width.
|
||||||
|
// Two width constraints are needed because PAD has a hard floor of 6:
|
||||||
|
// A) when PAD = CELL*0.65 (large CELL): CELL*(COLS+0.3) + fretLabelW ≤ canvasW
|
||||||
|
// B) when PAD = 6 floor (small CELL): CELL*(COLS-1) + 12 + fretLabelW ≤ canvasW
|
||||||
|
// Both are included so boxW ≤ canvasW in every regime.
|
||||||
|
// fretLabelW was measured at cellEst ≥ CELL, so the cap is conservative.
|
||||||
|
const CELL = Math.max(1, Math.min(
|
||||||
|
cellEst,
|
||||||
|
Math.floor((canvasW - fretLabelW) / (COLS + 0.3)),
|
||||||
|
Math.floor((canvasW - 2 * 6 - fretLabelW) / Math.max(1, COLS - 1)),
|
||||||
|
));
|
||||||
|
const HEADER = Math.round(CELL * 1.6);
|
||||||
|
const MARKER = Math.round(CELL * 0.7);
|
||||||
|
const DOT_R = CELL * 0.3;
|
||||||
|
const PAD = Math.max(6, Math.round(CELL * 0.65));
|
||||||
|
const gridW = CELL * (COLS - 1);
|
||||||
|
const gridH = CELL * ROWS;
|
||||||
|
|
||||||
|
const PAD_L = PAD + fretLabelW;
|
||||||
|
|
||||||
|
const boxW = gridW + PAD_L + PAD;
|
||||||
|
const boxH = HEADER + MARKER + gridH + PAD;
|
||||||
|
|
||||||
|
// Anchor to chosen corner. Top positions get extra vertical offset
|
||||||
|
// to clear the timeline plugin and song name displayed at the top.
|
||||||
|
// lyricsBottom is the actual bottom Y of the lyrics banner (returned by
|
||||||
|
// drawLyrics), so TOP_Y steps down past all lyric rows regardless of
|
||||||
|
// how many wrap lines the current panel width produces.
|
||||||
|
const E = PAD;
|
||||||
|
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
|
||||||
|
let bx, by;
|
||||||
|
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
|
||||||
|
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
|
||||||
|
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
|
||||||
|
else { bx = E; by = TOP_Y + stackOffset; }
|
||||||
|
|
||||||
|
// Clamp so the box never bleeds off-canvas on narrow panels or wide string counts.
|
||||||
|
bx = Math.max(0, Math.min(canvasW - boxW, bx));
|
||||||
|
by = Math.max(0, Math.min(canvasH - boxH, by));
|
||||||
|
|
||||||
|
// Guard: the canvasH–boxH clamp above can push `by` above lyricsBottom when
|
||||||
|
// wrapped lyrics consume nearly the full panel height. This applies to ALL
|
||||||
|
// corner positions: a bottom-corner diagram anchored near the canvas bottom can
|
||||||
|
// still reach up into the lyrics banner on very short or narrow panels where
|
||||||
|
// boxH is larger than the space below the lyrics. In those cases skip drawing
|
||||||
|
// entirely rather than painting on top of the lyrics banner.
|
||||||
|
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
|
||||||
|
|
||||||
|
const gx = bx + PAD_L, gy = by + HEADER + MARKER;
|
||||||
|
|
||||||
|
// Ease-out quadratic entrance scale: 0.85 → 1.0.
|
||||||
|
const scale = 1 - 0.15 * (1 - entranceT) * (1 - entranceT);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
|
||||||
|
if (scale !== 1.0) {
|
||||||
|
const cx = bx + boxW / 2, cy = by + boxH / 2;
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.translate(-cx, -cy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background + border.
|
||||||
|
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
|
||||||
|
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
|
||||||
|
|
||||||
|
// Split-root typography: "Dm7" → "D" large bold + "m7" smaller.
|
||||||
|
const rootMatch = name.match(/^([A-G][#b]?)(.*)/);
|
||||||
|
const root = rootMatch ? rootMatch[1] : name;
|
||||||
|
const quality = rootMatch ? rootMatch[2] : '';
|
||||||
|
const rootSize = Math.round(CELL * 1.25);
|
||||||
|
const qualSize = Math.round(rootSize * 0.65);
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const nameY = by + HEADER * 0.55;
|
||||||
|
ctx.font = `bold ${rootSize}px sans-serif`;
|
||||||
|
const rootW = ctx.measureText(root).width;
|
||||||
|
ctx.font = `${qualSize}px sans-serif`;
|
||||||
|
const qualW = quality ? ctx.measureText(quality).width : 0;
|
||||||
|
const nameBlockW = rootW + (quality ? qualW + 2 : 0);
|
||||||
|
const nameStartX = bx + boxW / 2 - nameBlockW / 2;
|
||||||
|
ctx.fillStyle = '#e8d080';
|
||||||
|
ctx.font = `bold ${rootSize}px sans-serif`;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(root, nameStartX, nameY);
|
||||||
|
if (quality) {
|
||||||
|
ctx.font = `${qualSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = 'rgba(232,208,128,0.75)';
|
||||||
|
ctx.fillText(quality, nameStartX + rootW + 2, nameY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nut: CELL-proportional filled rect + subtle highlight line.
|
||||||
|
// Thickness is 40% of CELL, floored at 2 px so it stays visible on
|
||||||
|
// the smallest diagrams (CELL=1 on compact split panels).
|
||||||
|
const NUT_H = Math.round(Math.max(2, CELL * 0.4));
|
||||||
|
if (isFirstPos) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(gx, gy - NUT_H, gridW, NUT_H);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.fillRect(gx, gy - NUT_H, gridW, Math.max(1, Math.round(NUT_H * 0.25)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fret label for non-first-position chords.
|
||||||
|
if (!isFirstPos) {
|
||||||
|
ctx.fillStyle = 'rgba(220,200,120,0.9)';
|
||||||
|
ctx.font = `italic ${Math.round(CELL * 0.55)}px sans-serif`;
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(startFret + 'fr', gx - 4, gy + CELL * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fret lines.
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1;
|
||||||
|
for (let r = (isFirstPos ? 1 : 0); r <= ROWS; r++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gx, gy + r * CELL);
|
||||||
|
ctx.lineTo(gx + gridW, gy + r * CELL);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// String lines with varying weight: low E heavier, high e lighter.
|
||||||
|
// With getStrIdx(col) = col (non-inverted): col 0 (high e) → strIdx=0 → t=0 thin;
|
||||||
|
// col COLS-1 (low E) → strIdx=COLS-1 → t=1 thick. Inverted mode naturally mirrors.
|
||||||
|
// Weights scale with CELL so strings never bleed into adjacent columns on
|
||||||
|
// small-CELL diagrams (e.g. CELL=1 on compact split panels).
|
||||||
|
for (let col = 0; col < COLS; col++) {
|
||||||
|
const strIdx = getStrIdx(col);
|
||||||
|
const t = COLS > 1 ? strIdx / (COLS - 1) : 1; // 1=low E (thick), 0=high e (thin); guard COLS=1
|
||||||
|
ctx.lineWidth = Math.max(0.5, CELL * (0.05 + t * 0.10));
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gx + col * CELL, gy);
|
||||||
|
ctx.lineTo(gx + col * CELL, gy + ROWS * CELL);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Barre detection — two complementary paths:
|
||||||
|
//
|
||||||
|
// PATH A (F-shape / mini-barre): at least two ADJACENT columns are at startFret.
|
||||||
|
// Bracket is initially set to the consecutive run's own endpoints (not the full
|
||||||
|
// startFretCols range) so isolated bass notes at the same fret can't pull the
|
||||||
|
// bracket across an open gap (e.g. "2 0 2 2 0 0" stays bracketed at cols 2..3).
|
||||||
|
//
|
||||||
|
// PATH B (full-span barre / extension):
|
||||||
|
// When PATH A fired: extend the bracket outward to the full outer startFret span
|
||||||
|
// if the span ≥ MIN_BARRE_SPAN and every column between the outer startFret
|
||||||
|
// columns is fretted (f > 0).
|
||||||
|
// When PATH A did NOT fire: detect standalone full barres (e.g. x24442, x46654)
|
||||||
|
// where only the two outermost strings sit at startFret. An additional check
|
||||||
|
// ensures that no intermediate column is itself at startFret — this rules out
|
||||||
|
// alternating-fret voicings like "1 3 1 3 1 0" (col 2 at startFret would fire
|
||||||
|
// incorrectly) while still catching B-major-style shapes where the barre
|
||||||
|
// finger covers only the outer two strings.
|
||||||
|
//
|
||||||
|
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
|
||||||
|
// Examples (6-string, MIN_BARRE_SPAN=4):
|
||||||
|
// F major [1,1,2,3,3,1]: PATH A run=[4,5] → bracket 4..5; PATH B span=5, all fretted → extends to 0..5 ✓
|
||||||
|
// B major x24442: PATH A no run; PATH B span=4, all fretted, no inner at startFret → 1..5 ✓
|
||||||
|
// mini-A x02220: PATH A run=[2,3,4] → bracket 2..4; PATH B span=2<4 → no extension ✓
|
||||||
|
// D major xx0232: PATH A run length=1 → no PATH A; PATH B span<4 → no bracket ✓
|
||||||
|
// 2 0 2 2 0 0: PATH A run=[2,3] → bracket 2..3; PATH B span=3<4 → no extension ✓
|
||||||
|
// 1 3 1 3 1 0: PATH A no run; PATH B: inner col 2 at startFret → no bracket ✓
|
||||||
|
const startFretCols = [];
|
||||||
|
for (let col = 0; col < COLS; col++) {
|
||||||
|
if (frets[getStrIdx(col)] === startFret) startFretCols.push(col);
|
||||||
|
}
|
||||||
|
const barreRun = longestConsecutiveRun(startFretCols);
|
||||||
|
let hasBarreArc = barreRun.len >= 2; // PATH A
|
||||||
|
let barreMinCol = hasBarreArc ? startFretCols[barreRun.start] : -1;
|
||||||
|
let barreMaxCol = hasBarreArc ? startFretCols[barreRun.start + barreRun.len - 1] : -1;
|
||||||
|
|
||||||
|
if (startFretCols.length >= 2) { // PATH B
|
||||||
|
const minC = startFretCols[0];
|
||||||
|
const maxC = startFretCols[startFretCols.length - 1];
|
||||||
|
if (maxC - minC >= MIN_BARRE_SPAN) {
|
||||||
|
let allFretted = true;
|
||||||
|
for (let col = minC; col <= maxC; col++) {
|
||||||
|
if (frets[getStrIdx(col)] <= 0) { allFretted = false; break; }
|
||||||
|
}
|
||||||
|
if (allFretted) {
|
||||||
|
if (hasBarreArc) {
|
||||||
|
// PATH A fired: always safe to extend to full outer span.
|
||||||
|
barreMinCol = minC;
|
||||||
|
barreMaxCol = maxC;
|
||||||
|
} else {
|
||||||
|
// PATH A did not fire: only draw a bracket when no intermediate
|
||||||
|
// column sits at startFret. Intermediate startFret columns would
|
||||||
|
// indicate a scattered/alternating voicing rather than a clean
|
||||||
|
// outer-edge barre (e.g. "1 3 1 3 1 0" has col 2 at startFret).
|
||||||
|
let noInnerAtStartFret = true;
|
||||||
|
for (let col = minC + 1; col < maxC; col++) {
|
||||||
|
if (frets[getStrIdx(col)] === startFret) { noInnerAtStartFret = false; break; }
|
||||||
|
}
|
||||||
|
if (noInnerAtStartFret) {
|
||||||
|
hasBarreArc = true;
|
||||||
|
barreMinCol = minC;
|
||||||
|
barreMaxCol = maxC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasBarreArc) {
|
||||||
|
const barreY = gy + CELL * 0.5;
|
||||||
|
const capH = CELL * 0.22; // vertical offset from barreY to the bracket line
|
||||||
|
const capHalf = Math.max(1, Math.round(CELL * 0.3)); // half-height of the vertical end caps
|
||||||
|
// Straight bracket: a horizontal line with short vertical end caps.
|
||||||
|
// Stroke scales with CELL so it doesn't swamp tiny cells (floor at 1 px).
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = Math.max(1, CELL * 0.2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH);
|
||||||
|
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH - capHalf);
|
||||||
|
ctx.lineTo(gx + barreMinCol * CELL, barreY - capH + capHalf);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gx + barreMaxCol * CELL, barreY - capH - capHalf);
|
||||||
|
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH + capHalf);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open/muted markers + finger dots.
|
||||||
|
// Non-inverted: col 0 = high e → getStrIdx(0)=0 → frets[0]; col COLS-1 = low E → frets[COLS-1].
|
||||||
|
// Inverted: col 0 = low E → getStrIdx(0)=COLS-1 → frets[COLS-1]; col COLS-1 = high e → frets[0].
|
||||||
|
for (let col = 0; col < COLS; col++) {
|
||||||
|
const f = frets[getStrIdx(col)];
|
||||||
|
const sx = gx + col * CELL;
|
||||||
|
const markerY = gy - MARKER * 0.5;
|
||||||
|
if (f < 0) {
|
||||||
|
const r = CELL * 0.20;
|
||||||
|
ctx.strokeStyle = '#cc4444'; ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx - r, markerY - r); ctx.lineTo(sx + r, markerY + r); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx + r, markerY - r); ctx.lineTo(sx - r, markerY + r); ctx.stroke();
|
||||||
|
} else if (f === 0) {
|
||||||
|
ctx.strokeStyle = '#88bbff'; ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath(); ctx.arc(sx, markerY, CELL * 0.22, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
} else {
|
||||||
|
const row = f - startFret;
|
||||||
|
if (row >= 0 && row < ROWS) {
|
||||||
|
const isBarreCol = hasBarreArc && f === startFret &&
|
||||||
|
col >= barreMinCol && col <= barreMaxCol;
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
||||||
|
ctx.shadowBlur = Math.min(4, CELL * 0.4);
|
||||||
|
ctx.shadowOffsetX = Math.max(0.5, CELL * 0.1);
|
||||||
|
ctx.shadowOffsetY = Math.max(0.5, CELL * 0.1);
|
||||||
|
ctx.fillStyle = isBarreCol ? 'rgba(255,255,255,0.85)' : '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, gy + row * CELL + CELL * 0.5, DOT_R, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
|
||||||
|
ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
return boxH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached wrapper for drawChordDiagram. When entranceT === 1 (scale
|
||||||
|
// transform is identity) the diagram is rendered once to an
|
||||||
|
// OffscreenCanvas and reused every subsequent frame via drawImage +
|
||||||
|
// globalAlpha. During the 0.2 s entrance animation (entranceT < 1)
|
||||||
|
// the scale transform is non-trivial so we fall through to a fresh
|
||||||
|
// render — that window is ~12 frames at 60 fps, negligible.
|
||||||
|
//
|
||||||
|
// Returns boxH (diagram card height in px) so the draw loop can
|
||||||
|
// accumulate per-corner stack offsets when multiple overlays share
|
||||||
|
// the same corner position.
|
||||||
|
function _drawDiagramCached(ctx, opts) {
|
||||||
|
const { opacity = 1, entranceT = 1.0, canvasW, canvasH } = opts;
|
||||||
|
if (opacity <= 0) return 0;
|
||||||
|
if (entranceT < 1.0) {
|
||||||
|
return drawChordDiagram(ctx, opts) || 0;
|
||||||
|
}
|
||||||
|
const { name, frets, nStr, inverted, sizeSlider, position, lyricsBottom = 0, stackOffset = 0 } = opts;
|
||||||
|
const key = name + '|' + (frets || []).join(',') + '|' + nStr + '|' +
|
||||||
|
(inverted ? 1 : 0) + '|' + sizeSlider + '|' + position + '|' +
|
||||||
|
canvasW + '|' + canvasH + '|' + lyricsBottom + '|' + stackOffset;
|
||||||
|
let entry = diagRenderCache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
let oc;
|
||||||
|
try { oc = new OffscreenCanvas(canvasW, canvasH); }
|
||||||
|
catch (_) { oc = document.createElement('canvas'); oc.width = canvasW; oc.height = canvasH; }
|
||||||
|
const boxH = drawChordDiagram(oc.getContext('2d'), { ...opts, opacity: 1, entranceT: 1 }) || 0;
|
||||||
|
if (diagRenderCache.size >= _DIAG_CACHE_MAX) {
|
||||||
|
diagRenderCache.delete(diagRenderCache.keys().next().value);
|
||||||
|
}
|
||||||
|
entry = { oc, boxH };
|
||||||
|
diagRenderCache.set(key, entry);
|
||||||
|
}
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
ctx.drawImage(entry.oc, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
return entry.boxH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-line section card. Top line is "Now: <current>", bottom line
|
||||||
|
// is "Up Next: <next> in <countdown>". Explicit labels disambiguate
|
||||||
|
// current vs upcoming — earlier single-line variant rendered both
|
||||||
|
// states with the same word and was confusing during playback.
|
||||||
|
//
|
||||||
|
// Returns boxH on draw, 0 when nothing rendered. Position / size
|
||||||
|
// mirror the chord-diagram contract: 'tl' / 'tr' / 'bl' / 'br'
|
||||||
|
// anchor corners, sizeSlider in [0,1] scales card height.
|
||||||
|
//
|
||||||
|
// Hidden when:
|
||||||
|
// - no sections array, or
|
||||||
|
// - playback has not yet reached the first section AND there's
|
||||||
|
// no upcoming-only fallback rendered (we still show "Up Next"
|
||||||
|
// during the pre-roll so the user sees what's coming).
|
||||||
|
function drawSectionHud(ctx, opts) {
|
||||||
|
const {
|
||||||
|
sections, currentTime,
|
||||||
|
canvasW, canvasH,
|
||||||
|
position = 'tr',
|
||||||
|
sizeSlider = 0.5,
|
||||||
|
lyricsBottom = 0,
|
||||||
|
stackOffset = 0,
|
||||||
|
} = opts;
|
||||||
|
if (!sections || !sections.length) return 0;
|
||||||
|
|
||||||
|
// sections are time-ordered server-side; single forward scan.
|
||||||
|
let curIdx = -1;
|
||||||
|
for (let i = 0; i < sections.length; i++) {
|
||||||
|
if (sections[i].time <= currentTime) curIdx = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
const cur = curIdx >= 0 ? sections[curIdx] : null;
|
||||||
|
const next = (curIdx + 1 < sections.length) ? sections[curIdx + 1] : null;
|
||||||
|
// Pre-first-section: nothing playing yet but next is coming —
|
||||||
|
// still useful to render "Up Next" alone so the user gets the
|
||||||
|
// anticipatory cue during the song's intro silence.
|
||||||
|
if (!cur && !next) return 0;
|
||||||
|
|
||||||
|
const nowName = cur ? cur.name : '';
|
||||||
|
// Render countdown as a separate span so it can take a calmer
|
||||||
|
// grey-white treatment while the section name itself stays
|
||||||
|
// cyan. Combining them into one string would inherit the cyan
|
||||||
|
// fill across both, defeating the visual hierarchy promised
|
||||||
|
// in the FR.
|
||||||
|
let nextName = '';
|
||||||
|
let nextCountdown = '';
|
||||||
|
if (next) {
|
||||||
|
const dt = next.time - currentTime;
|
||||||
|
nextName = next.name;
|
||||||
|
nextCountdown = dt > 10
|
||||||
|
? 'in ' + Math.round(dt) + 's'
|
||||||
|
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeF = 0.65 + 0.85 * sizeSlider; // 0.65 .. 1.5
|
||||||
|
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
|
||||||
|
const PAD_X = Math.round(baseH * 0.45);
|
||||||
|
const PAD_Y = Math.round(baseH * 0.20);
|
||||||
|
// Per-text-element scale applied to nameSize / tagSize / lineH
|
||||||
|
// when the unscaled card would overflow a narrow panel
|
||||||
|
// (splitscreen quad layout, ultra-tall portrait). Computed
|
||||||
|
// below from the measured contentW vs the available width.
|
||||||
|
let textScale = 1.0;
|
||||||
|
const baseLineH = Math.round(baseH * 0.46);
|
||||||
|
const baseNameSize = Math.round(baseH * 0.36);
|
||||||
|
const baseTagSize = Math.round(baseH * 0.24);
|
||||||
|
const baseTagGap = Math.round(baseH * 0.14);
|
||||||
|
|
||||||
|
const TAG_NOW = 'Now:';
|
||||||
|
const TAG_NEXT = 'Up Next:';
|
||||||
|
|
||||||
|
// Phase-1 measurement at the unscaled font sizes — used to
|
||||||
|
// decide whether textScale needs to drop, and to lay out the
|
||||||
|
// final draw at whatever scale we land on.
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `${baseTagSize}px sans-serif`;
|
||||||
|
const tagNowWBase = ctx.measureText(TAG_NOW).width;
|
||||||
|
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
|
||||||
|
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
||||||
|
ctx.font = `bold ${baseNameSize}px sans-serif`;
|
||||||
|
const nowNameWBase = nowName ? ctx.measureText(nowName).width : 0;
|
||||||
|
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const lineNowWBase = nowName ? tagNowWBase + baseTagGap + nowNameWBase : 0;
|
||||||
|
const lineNextWBase = nextName
|
||||||
|
? tagNextWBase + baseTagGap + nextNameWBase
|
||||||
|
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
|
||||||
|
: 0;
|
||||||
|
const contentWBase = Math.max(lineNowWBase, lineNextWBase);
|
||||||
|
const numLines = (nowName ? 1 : 0) + (nextName ? 1 : 0);
|
||||||
|
if (numLines === 0) return 0;
|
||||||
|
|
||||||
|
// Target width budget: cap at canvasW - 16 and reserve PAD_X
|
||||||
|
// either side. If contentWBase exceeds the budget, scale the
|
||||||
|
// font proportionally — clamped to 0.55 so labels stay legible
|
||||||
|
// even on extreme split-panel widths.
|
||||||
|
const maxBoxW = Math.max(40, canvasW - 16);
|
||||||
|
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
|
||||||
|
if (contentWBase > availContentW) {
|
||||||
|
textScale = Math.max(0.55, availContentW / contentWBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineH = Math.max(1, Math.round(baseLineH * textScale));
|
||||||
|
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
|
||||||
|
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
|
||||||
|
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
|
||||||
|
|
||||||
|
// Phase-2 re-measurement at the scaled font sizes for the
|
||||||
|
// final layout. measureText doesn't scale linearly with font
|
||||||
|
// size on every glyph, so re-measuring is cheaper than
|
||||||
|
// multiplying the base widths by textScale and risking a
|
||||||
|
// half-pixel overflow.
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `${tagSize}px sans-serif`;
|
||||||
|
const tagNowW = ctx.measureText(TAG_NOW).width;
|
||||||
|
const tagNextW = ctx.measureText(TAG_NEXT).width;
|
||||||
|
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
||||||
|
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||||
|
const nowNameW = nowName ? ctx.measureText(nowName).width : 0;
|
||||||
|
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const lineNowW = nowName ? tagNowW + TAG_GAP + nowNameW : 0;
|
||||||
|
const lineNextW = nextName
|
||||||
|
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
|
||||||
|
: 0;
|
||||||
|
const contentW = Math.max(lineNowW, lineNextW);
|
||||||
|
|
||||||
|
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
|
||||||
|
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
|
||||||
|
|
||||||
|
const E = Math.round(baseH * 0.25);
|
||||||
|
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
|
||||||
|
let bx, by;
|
||||||
|
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
|
||||||
|
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
|
||||||
|
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
|
||||||
|
else { bx = E; by = TOP_Y + stackOffset; }
|
||||||
|
bx = Math.max(0, Math.min(canvasW - boxW, bx));
|
||||||
|
by = Math.max(0, Math.min(canvasH - boxH, by));
|
||||||
|
// Suppress overlap with the wrapped lyrics banner regardless
|
||||||
|
// of corner. Bottom-corner cards on short panels can still
|
||||||
|
// reach up into the banner once boxH exceeds the space below
|
||||||
|
// the lyrics — same shape the chord diagram uses.
|
||||||
|
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
|
||||||
|
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
|
||||||
|
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
|
||||||
|
// Layout each line with tag left-aligned, name in cyan after a
|
||||||
|
// small gap. Both lines share the same x origin (bx + PAD_X)
|
||||||
|
// so the tag column visually aligns vertically.
|
||||||
|
const lineX = bx + PAD_X;
|
||||||
|
let lineY = by + PAD_Y + lineH / 2;
|
||||||
|
const TAG_COLOR = 'rgba(180,190,205,0.85)';
|
||||||
|
const NAME_COLOR = '#00cccc';
|
||||||
|
const TIME_COLOR = 'rgba(220,225,235,0.9)';
|
||||||
|
|
||||||
|
if (nowName) {
|
||||||
|
ctx.font = `${tagSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = TAG_COLOR;
|
||||||
|
ctx.fillText(TAG_NOW, lineX, lineY);
|
||||||
|
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = NAME_COLOR;
|
||||||
|
ctx.fillText(nowName, lineX + tagNowW + TAG_GAP, lineY);
|
||||||
|
lineY += lineH;
|
||||||
|
}
|
||||||
|
if (nextName) {
|
||||||
|
ctx.font = `${tagSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = TAG_COLOR;
|
||||||
|
ctx.fillText(TAG_NEXT, lineX, lineY);
|
||||||
|
const nextX = lineX + tagNextW + TAG_GAP;
|
||||||
|
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = NAME_COLOR;
|
||||||
|
ctx.fillText(nextName, nextX, lineY);
|
||||||
|
if (nextCountdown) {
|
||||||
|
ctx.font = `${tagSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = TIME_COLOR;
|
||||||
|
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
return boxH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tone-change HUD — card showing the active tone and the next upcoming
|
||||||
|
// tone with a countdown. Mirrors drawSectionHud's layout contract
|
||||||
|
// (position, size slider, lyricsBottom) but uses an amber accent colour
|
||||||
|
// so it reads as distinct from the cyan section card.
|
||||||
|
function drawToneHud(ctx, opts) {
|
||||||
|
const {
|
||||||
|
toneChanges, toneBase = '',
|
||||||
|
currentTime,
|
||||||
|
canvasW, canvasH,
|
||||||
|
position = 'tl',
|
||||||
|
sizeSlider = 0.5,
|
||||||
|
lyricsBottom = 0,
|
||||||
|
stackOffset = 0,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
// Resolve active tone: toneBase before all changes, else the most
|
||||||
|
// recent change at or before currentTime.
|
||||||
|
// toneChanges items use { t, name } (not { time, name }) — both
|
||||||
|
// the legacy import path (server.py xml_tone_changes) and the sloppak
|
||||||
|
// path (lib/tones.py sloppak_tone_changes) emit "t" as the key.
|
||||||
|
let curName = toneBase;
|
||||||
|
let nextChange = null;
|
||||||
|
if (toneChanges && toneChanges.length) {
|
||||||
|
for (let i = 0; i < toneChanges.length; i++) {
|
||||||
|
if (toneChanges[i].t <= currentTime) {
|
||||||
|
curName = toneChanges[i].name;
|
||||||
|
} else {
|
||||||
|
nextChange = toneChanges[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!curName && !nextChange) return 0;
|
||||||
|
|
||||||
|
let nextName = '';
|
||||||
|
let nextCountdown = '';
|
||||||
|
if (nextChange) {
|
||||||
|
const dt = nextChange.t - currentTime;
|
||||||
|
nextName = nextChange.name;
|
||||||
|
nextCountdown = dt > 10
|
||||||
|
? 'in ' + Math.round(dt) + 's'
|
||||||
|
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeF = 0.65 + 0.85 * sizeSlider;
|
||||||
|
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
|
||||||
|
const PAD_X = Math.round(baseH * 0.45);
|
||||||
|
const PAD_Y = Math.round(baseH * 0.20);
|
||||||
|
let textScale = 1.0;
|
||||||
|
const baseLineH = Math.round(baseH * 0.46);
|
||||||
|
const baseNameSize = Math.round(baseH * 0.36);
|
||||||
|
const baseTagSize = Math.round(baseH * 0.24);
|
||||||
|
const baseTagGap = Math.round(baseH * 0.14);
|
||||||
|
|
||||||
|
const TAG_CUR = 'Tone:';
|
||||||
|
const TAG_NEXT = 'Next:';
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `${baseTagSize}px sans-serif`;
|
||||||
|
const tagCurWBase = ctx.measureText(TAG_CUR).width;
|
||||||
|
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
|
||||||
|
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
||||||
|
ctx.font = `bold ${baseNameSize}px sans-serif`;
|
||||||
|
const curNameWBase = curName ? ctx.measureText(curName).width : 0;
|
||||||
|
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const lineCurWBase = curName ? tagCurWBase + baseTagGap + curNameWBase : 0;
|
||||||
|
const lineNextWBase = nextName
|
||||||
|
? tagNextWBase + baseTagGap + nextNameWBase
|
||||||
|
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
|
||||||
|
: 0;
|
||||||
|
const contentWBase = Math.max(lineCurWBase, lineNextWBase);
|
||||||
|
const numLines = (curName ? 1 : 0) + (nextName ? 1 : 0);
|
||||||
|
if (numLines === 0) return 0;
|
||||||
|
|
||||||
|
const maxBoxW = Math.max(40, canvasW - 16);
|
||||||
|
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
|
||||||
|
if (contentWBase > availContentW) {
|
||||||
|
textScale = Math.max(0.55, availContentW / contentWBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineH = Math.max(1, Math.round(baseLineH * textScale));
|
||||||
|
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
|
||||||
|
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
|
||||||
|
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `${tagSize}px sans-serif`;
|
||||||
|
const tagCurW = ctx.measureText(TAG_CUR).width;
|
||||||
|
const tagNextW = ctx.measureText(TAG_NEXT).width;
|
||||||
|
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
||||||
|
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||||
|
const curNameW = curName ? ctx.measureText(curName).width : 0;
|
||||||
|
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const lineCurW = curName ? tagCurW + TAG_GAP + curNameW : 0;
|
||||||
|
const lineNextW = nextName
|
||||||
|
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
|
||||||
|
: 0;
|
||||||
|
const contentW = Math.max(lineCurW, lineNextW);
|
||||||
|
|
||||||
|
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
|
||||||
|
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
|
||||||
|
|
||||||
|
const E = Math.round(baseH * 0.25);
|
||||||
|
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
|
||||||
|
let bx, by;
|
||||||
|
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
|
||||||
|
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
|
||||||
|
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
|
||||||
|
else { bx = E; by = TOP_Y + stackOffset; } // 'tl' default
|
||||||
|
bx = Math.max(0, Math.min(canvasW - boxW, bx));
|
||||||
|
by = Math.max(0, Math.min(canvasH - boxH, by));
|
||||||
|
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
|
||||||
|
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
|
||||||
|
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
|
||||||
|
const lineX = bx + PAD_X;
|
||||||
|
let lineY = by + PAD_Y + lineH / 2;
|
||||||
|
const TAG_COLOR = 'rgba(180,190,205,0.85)';
|
||||||
|
const NAME_COLOR = '#ff9a3c'; // amber — distinct from section cyan
|
||||||
|
const TIME_COLOR = 'rgba(220,225,235,0.9)';
|
||||||
|
|
||||||
|
if (curName) {
|
||||||
|
ctx.font = `${tagSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = TAG_COLOR;
|
||||||
|
ctx.fillText(TAG_CUR, lineX, lineY);
|
||||||
|
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = NAME_COLOR;
|
||||||
|
ctx.fillText(curName, lineX + tagCurW + TAG_GAP, lineY);
|
||||||
|
lineY += lineH;
|
||||||
|
}
|
||||||
|
if (nextName) {
|
||||||
|
ctx.font = `${tagSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = TAG_COLOR;
|
||||||
|
ctx.fillText(TAG_NEXT, lineX, lineY);
|
||||||
|
const nextX = lineX + tagNextW + TAG_GAP;
|
||||||
|
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = NAME_COLOR;
|
||||||
|
ctx.fillText(nextName, nextX, lineY);
|
||||||
|
if (nextCountdown) {
|
||||||
|
ctx.font = `${tagSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = TIME_COLOR;
|
||||||
|
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
return boxH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLyrics(lyrics, currentTime, ctx, W, H) {
|
||||||
|
if (!lyrics._lines) {
|
||||||
|
const lines = [];
|
||||||
|
let line = null, word = null;
|
||||||
|
const flushWord = () => { if (word && word.length) line.words.push(word); word = null; };
|
||||||
|
const flushLine = () => { flushWord(); if (line && line.words.length) lines.push(line); line = null; };
|
||||||
|
for (let i = 0; i < lyrics.length; i++) {
|
||||||
|
const l = lyrics[i];
|
||||||
|
const raw = l.w || '';
|
||||||
|
const endsLine = raw.endsWith('+');
|
||||||
|
const continuesWord = raw.endsWith('-');
|
||||||
|
if (line && i > 0 && l.t - (lyrics[i - 1].t + lyrics[i - 1].d) > 4.0) flushLine();
|
||||||
|
if (!line) line = { words: [], start: l.t, end: l.t + l.d };
|
||||||
|
if (!word) word = [];
|
||||||
|
word.push(l);
|
||||||
|
line.end = Math.max(line.end, l.t + l.d);
|
||||||
|
if (!continuesWord) flushWord();
|
||||||
|
if (endsLine) flushLine();
|
||||||
|
}
|
||||||
|
flushLine();
|
||||||
|
lyrics._lines = lines;
|
||||||
|
}
|
||||||
|
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 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;
|
||||||
|
for (const wordSyls of authoredLine.words) {
|
||||||
|
const parts = [];
|
||||||
|
let wordWidth = 0;
|
||||||
|
for (const s of wordSyls) {
|
||||||
|
const text = sylText(s);
|
||||||
|
const w = ctx.measureText(text).width;
|
||||||
|
parts.push({ syl: s, text, width: w });
|
||||||
|
wordWidth += w;
|
||||||
|
}
|
||||||
|
const advance = wordWidth + spaceWidth;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowHeight = fontSize + 6;
|
||||||
|
const totalHeight = rows.length * rowHeight + 10;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||||
|
ctx.beginPath();
|
||||||
|
const bx = W / 2 - bgWidth / 2, by = lineY - 4, br = 8;
|
||||||
|
ctx.moveTo(bx + br, by); ctx.lineTo(bx + bgWidth - br, by);
|
||||||
|
ctx.quadraticCurveTo(bx + bgWidth, by, bx + bgWidth, by + br);
|
||||||
|
ctx.lineTo(bx + bgWidth, by + totalHeight - br);
|
||||||
|
ctx.quadraticCurveTo(bx + bgWidth, by + totalHeight, bx + bgWidth - br, by + totalHeight);
|
||||||
|
ctx.lineTo(bx + br, by + totalHeight);
|
||||||
|
ctx.quadraticCurveTo(bx, by + totalHeight, bx, by + totalHeight - br);
|
||||||
|
ctx.lineTo(bx, by + br);
|
||||||
|
ctx.quadraticCurveTo(bx, by, bx + br, by);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
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;
|
||||||
|
const yPos = lineY + r * rowHeight + 2;
|
||||||
|
for (const w of row) {
|
||||||
|
for (const part of w.parts) {
|
||||||
|
const l = part.syl;
|
||||||
|
const isActive = currentTime >= l.t && currentTime < l.t + l.d;
|
||||||
|
const isPast = currentTime >= l.t + l.d;
|
||||||
|
ctx.fillStyle = isActive ? '#4ae0ff' : isPast ? '#8899aa' : '#556677';
|
||||||
|
ctx.font = `${isActive ? 'bold' : 'normal'} ${fontSize}px sans-serif`;
|
||||||
|
ctx.fillText(part.text, xPos, yPos);
|
||||||
|
xPos += part.width;
|
||||||
|
}
|
||||||
|
xPos += spaceWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Return the actual bottom Y of the rendered background box so callers
|
||||||
|
// (e.g. drawChordDiagram) can avoid overlapping it.
|
||||||
|
return Math.round(by + totalHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawChordDiagram,
|
||||||
|
_drawDiagramCached,
|
||||||
|
drawSectionHud,
|
||||||
|
drawToneHud,
|
||||||
|
drawLyrics,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// Source + behavioural guards for h3d-carve-7: O-section (lyrics + HUD overlay)
|
||||||
|
// extracted to src/overlay.js.
|
||||||
|
//
|
||||||
|
// Class-killers guaranteed:
|
||||||
|
// 1. Module exports createOverlay (source)
|
||||||
|
// 2. createOverlay return set covers all 5 required symbols (source)
|
||||||
|
// 3. Stranded-caller: every returned symbol appears in screen.js destructure (source)
|
||||||
|
// 4. Private-guard: factory-depth-1 privates not bare in screen.js (source)
|
||||||
|
// 5. Moved constants absent from screen.js (source — deletion check)
|
||||||
|
// 6. _diagRenderCache ref-identity: teardown .clear() reaches the same Map passed to
|
||||||
|
// createOverlay (behavioural — mutation: new Map() breaks it → RED)
|
||||||
|
// 7. drawSectionHud returns 0 when no sections (behavioural)
|
||||||
|
// 8. drawLyrics returns a number (behavioural)
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const OVERLAY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'overlay.js');
|
||||||
|
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||||
|
|
||||||
|
function stripComments(s) {
|
||||||
|
return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function src() {
|
||||||
|
return fs.readFileSync(OVERLAY_JS, 'utf8');
|
||||||
|
}
|
||||||
|
function screenSrc() {
|
||||||
|
return fs.readFileSync(SCREEN_JS, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Module exports createOverlay ─────────────────────────────────────────
|
||||||
|
test('overlay.js exports createOverlay', () => {
|
||||||
|
assert.match(src(), /export\s+function\s+createOverlay\s*\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. Return set covers all 5 required symbols ──────────────────────────────
|
||||||
|
test('createOverlay returns all 5 required symbols', () => {
|
||||||
|
const stripped = stripComments(src());
|
||||||
|
const REQUIRED = ['drawChordDiagram', '_drawDiagramCached', 'drawSectionHud', 'drawToneHud', 'drawLyrics'];
|
||||||
|
// Match the factory-level return block (4-space indent inside createOverlay).
|
||||||
|
// Inner function returns like longestConsecutiveRun's are at 8+ spaces.
|
||||||
|
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
|
||||||
|
assert.ok(retMatch, 'factory-level return block must be present');
|
||||||
|
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
for (const sym of REQUIRED) {
|
||||||
|
assert.ok(returned.includes(sym), `return set must include ${sym}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. Stranded-caller: returned ⊆ screen.js destructure ────────────────────
|
||||||
|
test('every createOverlay returned symbol appears in screen.js destructure', () => {
|
||||||
|
// Mutation: remove _drawDiagramCached from screen.js destructure → missing → RED.
|
||||||
|
const stripped = stripComments(src());
|
||||||
|
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
|
||||||
|
assert.ok(retMatch, 'factory-level return block must be present');
|
||||||
|
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const scrRaw = screenSrc();
|
||||||
|
const destrMatch = scrRaw.match(/const\s*\{([^}]+)\}\s*=\s*createOverlay\s*\(/);
|
||||||
|
assert.ok(destrMatch, 'screen.js must have a createOverlay destructure');
|
||||||
|
const destructured = destrMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
for (const sym of returned) {
|
||||||
|
assert.ok(destructured.includes(sym),
|
||||||
|
`returned symbol '${sym}' must appear in screen.js createOverlay destructure`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. Private-guard: factory-depth-1 privates not bare in screen.js ─────────
|
||||||
|
test('factory-private symbols in overlay.js do not appear bare in screen.js', () => {
|
||||||
|
// Mutation: add bare _DIAG_CACHE_MAX to screen.js → violations → RED.
|
||||||
|
const stripped = stripComments(src());
|
||||||
|
|
||||||
|
// Collect returned symbols (factory-level return, 4-space indent).
|
||||||
|
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
|
||||||
|
assert.ok(retMatch, 'factory-level return block must be present');
|
||||||
|
const returned = new Set(
|
||||||
|
retMatch[1].split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Factory-depth-1 const/let: exactly 4-space indent inside createOverlay body.
|
||||||
|
const privateSyms = [];
|
||||||
|
for (const m of stripped.matchAll(/^ {4}(?:const|let)\s+(\w+)/gm)) {
|
||||||
|
const sym = m[1];
|
||||||
|
if (!returned.has(sym)) privateSyms.push(sym);
|
||||||
|
}
|
||||||
|
assert.ok(privateSyms.length > 0, 'factory must have at least one private depth-1 declaration');
|
||||||
|
|
||||||
|
let scr = screenSrc().replace(/^import\s+.*\n/gm, '');
|
||||||
|
scr = stripComments(scr);
|
||||||
|
scr = scr.replace(/const\s*\{[^}]+\}\s*=\s*createOverlay\s*\([^)]*\)\s*;/, '');
|
||||||
|
|
||||||
|
const violations = privateSyms.filter(sym =>
|
||||||
|
new RegExp('\\b' + sym + '\\b').test(scr)
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(violations, [],
|
||||||
|
'screen.js must not reference factory-private overlay.js symbols: ' + violations.join(', '));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 5. Moved constants absent from screen.js ─────────────────────────────────
|
||||||
|
test('DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX, _DIAG_CACHE_MAX absent from screen.js', () => {
|
||||||
|
// Mutation: add const DIAG_SIZE_MIN = 0.08 back to screen.js → RED.
|
||||||
|
const scr = stripComments(screenSrc());
|
||||||
|
for (const sym of ['DIAG_SIZE_MIN', 'DIAG_SIZE_MAX', 'DIAG_CELL_MAX', '_DIAG_CACHE_MAX']) {
|
||||||
|
assert.doesNotMatch(scr, new RegExp('const\\s+' + sym + '\\b'),
|
||||||
|
`const ${sym} must not appear in screen.js (it moved to overlay.js)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 6. Ref-identity class-killer: source-scan ────────────────────────────────
|
||||||
|
test('screen.js passes _diagRenderCache (not a new Map) to createOverlay', () => {
|
||||||
|
// This is the primary ref-severing class-killer god requested.
|
||||||
|
// Mutation: change createOverlay({ diagRenderCache: _diagRenderCache })
|
||||||
|
// to createOverlay({ diagRenderCache: new Map() })
|
||||||
|
// → teardown .clear() on screen.js's _diagRenderCache no longer reaches the
|
||||||
|
// overlay cache → cache leaks → this test goes RED.
|
||||||
|
const scr = stripComments(screenSrc());
|
||||||
|
assert.match(
|
||||||
|
scr,
|
||||||
|
/createOverlay\s*\(\s*\{\s*diagRenderCache\s*:\s*_diagRenderCache\s*\}\s*\)/,
|
||||||
|
'screen.js must pass _diagRenderCache (not a new Map or other value) as diagRenderCache to createOverlay',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 6b–8: Behavioural tests in a vm sandbox ──────────────────────────────────
|
||||||
|
// createOverlay needs a diagRenderCache Map (stable ref). The functions under
|
||||||
|
// test do canvas 2D drawing; we stub ctx with the minimal surface they call.
|
||||||
|
function makeCtx() {
|
||||||
|
return {
|
||||||
|
save() {}, restore() {}, beginPath() {}, fill() {}, stroke() {},
|
||||||
|
moveTo() {}, lineTo() {}, arc() {}, roundRect() {}, closePath() {},
|
||||||
|
fillText() {}, strokeText() {}, quadraticCurveTo() {},
|
||||||
|
fillRect() {}, strokeRect() {}, drawImage() {},
|
||||||
|
measureText(t) { return { width: t.length * 7 }; },
|
||||||
|
fillStyle: '', strokeStyle: '', lineWidth: 1,
|
||||||
|
globalAlpha: 1, font: '', textAlign: '', textBaseline: '',
|
||||||
|
shadowColor: '', shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadModule(diagRenderCache) {
|
||||||
|
const raw = fs.readFileSync(OVERLAY_JS, 'utf8');
|
||||||
|
// Strip the ES module export keyword so the script runs in a vm CommonJS-style.
|
||||||
|
// Use /m flag so ^ matches line starts (file begins with a comment block).
|
||||||
|
const code = raw.replace(/^export\s+function\s+createOverlay/m, 'function createOverlay');
|
||||||
|
const sandbox = {
|
||||||
|
OffscreenCanvas: class { constructor(w, h) { this.width=w; this.height=h; }
|
||||||
|
getContext() { return makeCtx(); } },
|
||||||
|
document: {
|
||||||
|
createElement() {
|
||||||
|
return { width: 0, height: 0, getContext() { return makeCtx(); } };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
console,
|
||||||
|
__exports: {},
|
||||||
|
};
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(code + '\n__exports.createOverlay = createOverlay;', sandbox);
|
||||||
|
return sandbox.__exports.createOverlay({ diagRenderCache });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. _diagRenderCache ref-identity ────────────────────────────────────────
|
||||||
|
test('diagRenderCache passed to createOverlay is the same Map reached by teardown .clear()', () => {
|
||||||
|
// This is the class-killer god requested.
|
||||||
|
//
|
||||||
|
// Mutation: in screen.js, change
|
||||||
|
// createOverlay({ diagRenderCache: _diagRenderCache })
|
||||||
|
// to
|
||||||
|
// createOverlay({ diagRenderCache: new Map() })
|
||||||
|
// → the overlay populates its own Map, but screen.js teardown clears _diagRenderCache
|
||||||
|
// (a different object) → overlay cache leaks → both Map sizes diverge → RED.
|
||||||
|
//
|
||||||
|
// Here we verify the ref is the same Map by populating a sentinel key via
|
||||||
|
// _drawDiagramCached (which writes to diagRenderCache) and then confirming
|
||||||
|
// the original Map reference sees the write.
|
||||||
|
const sharedMap = new Map();
|
||||||
|
const { _drawDiagramCached } = loadModule(sharedMap);
|
||||||
|
|
||||||
|
const ctx = makeCtx();
|
||||||
|
// entranceT < 1 bypasses cache; entranceT = 1.0 triggers the cache write.
|
||||||
|
// Set opacity=0 to short-circuit before the cache write → use opacity=1.
|
||||||
|
_drawDiagramCached(ctx, {
|
||||||
|
name: 'Am', frets: [0, 0, 2, 2, 1, 0], nStr: 6,
|
||||||
|
inverted: false, sizeSlider: 0.5, position: 'tl',
|
||||||
|
canvasW: 600, canvasH: 400, opacity: 1, entranceT: 1.0,
|
||||||
|
lyricsBottom: 0, stackOffset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The overlay must have written to sharedMap (the same ref we passed in).
|
||||||
|
assert.ok(sharedMap.size > 0,
|
||||||
|
'overlay must write to the diagRenderCache Map reference passed in via DI; ' +
|
||||||
|
'if size=0 the ref was severed (createOverlay got a different Map)');
|
||||||
|
|
||||||
|
// Simulating teardown: clear the same Map as screen.js would.
|
||||||
|
sharedMap.clear();
|
||||||
|
assert.equal(sharedMap.size, 0, 'Map cleared by teardown must now be empty');
|
||||||
|
|
||||||
|
// A second call re-populates the shared Map (not a separate internal one).
|
||||||
|
_drawDiagramCached(ctx, {
|
||||||
|
name: 'G', frets: [3, 2, 0, 0, 3, 3], nStr: 6,
|
||||||
|
inverted: false, sizeSlider: 0.5, position: 'tl',
|
||||||
|
canvasW: 600, canvasH: 400, opacity: 1, entranceT: 1.0,
|
||||||
|
lyricsBottom: 0, stackOffset: 0,
|
||||||
|
});
|
||||||
|
assert.ok(sharedMap.size > 0, 'cache repopulated via the same shared Map reference');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 7. drawSectionHud returns 0 for no sections ───────────────────────────────
|
||||||
|
test('drawSectionHud returns 0 when sections array is empty', () => {
|
||||||
|
const { drawSectionHud } = loadModule(new Map());
|
||||||
|
const ctx = makeCtx();
|
||||||
|
const result = drawSectionHud(ctx, {
|
||||||
|
sections: [], currentTime: 10,
|
||||||
|
canvasW: 800, canvasH: 600,
|
||||||
|
});
|
||||||
|
assert.equal(result, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 8. drawLyrics returns a number ───────────────────────────────────────────
|
||||||
|
test('drawLyrics returns a finite number', () => {
|
||||||
|
const { drawLyrics } = loadModule(new Map());
|
||||||
|
const ctx = makeCtx();
|
||||||
|
const lyrics = [
|
||||||
|
{ w: 'Hel-', t: 0, d: 0.3 }, { w: 'lo+', t: 0.3, d: 0.3 },
|
||||||
|
{ w: 'World', t: 0.6, d: 0.4 },
|
||||||
|
];
|
||||||
|
const result = drawLyrics(lyrics, 0.15, ctx, 800, 600);
|
||||||
|
assert.ok(typeof result === 'number' && isFinite(result),
|
||||||
|
'drawLyrics must return a finite number (bottom Y of lyrics banner)');
|
||||||
|
});
|
||||||
@@ -112,6 +112,14 @@ function loadHighway3dStatics() {
|
|||||||
_meshMatForGhostFretDigit() {}, _spriteMat2MeshMat() {},
|
_meshMatForGhostFretDigit() {}, _spriteMat2MeshMat() {},
|
||||||
pool: () => ({ get() {}, reset() {}, warm() { return this; } }),
|
pool: () => ({ get() {}, reset() {}, warm() { return this; } }),
|
||||||
}),
|
}),
|
||||||
|
// h3d-carve-7: overlay (lyrics + HUD) moved to src/overlay.js.
|
||||||
|
createOverlay: () => ({
|
||||||
|
drawChordDiagram() { return 0; },
|
||||||
|
_drawDiagramCached() { return 0; },
|
||||||
|
drawSectionHud() { return 0; },
|
||||||
|
drawToneHud() { return 0; },
|
||||||
|
drawLyrics() { return 0; },
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
|
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
|
||||||
|
|||||||
Reference in New Issue
Block a user