Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
chart, settings, or plugin data changes, and no plugin API changes: v3 reuses the same
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).

### Fixed
- **Count-in follows the song's meter and its pickup measure.** The count-in
### Fixed
- **Highways no longer render blank for metadata-only phrase lists (#832).**
When a chart includes `phrases` but none of them contains authored difficulty
`levels`, mastery filtering now falls through to the arrangement's flat notes,
chords, anchors, and handshapes instead of replacing them with empty arrays.
- **Count-in follows the song's meter and its pickup measure.** The count-in
(loop wrap, section practice, and the "Countdown before song" setting) always
clicked exactly four beats, so a 3/4 song was counted in 4/4, and a song
opening with a pickup (anacrusis) had the pickup enter where the downbeat
Expand Down
59 changes: 30 additions & 29 deletions static/highway.js
Original file line number Diff line number Diff line change
Expand Up @@ -581,9 +581,10 @@ function createHighway() {
// signal to fall back to legacy MIDI-encoded drums.
b.drumTab = hwState.drumTab;

// Master-difficulty (feedBack#48)
// Master-difficulty (feedBack#48). This reports authored ladder
// availability; timing-only phrases remain available separately.
b.mastery = hwState._mastery;
b.hasPhraseData = !!(hwState._phrases && hwState._phrases.length > 0);
b.hasPhraseData = _hasAuthoredPhraseLevels();
// When phrase data authored ANY handshape, respect the filtered
// list strictly (even when this difficulty leaves it empty) —
// otherwise low-mastery levels would surface arp hints that
Expand Down Expand Up @@ -1476,22 +1477,23 @@ function createHighway() {
hwState._chordRenderCacheTemplates = null;
}

// Rebuild the mastery-filtered note/chord arrays from _phrases +
// _mastery. Called on `ready` and on every setMastery(). When
// _phrases is null (slider-disabled source), we clear the filtered
// arrays — drawNotes/drawChords fall through to the flat arrays.
//
// Output arrays are pre-sorted by time because phrase iterations
// arrive in chronological order and within each level the notes/
// chords are time-sorted already (PR 1's parser sorts them), so
// concatenation preserves the order. No explicit sort needed.
/** Return whether any phrase contains an authored difficulty level. */
function _hasAuthoredPhraseLevels(phrases = hwState._phrases) {
return Array.isArray(phrases)
&& phrases.some(p => Array.isArray(p?.levels) && p.levels.length > 0);
}

/**
* Rebuild mastery-filtered chart arrays, falling back to the flat chart
* when no authored difficulty ladder exists. Called on `ready` and every
* setMastery(). Phrase and level order already preserves timeline order.
*/
function _rebuildMasteryFilter() {
// Null OR empty → fall through to flat arrays. The server's
// chunked emission invariant means _phrases should never land
// at `[]` in practice (it'd require the `phrases` message to
// fire with zero data), but the defensive guard means a bug
// on the way in wouldn't blank the chart.
if (hwState._phrases === null || hwState._phrases.length === 0) {
// Missing phrases, an empty list, or metadata-only phrases with
// no authored levels all mean there is no difficulty ladder to
// filter. Fall through to the flat arrangement-root arrays.
const hasPhraseLevels = _hasAuthoredPhraseLevels();
if (!hasPhraseLevels) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
hwState._filteredNotes = null;
hwState._filteredChords = null;
hwState._filteredAnchors = null;
Expand All @@ -1512,14 +1514,15 @@ function createHighway() {
// back to the flat arrangement-root list (DLC pattern).
let anyHandShapeInPhrases = false;
for (const p of hwState._phrases) {
const n = p.levels.length;
const levels = Array.isArray(p?.levels) ? p.levels : [];
const n = levels.length;
if (n === 0) continue;
// Map slider fraction to a level index. `n` already equals
// `max_difficulty + 1` for fully-authored phrases, and
// equals the authored-level count otherwise — so indexing
// into p.levels.length is both correct and defensive.
// into the normalized levels length is both correct and defensive.
const idx = Math.min(n - 1, Math.floor(hwState._mastery * n));
const lv = p.levels[idx];
const lv = levels[idx];
for (const x of lv.notes) outNotes.push(x);
for (const x of lv.chords) outChords.push(x);
// Anchors drive the fret zoom / pan. Keeping max-mastery
Expand All @@ -1529,7 +1532,7 @@ function createHighway() {
for (const x of lv.anchors) outAnchors.push(x);
for (const x of (lv.handshapes || [])) outHandShapes.push(x);
if (!anyHandShapeInPhrases) {
for (const level of p.levels) {
for (const level of levels) {
if (level.handshapes && level.handshapes.length > 0) {
anyHandShapeInPhrases = true;
break;
Expand Down Expand Up @@ -1843,12 +1846,9 @@ function createHighway() {
_rebuildMasteryFilter();
},
getMastery() { return hwState._mastery; },
// Align with _rebuildMasteryFilter's own "null OR empty → fall
// through" check. If we returned true for _phrases = [], the
// slider would be enabled (via song:ready's hasPhraseData) but
// dragging it would do nothing (filter stays null). Same
// sentinel, same check, single source of truth.
hasPhraseData() { return !!(hwState._phrases && hwState._phrases.length > 0); },
// Difficulty-ladder availability for mastery controls. Timing-only
// phrase windows remain available through the separate phrase getter.
hasPhraseData() { return _hasAuthoredPhraseLevels(); },
// Lightweight phrase windows for Section Practice — timing only, no note payloads.
getPracticePhrases() {
if (!hwState._phrases || !hwState._phrases.length) return null;
Expand Down Expand Up @@ -2671,8 +2671,9 @@ function createHighway() {
// WS connection — mirrors getBeats()/getSections().
getLyrics() { return hwState.lyrics; },
// Phrase timing windows for plugins — `[{ index, start_time, end_time, max_difficulty }]`.
// Returns null when the current song has no phrase data (GP imports, single-difficulty
// charts). Gate phrase-aware logic with hasPhraseData() first. Read-only; do not mutate.
// Returns null only when no phrase timing data exists. This remains available for
// metadata-only phrases even when hasPhraseData() reports no authored difficulty ladder.
// Read-only; do not mutate.
getPhrases() {
if (!hwState._phrases || !hwState._phrases.length) return null;
return hwState._phrases.map((p, index) => ({
Expand Down
135 changes: 129 additions & 6 deletions tests/js/highway_filtered_notes.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Source-level tests for highway.getFilteredNotes() / getFilteredChords() and
// the hasPhraseData() companion getter. These mirror the pattern used in
// highway_note_state.test.js: the createHighway closure is too heavy for a Node
// sandbox, so tests inspect the source text to lock in correct wiring.
// Focused coverage for highway difficulty filtering. The createHighway closure
// is too large for the Node harness, so wiring stays source-checked while the
// extracted filter and bundle helpers are exercised with real chart state.

const { test } = require('node:test');
const assert = require('node:assert/strict');
Expand All @@ -10,6 +9,85 @@ const path = require('node:path');

const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');

function extractBlock(src, marker) {
const start = src.indexOf(marker);
assert.ok(start >= 0, `${marker} present`);
const open = src.indexOf('{', start);
assert.ok(open >= 0, `${marker} has a body`);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth += 1;
else if (src[i] === '}') {
depth -= 1;
if (depth === 0) return src.slice(start, i + 1);
}
}
assert.fail(`${marker} body is balanced`);
}

function createMasteryHarness() {
const src = fs.readFileSync(highwayJs, 'utf8');
const snippets = [
extractBlock(src, 'function _hasAuthoredPhraseLevels(phrases = hwState._phrases)'),
extractBlock(src, 'function _rebuildMasteryFilter()'),
extractBlock(src, 'function _makeBundle()'),
].join('\n');
const hwState = {
currentTime: 0,
songInfo: { tuning: [0, 0, 0, 0, 0, 0], capo: 0, centOffset: 0 },
ready: false,
_chartAnchorPerfNow: NaN,
_chartLastAdvanceAt: 0,
notes: [{ t: 1, s: 0, f: 3 }],
chords: [{ t: 2, notes: [{ s: 1, f: 5 }] }],
anchors: [{ time: 0, fret: 1, width: 4 }],
handShapes: [{ start_time: 0, end_time: 3 }],
beats: [], sections: [], chordTemplates: [], stringCount: 6,
lyrics: [], lyricsSource: null, toneChanges: [], toneBase: null, drumTab: null,
_mastery: 1,
_phrases: null,
_filteredNotes: [{ sentinel: 'note' }],
_filteredChords: [{ sentinel: 'chord' }],
_filteredAnchors: [{ sentinel: 'anchor' }],
_filteredHandShapes: [{ sentinel: 'handshape' }],
_phrasesHaveHandShapes: true,
_xfNotes: null, _xfChords: null, _xfAnchors: null,
_xfNotesAll: null, _xfChordsAll: null, _xfChordTemplates: null,
_xfStringCount: null, _xfTuning: null, _xfCapo: null,
_xfHandShapes: null, _xfCentOffset: null,
_inverted: false, _lefty: false, showLyrics: true,
_showTeachingMarks: true, _showFingerHints: true,
};
let restages = 0;
const runtime = new Function('hwState', 'onRestage', `
const _bundleReused = {};
const _CHART_MAX_INTERP_MS = 250;
const performance = { now: () => 0 };
const _effectiveRenderScale = () => 1;
const project = () => {};
const boundFretX = () => {};
const bsearch = () => 0;
const bsearchTime = () => 0;
const boundNoteState = () => null;
const _getNoteStateProvider = () => null;
const _restageChartTransform = onRestage;
${snippets}
return {
phrases(data) {
if (hwState._phrases === null) hwState._phrases = [];
for (const phrase of data) hwState._phrases.push(phrase);
},
ready() {
hwState.ready = true;
_rebuildMasteryFilter();
return _makeBundle();
},
hasPhraseData: _hasAuthoredPhraseLevels,
};
`)(hwState, () => { restages += 1; });
return { hwState, runtime, restages: () => restages };
}

test('highway public API exposes getFilteredNotes', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
Expand Down Expand Up @@ -50,7 +128,52 @@ test('highway public API exposes hasPhraseData', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/hasPhraseData\s*\(\s*\)\s*\{[^}]*_phrases/,
'hasPhraseData must reference _phrases',
/hasPhraseData\s*\(\s*\)\s*\{[^}]*_hasAuthoredPhraseLevels\(\)/,
'hasPhraseData must use the authored-level predicate',
);
});

test('metadata-only phrases preserve flat chart views through ready', () => {
const { hwState, runtime, restages } = createMasteryHarness();
runtime.phrases([
{ start_time: 0, end_time: 1, levels: [] },
{ start_time: 1, end_time: 2 },
{ start_time: 2, end_time: 3, levels: null },
]);

const bundle = runtime.ready();

assert.strictEqual(bundle.notes, hwState.notes);
assert.strictEqual(bundle.chords, hwState.chords);
assert.strictEqual(bundle.anchors, hwState.anchors);
assert.strictEqual(bundle.handShapes, hwState.handShapes);
assert.equal(bundle.hasPhraseData, false, 'mastery slider stays disabled');
assert.equal(runtime.hasPhraseData(), false);
assert.equal(restages(), 1, 'ready refreshes the chart transform stage');
});

test('mixed phrase payload safely skips missing levels and filters authored levels', () => {
const { runtime } = createMasteryHarness();
const filteredNote = { t: 4, s: 2, f: 7 };
const filteredChord = { t: 5, notes: [{ s: 0, f: 2 }] };
const filteredAnchor = { time: 4, fret: 5, width: 4 };
const filteredHandShape = { start_time: 4, end_time: 6 };
runtime.phrases([
{ start_time: 0, end_time: 3 },
{ start_time: 3, end_time: 4, levels: null },
{ start_time: 4, end_time: 6, levels: [{
notes: [filteredNote],
chords: [filteredChord],
anchors: [filteredAnchor],
handshapes: [filteredHandShape],
}] },
]);

const bundle = runtime.ready();

assert.deepEqual(bundle.notes, [filteredNote]);
assert.deepEqual(bundle.chords, [filteredChord]);
assert.deepEqual(bundle.anchors, [filteredAnchor]);
assert.deepEqual(bundle.handShapes, [filteredHandShape]);
assert.equal(bundle.hasPhraseData, true);
});