Three new docs in docs/ that consolidate the path from audio detection through to the highway, plus small cross-links in CLAUDE.md for discoverability. - realtime-scoring-pipeline.md: end-to-end trace with diagrams and a concrete chord-strum example walked through every layer (engine, IPC, note_detect, highway core, renderer). - note-state-provider.md: full reference for setNoteStateProvider / bundle.getNoteState (slopsmith#254). Return values, alpha ownership, the time_string_fret keying convention, 'hit' vs 'active' sustain semantics, common pitfalls. - visualization-feedback-guide.md: practical walkthrough for a plugin author adding hit feedback to a custom viz. Minimal 2D Canvas example plus a troubleshooting decision tree. The note-state provider contract was previously documented in three places with different angles and assumed cross-reading. These docs consolidate it into one coherent narrative + focused API reference + how-to. The pipeline doc also documents the engine-verifier path (audio:setChart / audio:getNoteVerdicts) which was previously only described in NoteVerifier source comments. Companion PR against byrongamatos/slopsmith-desktop covers the engine-side internals. Signed-off-by: Kris Anderson <topkoa@gmail.com> (cherry picked from commit c8ec371813bf36c686f1884f3b2340f4a94f11a8)
12 KiB
Adding hit feedback to a visualization
A practical guide to making your custom visualization light up when notes are hit, dim when they're missed, and stay glowing while sustained notes are held correctly.
Audience: you're building a visualization plugin (per ../CLAUDE.md § "Visualization plugins") and you want it to react to detected hits the same way the built-in 2D and 3D highways do.
Prerequisites
- You can already get a
setRenderer-style plugin loading. (If not, start with../CLAUDE.md— that contract is the entry point.) - You've skimmed
realtime-scoring-pipeline.md. You don't need to understand the engine internals, just the shape ofbundle.getNoteState. note_detectis enabled in your test setup. Without it, the provider stays unregistered andbundle.getNoteStatereturnsnullfor everything — your viz will work but you won't see feedback.
The whole contract, in 5 lines
draw(bundle) {
for (const n of bundle.notes) {
const st = bundle.getNoteState(n, n.t); // for single notes, pass n.t
// st is null | { state: 'hit' | 'active' | 'miss', alpha: 0..1, color: string|null }
}
for (const ch of bundle.chords) {
for (const cn of ch.notes) {
const st = bundle.getNoteState(cn, ch.t); // for chord constituents, pass CHORD time
// …
}
}
}
That's it. The full semantics — when the provider fires, who owns fade timing, what the keys are — are in note-state-provider.md. This doc is about how to use them.
Minimal 2D Canvas example
A complete setRenderer-style plugin that draws each chart note as a circle, lit up when hit, with a green halo for active sustains and a red wash for misses. ~80 lines, no dependencies. Drop it in plugins/my_viz/screen.js along with a matching plugin.json ("type": "visualization").
(function () {
'use strict';
window.slopsmithViz_my_viz = function () {
let ctx = null;
let W = 0, H = 0;
return {
contextType: '2d',
init(canvas, bundle) {
ctx = canvas.getContext('2d');
W = canvas.width;
H = canvas.height;
},
resize(w, h) {
W = w; H = h;
},
draw(bundle) {
if (!ctx) return;
ctx.clearRect(0, 0, W, H);
const now = bundle.currentTime;
const stringCount = bundle.stringCount || 6;
// Project a chart time onto x. Notes ahead of `now` slide
// in from the right; notes behind `now` slide off left.
const xFor = (t) => W * 0.5 + (t - now) * 100;
const yFor = (s) => H * (0.2 + 0.6 * (s / (stringCount - 1)));
// Single notes.
for (const n of bundle.notes) {
const x = xFor(n.t);
if (x < -50 || x > W + 50) continue; // off-screen cull
const y = yFor(n.s);
const st = bundle.getNoteState(n, n.t);
drawGem(ctx, x, y, n, st);
}
// Chord constituents — pass CHORD time, not constituent time.
for (const ch of bundle.chords) {
const x = xFor(ch.t);
if (x < -50 || x > W + 50) continue;
for (const cn of ch.notes) {
const y = yFor(cn.s);
const st = bundle.getNoteState(cn, ch.t);
drawGem(ctx, x, y, cn, st);
}
}
},
destroy() {
ctx = null;
},
};
};
function drawGem(ctx, x, y, note, st) {
const r = 12;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
if (!st) {
// Dim — no judgment yet (or expired).
ctx.fillStyle = '#445';
ctx.fill();
return;
}
// Lit. `alpha` is provider-owned fade.
const a = st.alpha;
if (st.state === 'hit' || st.state === 'active') {
ctx.fillStyle = st.color || `rgba(120, 220, 140, ${a})`;
ctx.fill();
// Halo grows with brightness — purely cosmetic.
ctx.beginPath();
ctx.arc(x, y, r + 6 * a, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(120, 220, 140, ${a * 0.5})`;
ctx.lineWidth = 2;
ctx.stroke();
} else if (st.state === 'miss') {
ctx.fillStyle = `rgba(220, 90, 90, ${a})`;
ctx.fill();
}
}
})();
Things this example deliberately skips:
- Sustain trails. Add a
ctx.fillRectfromxFor(n.t)toxFor(n.t + n.sus)and switch its colour based on the'active'state. - Inverted / lefty. Check
bundle.invertedandbundle.leftyand mirror your y-axis / x-axis transforms. - Difficulty filter awareness.
bundle.notesis already filtered — you get this for free. - Capo / tuning offsets. Not needed for visualization; the gems just show the chart positions.
Case study — how the 3D highway does it
../plugins/highway_3d/screen.js is the canonical custom renderer. It uses the same bundle.getNoteState(note, chartTime) call, but applied to Three.js materials.
The cliff notes (search the file for getNoteState to find each call site):
- For each visible note, the 3D highway calls
bundle.getNoteState(n, n.t)(orch.tfor chord constituents). - A non-null return switches the gem's outline mesh from
mStr[s](dim, dull string colour) tomGlow[s](bright emissive). The body mesh is swapped the same way. - Sustain trails use the bright material while
'active', the dim one otherwise. - A separate sparkle layer (
drawNotedetectSizzle()on the 2D overlay canvas) queues a few crackling arcs and dots projected through the camera, fading withst.alpha. The 3D highway runs this on its 2D overlay canvas, not in the 3D scene, so the sparkles don't blow out the bloom or get hidden behind world geometry. - Chord-frame tint: when the chord-level entry (
${chord.t}_chord) ishit, the chord-frame box tints green; on miss, it tints red. Per-constituent verdicts still drive the individual gem visuals so a partial-hit chord shows the frame green with individual constituents lit or dim.
Two implementation details worth copying:
- The 3D highway caches
bundle.getNoteStateonce per frame —const getNs = bundle.getNoteState;— and uses the cached ref for the inner loops. The function reference is stable across frames, so this is safe and saves a property read per gem. - It null-guards every consumption (
if (st && st.state === 'hit')) rather than relying onst.stateto be defined.nullis the common case — a single visible chord with 30 unstruck notes ahead of the playhead produces 30 null returns and 0 lit returns.
Troubleshooting
"I see no lit gems at all"
In order of likelihood:
note_detectisn't running. Open DevTools console and run:
If it returnswindow.highway && window.highway.getNoteStateProvider()null, no scorer is registered. Click the "Detect" button in the player controls (or enable it via your test fixture).- Your renderer isn't calling
bundle.getNoteState. Add aconsole.log(st)inside the loop. If you never see anything other thannull, the provider is registered but you might be on a song with no scoring events yet (try strumming and watch the console). - You're passing the wrong
chartTime. Chord constituents needchord.t, notcn.t. Easy to miss if your inner loop reuses a variable namedt. - Your renderer's
draw()isn't firing. Make sure the highway has had areadymessage —bundle.isReadyshould betrue. The factory gatesdraw()calls behind the ready flag.
"Single notes light up but chord notes don't"
You're almost certainly passing cn.t instead of ch.t to getNoteState. The lookup map is keyed by the chord's time. Most chord constituents have the same t as the chord, but the contract still requires the chord time explicitly — and there are chart formats where constituent t values drift slightly.
"Notes light up but never fade — they stay green forever"
The provider — not your renderer — owns fade timing. If you've replaced note_detect with your own provider and it returns a truthy state forever, gems stay lit forever. Return null when the effect should end. See note-state-provider.md § "alpha" for how to encode a fade.
"Sustains don't glow while held"
The provider needs to return 'active' (not 'hit') for as long as the held pitch is detected. Check whether the producer in use (note_detect on desktop) is tracking sustain state — it does, via _susActiveUntil, but only when monophonic pitch detection is enabled and on-pitch within the configured cents tolerance. If your audio is noisy or the cents tolerance is tight, sustains may decay to null between frames.
"My splitscreen panels all light up the same notes at the same time"
Two things might be going on:
- Two panels are scoring the same audio input. Each
note_detectinstance currently shares the engine's single-source detection. See issue #375 for the RFC on per-source detection. Until that lands, the fact that both panels light identically is expected. - Each panel's highway has its own provider slot. If you're building a plugin that replaces the provider, register it on each panel's highway instance, not on a global. Splitscreen creates one highway per panel —
setNoteStateProvideris per-highway.
"My renderer prints TypeError: bundle.getNoteState is not a function"
The bundle field has been part of the contract since slopsmith#254. If bundle.getNoteState is missing, the host slopsmith you're running against predates that issue — you're on an old build. Check the version with cat VERSION in the slopsmith repo; anything ≥ 0.2 should have it. The field is set to a stable reference, never undefined.
"I want to draw something on hit that isn't a gem (a particle burst, a sound, anything)"
Two options:
- Subscribe to events from inside your renderer.
window.slopsmith.on('note:hit', judgment => { … }). The judgment object has the chord/note info, timing/pitch error, etc. Fires once per detection, not per frame. Good for one-shots (sounds, screen shakes, achievement triggers). - Watch for state transitions in your draw loop. Cache the last-seen state per note key in your renderer; when this frame's state changes from
nullto truthy, you know an event just landed. Good for cumulative effects (particle bursts that need to know their start time).
The provider doesn't itself give you an event stream — it gives you a sample-per-frame view of current state. Pick the right tool for your effect.
Where to look next
realtime-scoring-pipeline.md— full data flow, top to bottom.note-state-provider.md— the producer contract (write your own scorer).../CLAUDE.md— the canonicalsetRenderer/ overlay contract.../plugins/highway_3d/screen.js— case-study renderer.note-detect-tuning.md— what to do when detection itself is flaky (your viz works fine but the underlying hits / misses look wrong).