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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ git clone https://github.com/got-feedback/feedback-plugin-notedetect.git note_de
5. The note's **gem lights up brightly** on the highway when you hit it cleanly — and a sustained note keeps glowing for as long as you keep playing it on-pitch; a miss is flagged on the note too
6. Running accuracy and streak shown in the HUD

**Capo charts** are read the Rocksmith way: a fretted note's fret counts from the nut (it already includes the capo), and only fret 0 means an open string, which sounds at the capo. A capo-4 E chord is charted `A7 D6 G0 B5`. The expected pitch is `open + fret` for fretted notes and `open + capo` for open ones. A `setVerifyTarget` context that passes `tuning` + `capo` is read the same way.

> **How step 5 renders depends on the active highway renderer.** The plugin publishes a per-note judgment via Slopsmith core's `highway.setNoteStateProvider` hook (slopsmith#254 — honored by the bundled 2D and 3D highways; other renderers can read the same data from the core renderer `bundle`'s `getNoteState(note, chartTime)` method). On the **default 2D highway** the plugin *also* draws its own canvas overlay — the slide-down red ✕ miss markers below the now-line, the EARLY/LATE/SHARP/FLAT diagnostic labels, and the cyan "currently detected" indicator. On a **custom renderer** (e.g. the 3D highway) that canvas overlay is suppressed — the renderer owns the per-note feedback there (the 3D highway shows hit/active glow and a red miss outline + the diagnostic labels itself). The HUD (accuracy/streak), the end-of-song summary, and the optional screen-edge flash are DOM, not canvas, so they show under any renderer. On an **older core** without the hook, the plugin falls back to the 2D-canvas green hit ring / red miss marker overlay near the note regardless of renderer.

## Audio Input Channel Selection
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "slopsmith-plugin-notedetect",
"version": "1.32.0",
"version": "1.33.0",
"private": true,
"description": "Slopsmith note detection plugin — test harness for detection core",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "note_detect",
"name": "Note Detection",
"version": "1.32.0",
"version": "1.33.0",
"private": false,
"script": "screen.js",
"styles": "assets/plugin.css",
Expand Down
38 changes: 34 additions & 4 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ const _ND_AUTO_ENABLE_RETRY_MS = 1500;
// exact build that produced it. The script tag has no `import`/`fetch`
// hook to read package.json at load time, so this is the single
// hand-maintained constant the diagnostic path keys off of.
const _ND_VERSION = '1.32.0';
const _ND_VERSION = '1.33.0';

// Bleed-rescue tuning for the low-bass blind spot. A bass DI's fundamental is
// weaker than its 2nd harmonic and an open string / neighbour often rings the
Expand Down Expand Up @@ -514,7 +514,8 @@ function _ndVerifySigFor(arrangement, stringCount, offsets, capo) {
// • `tuning` / `offsets`: per-string semitone OFFSETS from standard tuning
// (the chart convention, matching the instance's tuningOffsets). `offsets`
// is also accepted so getVerifyContext() output round-trips back in. This
// path honors ctx.capo.
// path honors ctx.capo, with the chart's fret convention (fretted notes
// count from the nut, fret 0 sounds at the capo; see _ndAbsoluteFret).
// Either array is read BY STRING INDEX — a non-finite entry maps to 0 in place,
// never reindexed, so one bad string can't shift the others' pitch mapping.
//
Expand Down Expand Up @@ -607,10 +608,35 @@ function _ndMidiToName(midi) {
return _ND_PITCH_NAMES[pc] + octave;
}

// Capo charts follow the Rocksmith convention: a FRETTED note's fret is
// absolute (counted from the nut, so it already includes the capo) and only
// fret 0 means "open", which with a capo on is the capo fret. A capo-4 E
// chord is charted A7 D6 G0 B5, not A3 D2 G0 B1. Adding the capo to every
// fret put each fretted note `capo` semitones sharp, so capo charts barely
// scored. Returns the fret counted from the nut.
function _ndAbsoluteFret(fret, capo) {
return fret === 0 && capo > 0 ? capo : fret;
}

function _ndMidiFromStringFret(string, fret, arrangement, stringCount, offsets, capo) {
const base = _ndStandardMidiFor(arrangement, stringCount);
const offset = offsets && offsets[string] !== undefined ? offsets[string] : 0;
return base[string] + offset + (capo || 0) + fret;
return base[string] + offset + _ndAbsoluteFret(fret, capo);
}

// The desktop engine maps (string, fret) to pitch as open + capo + fret and
// has no switch for the chart convention above. So hand it capo 0 and
// rewrite open (fret 0) notes to the capo fret: the engine then expects the
// same pitches as _ndMidiFromStringFret. Used by both bridge funnels
// (setChart and scoreChord payloads both carry `capo` + `notes[].f`).
// Returns the payload unchanged when there's no capo.
function _ndEngineCapoPayload(payload) {
if (!payload || !(payload.capo > 0)) return payload;
const capo = payload.capo;
const notes = Array.isArray(payload.notes)
? payload.notes.map(n => (n && n.f === 0 ? { ...n, f: capo } : n))
: payload.notes;
return { ...payload, capo: 0, notes };
}

function _ndClassifyTiming(timingErrorMs, timingThresholdMs, lateGraceMs) {
Expand Down Expand Up @@ -1406,7 +1432,9 @@ function _ndMidiToStringFret(midiNote, arrangement, stringCount, offsets, capo)
if (dist < bestDist) {
bestDist = dist;
bestString = s;
bestFret = fret;
// `fret` counts from the capo here; report it from the nut,
// the way the chart writes it (see _ndAbsoluteFret).
bestFret = fret > 0 && capo > 0 ? fret + capo : fret;
}
}
return { string: bestString, fret: bestFret };
Expand Down Expand Up @@ -3526,6 +3554,7 @@ function createNoteDetector(options = {}) {
}
function _ndBridgeScoreChord(ctx) {
const a = _ndBridgeAudio(); if (!a) return Promise.resolve(null);
ctx = _ndEngineCapoPayload(ctx);
if (sourceId != null && typeof a.scoreSourceChord === 'function')
return a.scoreSourceChord(sourceId, ctx);
if (sourceId == null && typeof a.scoreChord === 'function')
Expand All @@ -3540,6 +3569,7 @@ function createNoteDetector(options = {}) {
}
function _ndBridgeSetChart(chart) {
const a = _ndBridgeAudio(); if (!a) return Promise.resolve(null);
chart = _ndEngineCapoPayload(chart);
if (sourceId != null && typeof a.setSourceChart === 'function')
return a.setSourceChart(sourceId, chart);
if (sourceId == null && typeof a.setChart === 'function')
Expand Down
132 changes: 132 additions & 0 deletions test/capo_expected_pitch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Capo charts: expected pitch follows the Rocksmith fret convention.
//
// A capo chart writes FRETTED notes at their absolute fret (counted from the
// nut) and only fret 0 means "open", which with a capo on sounds at the capo
// fret. A capo-4 E major shape is charted A7 D6 G0 B5: A7 = E3, D6 = G#3,
// G0 (at the capo) = B3, B5 = E4. Adding the capo to every fret expected each
// fretted note 4 semitones sharp (A7 → G#3), so capo charts barely scored.
//
// The desktop engine computes open + capo + fret itself, so the bridge hands
// it capo 0 plus open notes rewritten to the capo fret — the same pitches.

const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadDetectionCore } = require('./_loader');

function loadWithSandbox(before) {
let sb = null;
const core = loadDetectionCore({
sandboxBeforeRun(s) { sb = s; if (before) before(s); },
});
return { core, sb };
}

const G6 = [0, 0, 0, 0, 0, 0];

test('capo 4: fretted notes are absolute, fret 0 sounds at the capo', () => {
const { sb } = loadWithSandbox();
const m = (s, f) => sb._ndMidiFromStringFret(s, f, 'guitar', 6, G6, 4);
// E major at capo 4: A7 D6 G0 B5 → E3 G#3 B3 E4.
assert.equal(m(1, 7), 52, 'A string fret 7 is E3, not G#3');
assert.equal(m(2, 6), 56, 'D string fret 6 is G#3');
assert.equal(m(3, 0), 59, 'open G under capo 4 is B3');
assert.equal(m(4, 5), 64, 'B string fret 5 is E4');
// Open low E under the capo is G#2.
assert.equal(m(0, 0), 44);
});

test('no capo: expected pitch is unchanged (open + fret)', () => {
const { sb } = loadWithSandbox();
const m = (s, f, capo) => sb._ndMidiFromStringFret(s, f, 'guitar', 6, G6, capo);
assert.equal(m(0, 0, 0), 40);
assert.equal(m(1, 7, 0), 52);
assert.equal(m(5, 12, undefined), 76);
});

test('capo + tuning offset: the offset still applies to the absolute fret', () => {
const { sb } = loadWithSandbox();
// Drop D (low string -2), capo 3: low string fret 5 is D2 + 5 = G2.
assert.equal(sb._ndMidiFromStringFret(0, 5, 'guitar', 6, [-2, 0, 0, 0, 0, 0], 3), 43);
// Open low string under the capo: D2 + 3 = F2.
assert.equal(sb._ndMidiFromStringFret(0, 0, 'guitar', 6, [-2, 0, 0, 0, 0, 0], 3), 41);
});

test('capo 4: display fallback reports the fret counted from the nut', () => {
const { sb } = loadWithSandbox();
// E3 (52): first match is the low E string. Capo 4 → absolute fret 12,
// the fret the chart would write for it.
const r = sb._ndMidiToStringFret(52, 'guitar', 6, G6, 4);
assert.equal(r.string, 0);
assert.equal(r.fret, 12);
// A note sounding at the capo maps to fret 0 (open), as charted.
const open = sb._ndMidiToStringFret(44, 'guitar', 6, G6, 4);
assert.equal(open.string, 0);
assert.equal(open.fret, 0);
// And the round trip agrees with the expected-pitch mapping.
assert.equal(sb._ndMidiFromStringFret(r.string, r.fret, 'guitar', 6, G6, 4), 52);
});

test('engine payload: capo 0 plus open notes moved to the capo fret', () => {
const { sb } = loadWithSandbox();
const out = sb._ndEngineCapoPayload({
capo: 4, arrangement: 'guitar', notes: [{ s: 3, f: 0, ho: true }, { s: 1, f: 7 }],
});
assert.equal(out.capo, 0);
assert.deepEqual(out.notes.map(n => ({ s: n.s, f: n.f })), [{ s: 3, f: 4 }, { s: 1, f: 7 }]);
assert.equal(out.notes[0].ho, true, 'technique flags travel with the rewritten note');
// No capo → the very same payload object.
const plain = { capo: 0, notes: [{ s: 0, f: 0 }] };
assert.equal(sb._ndEngineCapoPayload(plain), plain);
});

async function flush(turns = 6) {
for (let i = 0; i < turns; i++) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setImmediate(r));
}
}

test('desktop engine verifier receives capo 0 and absolute frets for a capo chart', async () => {
let pushed = null;
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) => { pushed = chart; return true; },
getNoteVerdicts: async () => [],
};
const intervals = [];
const { core } = loadWithSandbox((sb) => {
sb.setInterval = (cb) => { if (typeof cb === 'function') intervals.push(cb); return intervals.length; };
sb.highway.getSongInfo = () => ({ arrangement: 'Rhythm', tuning: G6, capo: 4 });
sb.highway.getNotes = () => ([
{ t: 1.0, s: 1, f: 7, sus: 0 },
{ t: 1.0, s: 2, f: 6, sus: 0 },
{ t: 1.0, s: 3, f: 0, sus: 0 },
{ t: 1.0, s: 4, f: 5, sus: 0 },
]);
sb.highway.getChords = () => ([]);
sb.highway.getTime = () => 0;
sb.highway.getAvOffset = () => 0;
sb.window.feedBackDesktop = { isDesktop: true, platform: 'linux', audio };
});
const det = core.createNoteDetector({ isDefault: false });
await det.enable();
await flush();
for (const cb of intervals) {
if (pushed) break;
// eslint-disable-next-line no-await-in-loop
await cb();
// eslint-disable-next-line no-await-in-loop
await flush();
}
assert.ok(pushed, 'the chart should reach the engine');
assert.equal(pushed.capo, 0, 'the engine must not add the capo on top of absolute frets');
const frets = Array.from(pushed.notes, n => `${n.s}:${n.f}`).sort();
assert.deepEqual(frets, ['1:7', '2:6', '3:4', '4:5']);
det.destroy();
await flush();
});
7 changes: 6 additions & 1 deletion test/verify-target-context.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,12 @@ test('setVerifyTarget ctx: scoreChord is called with the caller tuning, not the
const req = env.scoreChordRequests[env.scoreChordRequests.length - 1];
assert.equal(req.arrangement, 'bass', 'scoreChord arrangement must come from the ctx');
assert.equal(req.stringCount, 5, 'scoreChord stringCount must come from the ctx');
assert.equal(req.capo, 2, 'scoreChord capo must come from the ctx');
// The ctx capo reaches the engine already applied: capo charts write
// fretted notes from the nut, so the bridge hands the engine capo 0 and
// moves only open notes to the capo fret (see _ndEngineCapoPayload).
// The target here is fretted, so its fret passes through unchanged.
assert.equal(req.capo, 0, 'the ctx capo is folded into the frets for the engine');
assert.deepEqual(Array.from(req.notes, n => n.f), [3], 'a fretted target keeps its fret');
assert.deepEqual([...req.offsets], [-2, 0, 0, 0, 0], 'scoreChord offsets must come from the ctx');
assert.ok(req.harmonicVerify === true && req.bypassMl === true,
'still the harmonic-comb DSP verify path');
Expand Down