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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ defer the window copy a task and drop it if the element copy arrived

| Event | Bus too? | Payload (additive-only contract) |
|---|---|---|
| `notedetect:hit` / `notedetect:miss` | as `note:hit`/`note:miss` | the full judgment (`{ hit, chord, note, notes, noteTime, timingState, timingError, pitchState, pitchError, ... }`) |
| `notedetect:hit` / `notedetect:miss` | as `note:hit`/`note:miss` | the full judgment (`{ hit, chord, note, notes, noteTime, timingState, timingError, pitchState, pitchError, ... }`), plus from v1.33 `{ key, strings, techniques, latencyMs, avOffsetMs, detector, section, counted, points, multiplier, streak, isDefault }` (field reference in README "Events"; stamped by `_ndEnrichJudgment` at emit) |
| `notedetect:fx` | yes | `{ fxType: 'multiplier', mult, prevMult, streak }` on tier change; `{ fxType: 'milestone', streak, mult }` at 25/50/every 100; `{ fxType: 'streakBreak', lostStreak, prevMult }` after a ≥10 run dies. All carry `{ isDefault, ts }` |
| `notedetect:session` | no | end-of-song summary: `{ title, artist, arrangement, accuracy, hits, misses, bestStreak, score, maxMultiplier, grade, fullCombo, sections, timestamp }` (consumed by the practice journal — never rename/remove keys) |
| `notedetect:skin` | yes | `{ skin }` — re-resolve any cached palette on this |
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ Field reference for the per-note events:
| `hit` | `true` only when timing and pitch are both clean | all events (`false` for misses) |
| `timingState` / `pitchState` | Independent diagnostic axes: `OK`, `EARLY`, `LATE`, `SHARP`, or `FLAT` | clean hits and matched diagnostic misses; `null` for pure misses |
| `timingError` / `pitchError` | Signed timing error in milliseconds and signed pitch error in cents; `pitchError` is octave-folded to the nearest octave (so it is not necessarily equal to `(detectedMidi - expectedMidi) * 100`) | clean hits and matched diagnostic misses; `null` for pure misses |
| `chord` / `notes` | `true` for a chord-level judgment; `notes` lists the chord's `{ s, f }` (`note` is then the lead string) | all events |
| `hitStrings` / `totalStrings` / `score` | How many of the chord's strings rang, out of how many, and the ratio the Chord Leniency setting is compared with | chord judgments where the scorer ran |
| `key` | Stable id of the judged chart note, the same key the highway's `getNoteState` hands back as `popKey`: `"<time>_<string>_<fret>"` for a single note (e.g. `"12.345_2_7"`), `"<time>_chord"` for a chord | all events (v1.33+) |
| `strings` | Per-string verdicts, `[{ s, f, hit, centsError, snr?, detectedAt? }]` (`centsError` rounded cents or `null`; `snr` and `detectedAt` from the desktop engine). For a partial chord this says which strings rang. A missed browser-path chord reports the best frame the scorer saw in its window | chord judgments; single notes on the desktop engine path. `null` when no per-string measurement exists |
| `techniques` | Named techniques of the chart note: `bend`, `slide`, `harmonic`, `pinchHarmonic`, `hammerOn`, `pullOff`, `tap`, `palmMute`, `mute`, `fretHandMute`, `vibrato`, `tremolo`, `accent`, `slap`, `pluck`. `[]` for a plain note. The raw chart note stays in `chartNote` | all chart judgments (v1.33+); `null` for external verdicts |
| `latencyMs` / `avOffsetMs` | The input-latency compensation and A/V offset in effect when the note was judged. `timingError` already has both applied; add `latencyMs` back to get the uncompensated offset | all events (v1.33+) |
| `detector` | Which path judged it: `desktop-engine-verifier`, a desktop detector label, `web-yin` / `web-hps` / `web-crepe`, or `external` | all events (v1.33+) |
| `section` | Name of the chart section the note sits in (by chart time), or `null` | all events (v1.33+) |
| `counted` / `points` / `multiplier` / `streak` | Whether the judgment counted toward the score, the points it earned, and the multiplier and streak after it | all events (v1.33+) |
| `isDefault` | `true` for the main player's detector, `false` for a splitscreen panel's | all events (v1.33+) |

Fields are additive: existing ones keep their meaning, and new ones may appear. Timing class for a scorecard is `timingState` (`OK` / `EARLY` / `LATE`) with `timingError` as the signed offset.

Example — log every hit:

Expand All @@ -133,6 +145,21 @@ window.addEventListener('notedetect:hit', (e) => {
});
```

Example — a per-string tally for partial chords:

```js
const perString = {};
window.addEventListener('notedetect:miss', (e) => {
const j = e.detail;
if (!j.counted || !j.isDefault || !j.chord || !j.strings) return;
for (const st of j.strings) {
const k = `${st.s}`;
perString[k] = perString[k] || { rang: 0, missed: 0 };
perString[k][st.hit ? 'rang' : 'missed']++;
}
});
```

## Pitch Detection Methods

YIN is the default and handles most rigs well. The other methods are opt-in for specific failure modes:
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
130 changes: 125 additions & 5 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 @@ -1295,6 +1295,70 @@ function _ndGradeClean(hit, timingError, pitchError, cleanTimingMs, cleanPitchCe
return { clean: false, looseReason: (tLoose && pLoose) ? 'both' : (tLoose ? 'timing' : 'pitch') };
}

// ── Public judgment-event enrichment (notedetect:hit / notedetect:miss) ──
// Named techniques of a chart note, for consumers of the judgment events.
// Reads the core wire format (`bn` bend amount, `sl`/`slu` slide-to fret
// with -1 = none, boolean flags otherwise). Also accepts the boolean
// `b`/`sl` shape note_detect builds for the scorers. Empty array when the
// note has no technique; null when there's no chart note (external
// verdicts).
function _ndTechniqueNames(n) {
if (!n || typeof n !== 'object') return null;
const out = [];
if ((Number(n.bn) || 0) > 0 || n.b === true) out.push('bend');
if ((typeof n.sl === 'number' && n.sl >= 0) || (typeof n.slu === 'number' && n.slu >= 0)
|| n.sl === true) out.push('slide');
if (n.hm) out.push('harmonic');
if (n.hp) out.push('pinchHarmonic');
if (n.ho) out.push('hammerOn');
if (n.po) out.push('pullOff');
if (n.tp) out.push('tap');
if (n.pm) out.push('palmMute');
if (n.mt) out.push('mute');
if (n.fhm) out.push('fretHandMute');
if (n.vb) out.push('vibrato');
if (n.tr) out.push('tremolo');
if (n.ac) out.push('accent');
if (n.slp) out.push('slap');
if (n.plk) out.push('pluck');
return out;
}

// Per-string verdicts in the public event shape: [{ s, f, hit, centsError,
// snr? , detectedAt? }]. Accepts the chord scorer's per-string results
// (`hit`) and raw engine verdicts (`detected`). Plain objects only, so a
// consumer can keep them after the event.
function _ndStringVerdicts(entries) {
if (!Array.isArray(entries)) return null;
const out = [];
for (const r of entries) {
if (!r || !Number.isFinite(r.s) || !Number.isFinite(r.f)) continue;
const v = {
s: r.s,
f: r.f,
hit: !!(r.hit !== undefined ? r.hit : r.detected),
centsError: Number.isFinite(r.centsError) ? Math.round(r.centsError) : null,
};
if (Number.isFinite(r.snr)) v.snr = r.snr;
if (Number.isFinite(r.detectedAt)) v.detectedAt = r.detectedAt;
out.push(v);
}
return out;
}

// Name of the chart section containing time `t` (sections sorted by
// `time`), or null before the first one / without sections.
function _ndSectionAt(sections, t) {
if (!Array.isArray(sections) || !Number.isFinite(t)) return null;
let name = null;
for (const sec of sections) {
if (!sec || !Number.isFinite(sec.time)) continue;
if (sec.time <= t) name = sec.name != null ? String(sec.name) : null;
else break;
}
return name;
}

function _ndMakeJudgment(opts) {
const o = opts || {};
const matched = !!o.matched;
Expand Down Expand Up @@ -1384,6 +1448,9 @@ function _ndMakeJudgment(opts) {
totalStrings: Number.isFinite(o.totalStrings) ? o.totalStrings : undefined,
score: Number.isFinite(o.score) ? o.score : undefined,
monophonicDetected: o.monophonicDetected,
// Per-string verdicts ({ s, f, hit, centsError, ... }) when the
// scorer measured them — see _ndStringVerdicts. null otherwise.
strings: Array.isArray(o.strings) ? o.strings : null,
};
}

Expand Down Expand Up @@ -5485,6 +5552,7 @@ function createNoteDetector(options = {}) {
totalStrings: extra.totalStrings,
score: extra.score,
monophonicDetected: extra.monophonicDetected,
strings: extra.strings,
lateGraceMs: extra.lateGraceMs,
});
}
Expand Down Expand Up @@ -5619,6 +5687,7 @@ function createNoteDetector(options = {}) {
totalStrings: extra.totalStrings,
score: extra.score,
lateGraceMs: extra.lateGraceMs,
strings: extra.strings,
});
}

Expand Down Expand Up @@ -5927,7 +5996,34 @@ function createNoteDetector(options = {}) {
_scoreLedger.set(key, { t: _ledgerT, hit: !!judgment.hit, chord: !!judgment.chord });
}
}
if (emit) dispatchJudgment(judgment);
if (emit) {
_ndEnrichJudgment(key, judgment, count);
dispatchJudgment(judgment);
}
}

// Additive fields for notedetect:hit / notedetect:miss consumers (stats
// and scorecard plugins) — see README "Events". Stamped once, at emit;
// never changes what the judgment scored.
function _ndEnrichJudgment(key, judgment, counted) {
if (!judgment) return;
try {
judgment.key = key;
judgment.counted = !!counted;
judgment.isDefault = isDefault;
judgment.techniques = _ndTechniqueNames(judgment.chartNote || judgment.note);
if (!Array.isArray(judgment.strings)) judgment.strings = null;
judgment.latencyMs = Math.round(latencyOffset * 1000);
judgment.avOffsetMs = (hw && hw.getAvOffset) ? Math.round(hw.getAvOffset()) : 0;
judgment.detector = judgment._external ? 'external' : _ndVerifierPathLabel();
// The section the NOTE sits in (by chart time), not the
// playhead's: engine verdicts land ~0.4 s late.
judgment.section = _ndSectionAt(hw && hw.getSections ? hw.getSections() : null,
judgment.noteTime) || currentSection || null;
judgment.points = Number.isFinite(judgment._ndPoints) ? judgment._ndPoints : 0;
judgment.multiplier = multiplier;
judgment.streak = streak;
} catch (_) { /* enrichment is best-effort; never block the verdict */ }
}

// Timing-free, chart-aware verify hook. When a consumer (e.g. Step Mode,
Expand Down Expand Up @@ -6451,6 +6547,7 @@ function createNoteDetector(options = {}) {
pitchError: chordPitchError,
monophonicDetected: detectedMidi >= 0,
lateGraceMs: chordLateGraceMs,
strings: _ndStringVerdicts(chordResult.results),
}
);

Expand Down Expand Up @@ -6518,6 +6615,7 @@ function createNoteDetector(options = {}) {
score: useNewSnapshot ? chordResult.score : prev.score,
hitStrings: useNewSnapshot ? chordResult.hitStrings : prev.hitStrings,
totalStrings: useNewSnapshot ? chordResult.totalStrings : prev.totalStrings,
strings: useNewSnapshot ? _ndStringVerdicts(chordResult.results) : prev.strings,
voicingHit: voicingEver,
voicingT,
});
Expand Down Expand Up @@ -6827,6 +6925,7 @@ function createNoteDetector(options = {}) {
// through.
pitchError: null,
lateGraceMs: chordLateGraceMs,
strings: cachedChord.strings || null,
},
)
: makeMissJudgment(liveNotes[0], c.t, t, expectedMidi, {
Expand All @@ -6836,6 +6935,9 @@ function createNoteDetector(options = {}) {
totalStrings: cachedChord ? cachedChord.totalStrings : undefined,
score: cachedChord ? cachedChord.score : undefined,
lateGraceMs: chordLateGraceMs,
// Best frame the scorer saw in the window: which
// strings rang. null when the scorer never ran.
strings: cachedChord ? (cachedChord.strings || null) : null,
});
if (!voicingRescue) {
_ndLogVerifierRejectOnce(chordKey, {
Expand Down Expand Up @@ -14025,6 +14127,10 @@ function createNoteDetector(options = {}) {
cn, cn.t, judgedAt, expectedMidi, detectedMidiForJudgment, 1,
{ pitchError, pitchThresholdCents: lenientPitch ? 600 : undefined }
);
judgment.strings = _ndStringVerdicts([{
s: cn.s, f: cn.f, detected: true, centsError: v.centsError,
snr: v.snr, detectedAt: v.detectedSongTime,
}]);
if (!judgment.hit) {
const teReason = judgment.timingState === 'EARLY' || judgment.timingState === 'LATE'
? 'TIMING_FAIL'
Expand Down Expand Up @@ -14092,7 +14198,12 @@ function createNoteDetector(options = {}) {
engineVerdict: _ndSnapshotEngineVerdict(v),
});
}
recordJudgment(key, makeMissJudgment(cn, cn.t, t, expectedMidi));
const missJudgment = makeMissJudgment(cn, cn.t, t, expectedMidi);
missJudgment.strings = _ndStringVerdicts([{
s: cn.s, f: cn.f, detected: false, centsError: v.centsError,
snr: v.snr, detectedAt: v.detectedSongTime,
}]);
recordJudgment(key, missJudgment);
}
}

Expand Down Expand Up @@ -14138,14 +14249,23 @@ function createNoteDetector(options = {}) {
lead.s, lead.f, currentArrangement, currentStringCount, tuningOffsets, capo
);
const lateGraceMs = Math.min((grp.maxSus || 0) * 1000, 1000);
// Each member's own engine verdict, for the public event (which
// strings of a partial chord rang). A member the engine never
// reported counts as not heard.
const strings = _ndStringVerdicts(grp.memberIds.map((id) => {
const m = _ndVerifierChartById.get(id) || {};
const v = verdictMap.get(id) || {};
return { s: m.s, f: m.f, detected: !!v.detected, centsError: v.centsError,
snr: v.snr, detectedAt: v.detectedSongTime };
}));

const chordIsHit = score >= chordHitRatio && detectedTime !== null;
if (chordIsHit) {
const detMidi = Number.isFinite(bestCents) ? expectedMidi + bestCents / 100 : null;
recordJudgment(chordKey, makeMatchedJudgment(
lead, grp.t, detectedTime, expectedMidi, detMidi, 1,
{ chord: true, notes: grp.memberNotes, hitStrings, totalStrings,
score, pitchError: bestCents, lateGraceMs }
score, pitchError: bestCents, lateGraceMs, strings }
));
} else {
// Match the legacy chord-miss path: judgedAt is the current
Expand All @@ -14167,7 +14287,7 @@ function createNoteDetector(options = {}) {
});
recordJudgment(chordKey, makeMissJudgment(
lead, grp.t, missJudgedAt, expectedMidi,
{ chord: true, notes: grp.memberNotes, hitStrings, totalStrings, score, lateGraceMs }
{ chord: true, notes: grp.memberNotes, hitStrings, totalStrings, score, lateGraceMs, strings }
));
}

Expand Down
Loading