From e5266dbb05c2ccd4450357c8a8b5c3ff9f4dd282 Mon Sep 17 00:00:00 2001 From: mattromano <42412983+mattromano@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:19:34 -0700 Subject: [PATCH] fix(techniques): read bend/slide flags off the wire format Core's highway sends `sl`/`slu` as the slide-to fret (-1 = no slide) and the bend amount as `bn`. note_detect read `!!cn.sl` / `cn.b`, so every plain note looked like a slide (widened pitch window / lenient pitch gate) and real bends were never seen. Read both through new _ndHasSlide/_ndHasBend helpers that accept the wire numbers and the booleans note_detect's own payloads carry, including the chart pushed to the desktop engine. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mattromano <42412983+mattromano@users.noreply.github.com> --- screen.js | 56 ++++++++++------ test/_loader.js | 3 + test/technique_flags.test.js | 122 +++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 test/technique_flags.test.js diff --git a/screen.js b/screen.js index bd9025c..ae69e80 100644 --- a/screen.js +++ b/screen.js @@ -2143,6 +2143,23 @@ function _ndKeysToReopenOnSeek(lastT, t, tolerance, keys) { return out; } +// Technique-flag readers. Core's highway wire format (lib/song.py +// note_to_wire) sends `sl` / `slu` as the slide-to fret with -1 meaning +// "no slide", and the bend amount as `bn` (0 = none). A bare `!!cn.sl` reads +// that -1 as true, so EVERY note looked like a slide and got the widened pitch +// window. These accept both the wire numbers and the booleans note_detect's +// own payloads carry (`{ b: true, sl: true }` — calibration lab, verify +// targets, the engine chart), so they're safe on either shape. +function _ndHasSlide(n) { + if (!n) return false; + const on = (v) => v === true || (typeof v === 'number' && v >= 0); + return on(n.sl) || on(n.slu); +} +function _ndHasBend(n) { + if (!n) return false; + return !!(n.b || (typeof n.bn === 'number' ? n.bn > 0 : n.bn)); +} + // Score a chord by checking each of its constituent notes against their // respective string frequency bands. Returns { score, hitStrings, totalStrings }. // @@ -2150,7 +2167,8 @@ function _ndKeysToReopenOnSeek(lastT, t, tolerance, keys) { // minHitRatio — fraction of strings that must ring for the chord to count as a hit. // // Each `chordNotes` entry may carry abbreviated technique flags from the chart -// note data (`cn.ho`, `cn.po`, `cn.b`, `cn.sl`, `cn.hm`), used to adjust +// note data (`cn.ho`, `cn.po`, bend, slide, `cn.hm` — bend/slide read via +// _ndHasBend/_ndHasSlide so wire-format notes work), used to adjust // per-string thresholds: // - ho/po (hammer-on / pull-off): lower energyThreshold (no fresh pick attack) // - b/sl (bend / slide): widen pitchCheckCents (pitch is in motion) @@ -2179,7 +2197,7 @@ function _ndScoreChord(buffer, sampleRate, chordNotes, arrangement, stringCount, // Hammer-on / pull-off: no pick attack, energy will be lower energyThreshold = 0.015; } - if (cn.b || cn.sl) { + if (_ndHasBend(cn) || _ndHasSlide(cn)) { // Bend / slide: pitch is moving, widen the pitch window cents = Math.max(cents, 100); } @@ -5745,8 +5763,8 @@ function createNoteDetector(options = {}) { function _diagTechFlags(n) { if (!n) return null; const flags = []; - if (n.bn) flags.push('B'); // bend - if (n.sl != null && n.sl >= 0) flags.push('S'); // slide + if (_ndHasBend(n)) flags.push('B'); // bend + if (_ndHasSlide(n)) flags.push('S'); // slide if (n.hm || n.hp) flags.push('H'); // harmonic / pinch if (n.ho) flags.push('h'); // hammer-on if (n.po) flags.push('p'); // pull-off @@ -6192,7 +6210,7 @@ function createNoteDetector(options = {}) { notes: _ndSingleNotes.map(cn => ({ s: cn.s, f: cn.f, ho: !!cn.ho, po: !!cn.po, - b: !!cn.b, sl: !!cn.sl, hm: !!cn.hm, + b: _ndHasBend(cn), sl: _ndHasSlide(cn), hm: !!cn.hm, })), }; const gen = sessionGen; @@ -6332,7 +6350,7 @@ function createNoteDetector(options = {}) { notes: group.map(cn => ({ s: cn.s, f: cn.f, ho: !!cn.ho, po: !!cn.po, - b: !!cn.b, sl: !!cn.sl, hm: !!cn.hm, + b: _ndHasBend(cn), sl: _ndHasSlide(cn), hm: !!cn.hm, })), }; const gen = sessionGen; @@ -10154,7 +10172,7 @@ function createNoteDetector(options = {}) { function _calLabEffectivePitchTol(chartPitch, flags) { let cents = chartPitch; - if (flags && (flags.b || flags.sl)) cents = Math.max(cents, 100); + if (flags && (_ndHasBend(flags) || _ndHasSlide(flags))) cents = Math.max(cents, 100); if (flags && flags.hm) cents = 0; return cents; } @@ -10498,8 +10516,8 @@ function createNoteDetector(options = {}) { f: n.f, ho: !!n.ho, po: !!n.po, - b: !!n.b, - sl: !!n.sl, + b: _ndHasBend(n), + sl: _ndHasSlide(n), hm: !!n.hm, })), }; @@ -10519,7 +10537,7 @@ function createNoteDetector(options = {}) { const r = batch.results[0]; const flags = { ho: !!noteDef.ho, po: !!noteDef.po, - b: !!noteDef.b, sl: !!noteDef.sl, hm: !!noteDef.hm, + b: _ndHasBend(noteDef), sl: _ndHasSlide(noteDef), hm: !!noteDef.hm, }; const tick = _calLabTickFromResult(r, flags); return { @@ -10540,7 +10558,7 @@ function createNoteDetector(options = {}) { const nd = notes[i] || { s: r.s, f: r.f }; const flags = { ho: !!nd.ho, po: !!nd.po, - b: !!nd.b, sl: !!nd.sl, hm: !!nd.hm, + b: _ndHasBend(nd), sl: _ndHasSlide(nd), hm: !!nd.hm, }; return { string: nd.s, fret: nd.f, ..._calLabTickFromResult(r, flags) }; }); @@ -13704,8 +13722,8 @@ function createNoteDetector(options = {}) { mix(Math.round((Number.isFinite(nn.t) ? nn.t : 0) * 1000)); mix(((nn.s | 0) * 100) + (nn.f | 0)); mix(Math.round((Number.isFinite(nn.sus) ? nn.sus : 0) * 1000)); - mix((nn.ho ? 1 : 0) | (nn.po ? 2 : 0) | (nn.b ? 4 : 0) - | (nn.sl ? 8 : 0) | (nn.hm ? 16 : 0) | (nn.mt ? 32 : 0)); + mix((nn.ho ? 1 : 0) | (nn.po ? 2 : 0) | (_ndHasBend(nn) ? 4 : 0) + | (_ndHasSlide(nn) ? 8 : 0) | (nn.hm ? 16 : 0) | (nn.mt ? 32 : 0)); }; for (const nn of notes) if (nn) mixNote(nn); for (const ch of _ndChartChords()) { @@ -13773,7 +13791,7 @@ function createNoteDetector(options = {}) { s: n.s, f: n.f, sus: Number.isFinite(n.sus) ? n.sus : 0, - ho: !!n.ho, po: !!n.po, b: !!n.b, sl: !!n.sl, hm: !!n.hm, + ho: !!n.ho, po: !!n.po, b: _ndHasBend(n), sl: _ndHasSlide(n), hm: !!n.hm, }; notes.push(entry); byId.set(id, { ...n }); @@ -13792,7 +13810,7 @@ function createNoteDetector(options = {}) { s: cn.s, f: cn.f, sus: Number.isFinite(cn.sus) ? cn.sus : 0, - ho: !!cn.ho, po: !!cn.po, b: !!cn.b, sl: !!cn.sl, hm: !!cn.hm, + ho: !!cn.ho, po: !!cn.po, b: _ndHasBend(cn), sl: _ndHasSlide(cn), hm: !!cn.hm, }; notes.push(entry); byId.set(id, { ...cn, t: c.t }); @@ -14020,7 +14038,7 @@ function createNoteDetector(options = {}) { // A bend / slide / harmonic note's pitch deliberately leaves // the chart pitch; widen the pitch gate so the hit rides on // presence + timing, not a moving target. - const lenientPitch = !!(cn.b || cn.sl || cn.hm); + const lenientPitch = _ndHasBend(cn) || _ndHasSlide(cn) || !!cn.hm; const judgment = makeMatchedJudgment( cn, cn.t, judgedAt, expectedMidi, detectedMidiForJudgment, 1, { pitchError, pitchThresholdCents: lenientPitch ? 600 : undefined } @@ -14223,7 +14241,7 @@ function createNoteDetector(options = {}) { // That false-`hit` would then make noteStateFor return // 'miss' for the constituent, undoing the whole point of // stamping it with the chord verdict. - const lenientPitch = !!(mcn.b || mcn.sl || mcn.hm); + const lenientPitch = _ndHasBend(mcn) || _ndHasSlide(mcn) || !!mcn.hm; const mDetMidi = Number.isFinite(bestCents) ? mExpectedMidi + bestCents / 100 : null; mJudgment = makeMatchedJudgment( @@ -14347,7 +14365,7 @@ function createNoteDetector(options = {}) { s: n.s, f: n.f, sus: Number.isFinite(n.sus) ? n.sus : 0, - ho: !!n.ho, po: !!n.po, b: !!n.b, sl: !!n.sl, hm: !!n.hm, + ho: !!n.ho, po: !!n.po, b: _ndHasBend(n), sl: _ndHasSlide(n), hm: !!n.hm, }; entries.push(entry); byId.set(n.id, { ...n }); @@ -18123,7 +18141,7 @@ function createNoteDetector(options = {}) { && Number.isInteger(n.f) && n.f >= 0) .map(n => ({ s: n.s, f: n.f, - ho: !!n.ho, po: !!n.po, b: !!n.b, sl: !!n.sl, hm: !!n.hm, + ho: !!n.ho, po: !!n.po, b: _ndHasBend(n), sl: _ndHasSlide(n), hm: !!n.hm, })); let cleanNotes = clean.length ? clean : null; // A ctx only makes sense alongside a live target. diff --git a/test/_loader.js b/test/_loader.js index 660c9f3..05e255e 100644 --- a/test/_loader.js +++ b/test/_loader.js @@ -310,6 +310,9 @@ function loadDetectionCore({ sandboxBeforeRun } = {}) { }; }, calibrateOffsetMs: sandbox._ndCalibrateOffsetMs, + // Technique-flag readers (wire `sl`/`slu`/`bn` or boolean `b`/`sl`). + hasSlide: sandbox._ndHasSlide, + hasBend: sandbox._ndHasBend, // Game-scoring pure helpers (points / multiplier / grade layer). scoringHealthy: sandbox._ndScoringHealthy, multiplierForStreak: sandbox._ndMultiplierForStreak, diff --git a/test/technique_flags.test.js b/test/technique_flags.test.js new file mode 100644 index 0000000..e6cc04a --- /dev/null +++ b/test/technique_flags.test.js @@ -0,0 +1,122 @@ +// Bend / slide technique flags must be read off core's WIRE format. +// +// Core's highway (lib/song.py note_to_wire) sends `sl` / `slu` as the +// slide-to fret with -1 meaning "no slide", and the bend amount as `bn` +// (0 = none) — there is no `b` key. note_detect used to read `!!cn.sl` / +// `cn.b`, so every plain note (sl: -1) looked like a slide and got the +// widened pitch window, while real bends were never seen at all. +// _ndHasSlide / _ndHasBend read the wire numbers AND the booleans +// note_detect's own payloads carry (calibration lab, verify targets, the +// engine chart), and every consumer goes through them. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { loadDetectionCore } = require('./_loader'); +const { sine } = require('./_signals'); + +const core = loadDetectionCore(); + +// A plain note exactly as the highway WebSocket delivers it. +const WIRE_PLAIN = { t: 1.0, s: 1, f: 0, sus: 0, sl: -1, slu: -1, bn: 0, ho: false, po: false, hm: false }; + +test('hasSlide: wire -1 is "no slide"; a slide-to fret (incl. 0) or unpitched slide is a slide', () => { + assert.equal(core.hasSlide(WIRE_PLAIN), false); + assert.equal(core.hasSlide({ ...WIRE_PLAIN, sl: 7 }), true); + assert.equal(core.hasSlide({ ...WIRE_PLAIN, sl: 0 }), true); + assert.equal(core.hasSlide({ ...WIRE_PLAIN, slu: 3 }), true); + assert.equal(core.hasSlide({ s: 1, f: 0 }), false, 'keys omitted → no slide'); + assert.equal(core.hasSlide(null), false); +}); + +test('hasSlide: note_detect\'s own boolean payloads still read correctly', () => { + assert.equal(core.hasSlide({ sl: true }), true); + assert.equal(core.hasSlide({ sl: false }), false); +}); + +test('hasBend: wire `bn` amount, plus the legacy boolean `b`', () => { + assert.equal(core.hasBend(WIRE_PLAIN), false); + assert.equal(core.hasBend({ ...WIRE_PLAIN, bn: 1 }), true); + assert.equal(core.hasBend({ ...WIRE_PLAIN, bn: 0.5 }), true); + assert.equal(core.hasBend({ b: true }), true); + assert.equal(core.hasBend({ b: false }), false); + assert.equal(core.hasBend(null), false); +}); + +// ── _ndScoreChord: the widened (100¢) bend/slide pitch window ────────────── + +const SR = 48000; +const GUITAR_6 = [0, 0, 0, 0, 0, 0]; +// A2 (string 1, open) = 110 Hz; play it 70¢ sharp — outside a 50¢ window, +// inside the 100¢ bend/slide window. +const SHARP_A2 = sine(110 * Math.pow(2, 70 / 1200), SR, 16384 / SR); +const score = (note) => core.scoreChord(SHARP_A2, SR, [note], 'guitar', 6, GUITAR_6, 0, 50, 0.5); + +test('scoreChord: a plain wire-format note keeps the normal pitch window', () => { + const r = score({ ...WIRE_PLAIN }); + assert.equal(r.hitStrings, 0, + `a 70¢-sharp plain note must miss at 50¢ — sl:-1 is not a slide (centsDiff=${r.results[0].centsDiff})`); +}); + +test('scoreChord: wire-format slides and bends still get the widened window', () => { + assert.equal(score({ ...WIRE_PLAIN, sl: 2 }).hitStrings, 1, 'slide (sl: 2)'); + assert.equal(score({ ...WIRE_PLAIN, bn: 1 }).hitStrings, 1, 'bend (bn: 1) — was never detected before'); + assert.equal(score({ s: 1, f: 0, b: true }).hitStrings, 1, 'legacy boolean b'); +}); + +// ── Desktop engine: the chart pushed to the native verifier ──────────────── +// +// The engine (feedBack-desktop ChordScorer) coerces `b` / `sl` to bool and +// widens its pitch window on either, so the JS must hand it real booleans. + +async function flushPendingAsync(turns = 6) { + for (let i = 0; i < turns; i++) { + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setImmediate(r)); + } +} + +test('engine chart: wire-format flags become correct b / sl booleans', async () => { + const chartNotes = [ + { ...WIRE_PLAIN, t: 1.0 }, + { ...WIRE_PLAIN, t: 2.0, sl: 5 }, + { ...WIRE_PLAIN, t: 3.0, bn: 1 }, + ]; + const chords = [{ t: 4.0, notes: [{ ...WIRE_PLAIN, s: 0, f: 3, t: 4.0 }, { ...WIRE_PLAIN, s: 2, f: 5, t: 4.0, sl: 7 }] }]; + const charts = []; + const audio = { + isAvailable: async () => true, + isAudioRunning: async () => true, + startAudio: async () => {}, + getLevels: async () => ({ inputLevel: 0, inputPeak: 0, outputLevel: 0, outputPeak: 0 }), + getSampleRate: async () => 48000, + getPitchDetection: async () => ({ midiNote: -1, confidence: 0, frequency: -1, cents: 0, noteName: '' }), + setChart: async (chart) => { charts.push(chart); return true; }, + getNoteVerdicts: async () => [], + }; + const { createNoteDetector } = loadDetectionCore({ + sandboxBeforeRun(sb) { + sb.highway.getNotes = () => chartNotes.map((n) => ({ ...n })); + sb.highway.getChords = () => chords; + sb.highway.getTime = () => 0; + sb.highway.getAvOffset = () => 0; + sb.window.feedBackDesktop = { isDesktop: true, platform: 'linux', audio }; + }, + }); + const det = createNoteDetector({ isDefault: false }); + await det.enable(); + await flushPendingAsync(); + + const chart = charts[charts.length - 1]; + assert.ok(chart, 'a chart should have been pushed to the engine'); + const flags = Array.from(chart.notes, (n) => `${n.t}:${n.s}:b=${n.b},sl=${n.sl}`).sort(); + assert.deepEqual(flags, [ + '1:1:b=false,sl=false', + '2:1:b=false,sl=true', + '3:1:b=true,sl=false', + '4:0:b=false,sl=false', + '4:2:b=false,sl=true', + ]); + + det.destroy(); + await flushPendingAsync(); +});