diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 99a23b11..312b84d6 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -2534,6 +2534,9 @@ let _drawRecentByString = null; /** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */ let _drawChordTemplates = null; + /** Teaching marks sd/ch overlay pref (§6.2.2), mirrored from the 2D + * highway's `teachingMarksVisible` bundle flag. fg renders regardless. */ + let _drawTeachingMarks = false; let _laneTargetColor = null; let _renderScale = 1; let lyricsCanvas = null, lyricsCtx = null; @@ -3017,6 +3020,7 @@ let gPMXLines = null, pMuteXLines = null; // PM X lines combined geometry (8 segs as quads) let gFHXLines = null, pFHXLines = null; // FH X lines combined geometry let pNoteFretLabel, pConnectorLine, pDropLine, pTapChevron, pAccentHalo; + let pTeachMarkLbl; // teaching marks fg/sd label sprites (§6.2.2) let pHaloBar = null, gHaloBar = null; // gradient halo bar geometry — replaces per-shell pChordAccentHalo let gArpBracket = null; // shared 1×1×1 box geometry for pArpBracket; built once, disposed in teardown let pSusRibbon = null, pSusRibbonOl = null; @@ -6095,6 +6099,14 @@ _nfl.material.depthTest = false; return _nfl; }); + // Teaching marks fg/sd labels (§6.2.2). One pool, two get()s per note + // (finger + degree); the texture is swapped per draw via material.map. + pTeachMarkLbl = pool(lblG, () => { + const _tml = new T.Sprite(txtMat('0', '#7fd1ff', false, 'teachMark').clone()); + _tml.material.fog = false; + _tml.material.depthTest = false; + return _tml; + }); pConnectorLine = pool(noteG, () => new T.Line( new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]), new T.LineBasicMaterial({ color: 0xaaaaaa, transparent: true, opacity: 0.5, depthTest: false }), @@ -6153,6 +6165,7 @@ pSusRailBloom.warm(_WARM_CHORD); pTechPlane.warm(_WARM_CHORD); pNoteFretLabel.warm(_WARM_NOTE); + pTeachMarkLbl.warm(_WARM_NOTE); pChordFrameFill.warm(_WARM_CHORD); pChordBox.warm(_WARM_CHORD); pChordLbl.warm(_WARM_CHORD); @@ -8353,6 +8366,7 @@ if (pMuteXLines) pMuteXLines.reset(); if (pFHXLines) pFHXLines.reset(); pNoteFretLabel.reset(); pConnectorLine.reset(); pDropLine.reset(); + pTeachMarkLbl.reset(); pFretColMarker.reset(); pSusRail.reset(); pSusRailBloom.reset(); pTechPlane.reset(); // Clear per-frame queues in-place (avoid reallocating the array object). _ndLabels.length = 0; @@ -8816,6 +8830,7 @@ _drawNextByString = nextNoteByString; _drawChordTemplates = bundle.chordTemplates ?? null; + _drawTeachingMarks = !!bundle.teachingMarksVisible; // ── Recent-past event per string (for _nextAnyT deadline) ───── // Once a note/chord passes `now` it leaves _drawNextByString, @@ -9830,6 +9845,12 @@ // apply the wrong contour). Reset explicitly. _scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined; _scrChordNote.bt = cn.bt || 0; + // Same stale-scratch hazard for the teaching marks + // (§6.2.2): fg/sd are omit-when-default on the wire, + // so a chord note without them must reset to -1 or it + // inherits the previous note's finger/degree label. + _scrChordNote.fg = Number.isInteger(cn.fg) ? cn.fg : -1; + _scrChordNote.sd = Number.isInteger(cn.sd) ? cn.sd : -1; drawNote( _scrChordNote, now, @@ -11316,6 +11337,22 @@ return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1; } + // Teaching marks (§6.2.2) — display only, never grading. Pure label + // helpers, mirroring static/highway.js so the two highways agree; + // node-tested via tests/js/highway_teaching_marks.test.js. + function teachingFingerLabel(fg) { + // fret-hand finger: '' when unset/out of range; 0 -> 'T' (thumb), + // 1..4 -> '1'..'4'. + if (!Number.isInteger(fg) || fg < 0 || fg > 4) return ''; + return fg === 0 ? 'T' : String(fg); + } + function teachingDegreeLabel(sd) { + // scale degree: chromatic 0..11 above the active key tonic; '' when + // unset/out of range. + if (!Number.isInteger(sd) || sd < 0 || sd > 11) return ''; + return String(sd); + } + function bnvSampleAt(bnv, t) { // Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is // seconds from the note onset) at elapsed time t. Clamps to the @@ -12260,6 +12297,32 @@ fretLabel.scale.set(flS, flS, 1); fretLabel.material.opacity = alpha; } + + // Teaching marks (§6.2.2) — display only, never grading. The + // fret-hand finger (fg) renders by default to the right of the + // fret label; the scale degree (sd) is opt-in (mirrors the 2D + // `teachingMarksVisible` toggle) and renders to the left. + if (alpha > 0 && n.f > 0) { + const _tmS = 5.0 * K * _textSizeMul * fretLabelScaleForFret(n.f); + const _drawTeachMark = (text, colorHex, dx, cacheKey) => { + if (!text) return; + const spr = pTeachMarkLbl.get(); + const m = txtMat(text, colorHex, false, cacheKey); + if (spr.material.map !== m.map) { + spr.material.map = m.map; + spr.material.needsUpdate = true; + } + spr.position.set(x + dx, labelY, noteZ); + spr.renderOrder = renderOrderForLayerAtZ(noteZ, + _isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL'); + spr.scale.set(_tmS, _tmS, 1); + spr.material.opacity = alpha; + }; + _drawTeachMark(teachingFingerLabel(n.fg), '#7fd1ff', NW * 0.95, 'teachFg'); + if (_drawTeachingMarks) { + _drawTeachMark(teachingDegreeLabel(n.sd), '#ffcc66', -NW * 0.95, 'teachSd'); + } + } } } @@ -12973,7 +13036,7 @@ _renderScale = 1; mBeatM = mBeatQ = null; pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null; - pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = null; + pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = pTeachMarkLbl = null; if (gPMXFill) { gPMXFill.dispose(); gPMXFill = null; } if (gFHXFill) { gFHXFill.dispose(); gFHXFill = null; } if (gPMXLines) { gPMXLines.dispose(); gPMXLines = null; } diff --git a/static/highway.js b/static/highway.js index fd8871a1..1feb9d15 100644 --- a/static/highway.js +++ b/static/highway.js @@ -205,6 +205,11 @@ function createHighway() { // have any. let _phrasesHaveHandShapes = false; let showLyrics = localStorage.getItem('showLyrics') !== 'false'; + // Teaching marks (§6.2.2): the fret-hand finger numeral (fg) renders by + // default (small, on the gem), but the scale-degree (sd) + strum-group (ch) + // overlays are opt-in so the default highway stays uncluttered. Display only + // — never used for grading. + let _showTeachingMarks = localStorage.getItem('showTeachingMarks') === 'true'; let _drawHooks = []; // plugin draw callbacks: fn(ctx, W, H) // slopsmith#254 — per-note judgment overlay. A plugin (note_detect) // registers fn(note, chartTime) -> 'hit' | 'active' | 'miss' | null @@ -485,6 +490,37 @@ function createHighway() { return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v })); } + /** Teaching mark (§6.2.2): fret-hand-finger label for a note's `fg`. + * '' when unset/out of range; 0 → 'T' (thumb), 1..4 → '1'..'4'. Pure. */ + function teachingFingerLabel(fg) { + if (!Number.isInteger(fg) || fg < 0 || fg > 4) return ''; + return fg === 0 ? 'T' : String(fg); + } + + /** Teaching mark (§6.2.2): scale-degree label for a note's `sd` (chromatic + * 0..11 above the active key tonic). '' when unset/out of range. Pure. */ + function teachingDegreeLabel(sd) { + if (!Number.isInteger(sd) || sd < 0 || sd > 11) return ''; + return String(sd); + } + + /** Teaching mark (§6.2.2): bucket drawn notes by their strum-group key `ch`. + * Returns the groups (in first-seen order) for each ch value >= 0 that has + * at least two members — a lone note is not a strum gesture. Pure; drives + * the strum-bracket overlay and is node-tested. */ + function strumGroupBuckets(items) { + if (!Array.isArray(items)) return []; + const order = []; + const byKey = new Map(); + for (const it of items) { + const ch = it && Number.isInteger(it.ch) ? it.ch : -1; + if (ch < 0) continue; + if (!byKey.has(ch)) { byKey.set(ch, []); order.push(ch); } + byKey.get(ch).push(it); + } + return order.map(k => byKey.get(k)).filter(g => g.length >= 2); + } + /** Call while lefty mirror transform is active; keeps glyphs readable. */ function fillTextReadable(text, x, y) { // ctx may be null when the 2D context was never acquired @@ -737,6 +773,9 @@ function createHighway() { lefty: _lefty, renderScale: _effectiveRenderScale(), lyricsVisible: showLyrics, + // Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers + // (e.g. the 3D highway) can mirror the 2D opt-in toggle. + teachingMarksVisible: _showTeachingMarks, // 2D-style helpers (renderers that don't need these can ignore). // `fillTextUnmirrored` is deliberately NOT exposed here — @@ -1687,6 +1726,29 @@ function createHighway() { if (sz < 14) return; // Skip small technique labels + // Teaching marks (§6.2.2) — display only, never grading. The fret-hand + // finger (fg) renders by default as a small numeral hugging the gem's + // right edge (T = thumb, 1..4); the scale degree (sd) is opt-in and sits + // on the left edge so the two never collide with the centred fret number. + const fgLabel = teachingFingerLabel(opts?.fg); + if (fgLabel) { + ctx.fillStyle = '#7fd1ff'; + ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + fillTextReadable(fgLabel, x + half + 2, y + half * 0.5); + } + if (_showTeachingMarks) { + const sdLabel = teachingDegreeLabel(opts?.sd); + if (sdLabel) { + ctx.fillStyle = '#ffcc66'; + ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + fillTextReadable(sdLabel, x - half - 2, y + half * 0.5); + } + } + // Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to // the target fret; unpitched (slu) draws a dashed diagonal with no // arrowhead (no definite target pitch). The two are mutually exclusive @@ -1879,11 +1941,56 @@ function createHighway() { const x = fretX(n.f, p.scale, W); drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, _noteStateProvider ? _noteState(n, n.t) : null); - drawnNotes.push({ t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale }); + drawnNotes.push({ + t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale, + ch: Number.isInteger(n.ch) ? n.ch : -1, + pkd: Number.isInteger(n.pkd) ? n.pkd : -1, + }); } // Draw unison bend connectors drawUnisonBends(W, H, drawnNotes); + // Strum-group brackets (teaching mark ch, §6.2.2) — opt-in overlay. + // Scoped to standalone notes (the stream drawNotes renders); chord-note + // strum groups aren't bracketed (the editor authors ch over single-note + // selections, and chord notes already read as one simultaneous gesture). + if (_showTeachingMarks) drawStrumGroups(W, H, drawnNotes); + } + + function drawStrumGroups(W, H, drawnNotes) { + // Teaching mark (§6.2.2): notes sharing a `ch` key >= 0 are one + // strum/rake gesture. Connect each group's gems with a bracket and a + // single arrowhead whose direction comes from `pkd` (0 = down-strum, + // 1 = up-strum). Display only — never grading. + for (const group of strumGroupBuckets(drawnNotes)) { + const pts = group.slice().sort((a, b) => a.y - b.y || a.x - b.x); + const scale = pts[0].scale; + const sz = Math.max(12, 80 * scale * (H / 900)); + if (sz < 14) continue; + const pkd = (group.find(p => p.pkd === 0 || p.pkd === 1) || {}).pkd; + + ctx.save(); + ctx.strokeStyle = '#c89bff'; + ctx.lineWidth = Math.max(2, sz / 12); + ctx.lineJoin = 'round'; + ctx.beginPath(); + pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y))); + ctx.stroke(); + // Arrowhead at the gesture start: down-strum (pkd 0) points toward + // the last gem, up-strum (pkd 1) toward the first. + if (pkd === 0 || pkd === 1) { + const head = pkd === 1 ? pts[0] : pts[pts.length - 1]; + const from = pkd === 1 ? pts[1] : pts[pts.length - 2]; + const dy = Math.sign(head.y - from.y) || 1; + const a = sz * 0.18; + ctx.beginPath(); + ctx.moveTo(head.x - a, head.y - dy * a); + ctx.lineTo(head.x, head.y); + ctx.lineTo(head.x + a, head.y - dy * a); + ctx.stroke(); + } + ctx.restore(); + } } function drawUnisonBends(W, H, drawnNotes) { @@ -3675,6 +3782,18 @@ function createHighway() { }, setOnLyricsChange(fn) { _onLyricsChange = fn; }, + // Teaching marks (§6.2.2): toggle the opt-in sd/ch overlays. The fg + // numeral is unaffected (always on). Persisted to localStorage. + getTeachingMarksVisible() { return _showTeachingMarks; }, + toggleTeachingMarks() { + _showTeachingMarks = !_showTeachingMarks; + localStorage.setItem('showTeachingMarks', String(_showTeachingMarks)); + }, + setTeachingMarksVisible(v) { + _showTeachingMarks = !!v; + localStorage.setItem('showTeachingMarks', String(_showTeachingMarks)); + }, + reconnect(filename, arrangement) { // Close old WS but keep audio + animation running if (ws) { ws.close(); ws = null; } diff --git a/tests/js/highway_teaching_marks.test.js b/tests/js/highway_teaching_marks.test.js new file mode 100644 index 00000000..6e729ef3 --- /dev/null +++ b/tests/js/highway_teaching_marks.test.js @@ -0,0 +1,92 @@ +// Behavioural tests for the teaching-marks (§6.2.2) render helpers: +// teachingFingerLabel / teachingDegreeLabel (both highways) and +// strumGroupBuckets (2D, drives the strum bracket). All pure, so we extract +// the function source by brace-matching and eval it in isolation — same +// pattern as highway_bend_curve.test.js. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +function extractFn(src, name) { + const start = src.indexOf('function ' + name); + assert.ok(start >= 0, `function ${name} must exist`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error(`unbalanced braces extracting ${name}`); +} + +function loadFn(file, name) { + const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8'); + return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)(); +} + +const fingerLabel2D = loadFn('static/highway.js', 'teachingFingerLabel'); +const degreeLabel2D = loadFn('static/highway.js', 'teachingDegreeLabel'); +const fingerLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingFingerLabel'); +const degreeLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingDegreeLabel'); +const strumGroupBuckets = loadFn('static/highway.js', 'strumGroupBuckets'); + +// ── teachingFingerLabel (fg) ───────────────────────────────────────────────── + +for (const [name, fn] of [['2D', fingerLabel2D], ['3D', fingerLabel3D]]) { + test(`teachingFingerLabel (${name}) maps 0->T, 1..4->digit, else ''`, () => { + assert.equal(fn(0), 'T'); // thumb + assert.equal(fn(1), '1'); + assert.equal(fn(4), '4'); // pinky + assert.equal(fn(-1), ''); // unset + assert.equal(fn(5), ''); // out of range + assert.equal(fn(1.5), ''); // non-integer + assert.equal(fn(undefined), ''); + assert.equal(fn(null), ''); + }); +} + +// ── teachingDegreeLabel (sd) ───────────────────────────────────────────────── + +for (const [name, fn] of [['2D', degreeLabel2D], ['3D', degreeLabel3D]]) { + test(`teachingDegreeLabel (${name}) shows 0..11, else ''`, () => { + assert.equal(fn(0), '0'); // tonic + assert.equal(fn(7), '7'); // fifth + assert.equal(fn(11), '11'); + assert.equal(fn(-1), ''); // unset + assert.equal(fn(12), ''); // out of range + assert.equal(fn(3.2), ''); // non-integer + assert.equal(fn(undefined), ''); + }); +} + +// ── strumGroupBuckets (ch) ─────────────────────────────────────────────────── + +test('strumGroupBuckets groups notes sharing a ch >= 0, dropping lone notes', () => { + const items = [ + { id: 'a', ch: 5 }, + { id: 'b', ch: -1 }, // ungrouped + { id: 'c', ch: 5 }, + { id: 'd', ch: 7 }, // lone group (only one member) -> dropped + { id: 'e', ch: 5 }, + ]; + const groups = strumGroupBuckets(items); + assert.equal(groups.length, 1); + assert.deepEqual(groups[0].map(n => n.id), ['a', 'c', 'e']); +}); + +test('strumGroupBuckets preserves first-seen group order and handles multiple groups', () => { + const items = [ + { id: 'a', ch: 2 }, { id: 'b', ch: 9 }, + { id: 'c', ch: 2 }, { id: 'd', ch: 9 }, + ]; + const groups = strumGroupBuckets(items); + assert.deepEqual(groups.map(g => g.map(n => n.id)), [['a', 'c'], ['b', 'd']]); +}); + +test('strumGroupBuckets ignores non-integer / negative ch and bad input', () => { + assert.deepEqual(strumGroupBuckets([{ ch: -1 }, { ch: 1.5 }, { ch: null }, {}]), []); + assert.deepEqual(strumGroupBuckets([]), []); + assert.deepEqual(strumGroupBuckets(null), []); +});