Skip to content
Closed
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
56 changes: 37 additions & 19 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -2143,14 +2143,32 @@ 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 }.
//
// score = hitStrings / totalStrings (0..1)
// 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)
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
})),
};
Expand All @@ -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 {
Expand All @@ -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) };
});
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions test/_loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
122 changes: 122 additions & 0 deletions test/technique_flags.test.js
Original file line number Diff line number Diff line change
@@ -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();
});