Files
feedBack/docs/visualization-feedback-guide.md
T
Kris AndersonandBret Mogilefsky 1246c290f1 docs: add real-time scoring + hit-feedback pipeline docs
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)
2026-06-17 23:41:43 -07:00

204 lines
12 KiB
Markdown

# 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`](../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`](../CLAUDE.md) — that contract is the entry point.)
- You've skimmed [`realtime-scoring-pipeline.md`](realtime-scoring-pipeline.md). You don't need to understand the engine internals, just the shape of `bundle.getNoteState`.
- `note_detect` is enabled in your test setup. Without it, the provider stays unregistered and `bundle.getNoteState` returns `null` for everything — your viz will work but you won't see feedback.
## The whole contract, in 5 lines
```js
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`](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"`).
```js
(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.fillRect` from `xFor(n.t)` to `xFor(n.t + n.sus)` and switch its colour based on the `'active'` state.
- **Inverted / lefty.** Check `bundle.inverted` and `bundle.lefty` and mirror your y-axis / x-axis transforms.
- **Difficulty filter awareness.** `bundle.notes` is 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`](../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)` (or `ch.t` for chord constituents).
- A non-null return switches the gem's outline mesh from `mStr[s]` (dim, dull string colour) to `mGlow[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 with `st.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`) is `hit`, 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:
1. The 3D highway caches `bundle.getNoteState` once 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.
2. It null-guards every consumption (`if (st && st.state === 'hit')`) rather than relying on `st.state` to be defined. `null` is 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:
1. **`note_detect` isn't running.** Open DevTools console and run:
```js
window.highway && window.highway.getNoteStateProvider()
```
If it returns `null`, no scorer is registered. Click the "Detect" button in the player controls (or enable it via your test fixture).
2. **Your renderer isn't calling `bundle.getNoteState`.** Add a `console.log(st)` inside the loop. If you never see anything other than `null`, the provider is registered but you might be on a song with no scoring events yet (try strumming and watch the console).
3. **You're passing the wrong `chartTime`.** Chord constituents need `chord.t`, not `cn.t`. Easy to miss if your inner loop reuses a variable named `t`.
4. **Your renderer's `draw()` isn't firing.** Make sure the highway has had a `ready` message — `bundle.isReady` should be `true`. The factory gates `draw()` 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`](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:
1. **Two panels are scoring the same audio input.** Each `note_detect` instance currently shares the engine's single-source detection. See [issue #375](https://github.com/byrongamatos/slopsmith/issues/375) for the RFC on per-source detection. Until that lands, the fact that both panels light identically is expected.
2. **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 — `setNoteStateProvider` is 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:
1. **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).
2. **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 `null` to 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`](realtime-scoring-pipeline.md) — full data flow, top to bottom.
- [`note-state-provider.md`](note-state-provider.md) — the producer contract (write your own scorer).
- [`../CLAUDE.md`](../CLAUDE.md) — the canonical `setRenderer` / overlay contract.
- [`../plugins/highway_3d/screen.js`](../plugins/highway_3d/screen.js) — case-study renderer.
- [`note-detect-tuning.md`](note-detect-tuning.md) — what to do when detection itself is flaky (your viz works fine but the underlying hits / misses look wrong).