Skip to content

WGSL: octave-brightness + sustain-tail color parity for legacy LED shaders (v0.46/v0.47/v0.48) #280

Description

@ford442

Follow-up from the shader playback audit (PR #268). The WebGL2 hybrid overlay and the
reference shader patternv0.50.wgsl apply octaveBrightness(note): the same pitch class shares
hue across octaves but brightens in higher octaves (0.65 at C-0 → 1.0 at B-9). The older LED
shaders patternv0.46/0.47/0.48 still color by pitch class only, so C-3 and C-5 render
identically and sustain tails don't inherit the octave-scaled tint. Active focus: WGSL
circular/horizontal LED note-color readability for a PUBLIC audience.
This is a VISUAL-ONLY back-port. It must reach exact parity with patternv0.50.wgsl's existing
behavior — same curve, same application points — not introduce a new look.

Design decision (read before implementing)

Keep the brightness math IN-SHADER (WGSL), matching v0.50. Do NOT move it to a CPU-side
color/pack encoding: this project packs the raw note byte ([Note|Instr|VolCmd|VolVal]), not
RGB, so CPU-side baking would require changing the packed format (forbidden here) and would
diverge from v0.50, defeating the parity goal. Do NOT retune the floor/gamma in this issue —
strict parity means copying v0.50's curve verbatim. (A separate follow-up can re-tune the
shared curve across the whole shader family, v0.50 included.)

Approach

  1. Copy the octaveBrightness helper VERBATIM from patternv0.50.wgsl into v0.46/0.47/0.48.
    Do not retype the constants — read v0.50's actual implementation and match it exactly,
    including its NOTE_MAX/note-limit constant name and value. Reference shape:
    fn octaveBrightness(note: u32) -> f32 {
    if (note == 0u || note > NOTE_MAX) { return 1.0; }
    let oct = (note - 1u) / 12u; // 0..9
    return 0.65 + 0.35 * f32(oct) / 9.0;
    }
    If a same-named helper already exists in v0.47/v0.48 under any name, reuse it instead of
    adding a duplicate (compile error on redefinition).
  2. Multiply the helper into the VALUE/brightness of the note color, at every site where a
    PITCHED note color is produced — including the sustain-tail path — BEFORE bloom/ACES.
    • If the shader uses an HSV helper: keep hue/sat, scale value:
      hsv2rgb(vec3(hue, sat, base_v * octaveBrightness(note)))
    • If it produces RGB directly: base_color * octaveBrightness(note)
      Apply pre-tone-map (where v0.50 applies it). Do not apply it after ACES.
  3. Sustain tails: tint = noteColor(last_note) * octaveBrightness(last_note) * SUSTAIN_DIM.
    The tail must use the SUSTAINING note's integer (last_note), dimmed but NOT re-hued. Match
    v0.50's existing SUSTAIN_DIM factor exactly; do not invent a per-shader value.
  4. Run npm run sync:shaders to regenerate public/shaders/ copies, then npm run typecheck
    and npm run build. Commit the regenerated public/shaders/ files.

Hard constraints (gating — must hold)

  • octaveBrightness is GATED strictly behind "this cell is pitched note data". It must NEVER
    scale expression/effect/amber colors (volCmd/effCmd paths) or empty cells. Applying it to a
    non-note column would dim those columns by whatever integer sits in the note slot.
  • Note-Off / note-cut codes must hit the early-return guard (return 1.0) and must NOT render as
    a pitched, octave-scaled LED. Confirm the note-off integer each format uses is > NOTE_MAX (or
    is already handled by the existing note-off branch before color is computed).
  • No change to GPU packing, struct layout, or uniform bindings. This is visual-only.

Acceptance criteria

  • Same pitch class shares hue across octaves in v0.46/0.47/0.48 (only brightness differs).
  • Higher octaves are visibly brighter than lower octaves; output matches v0.50 for the same note.
  • Sustain-tail rows inherit octave-scaled pitch color (dimmed, not re-hued), using last_note.
  • Expression/effect/amber columns and Note-Off cells are UNCHANGED (not dimmed/scaled).
  • No change to packing/uniform structs; npm run typecheck and npm run build green.
  • public/shaders/ copies regenerated via npm run sync:shaders and committed.

Verification

Static (no GPU): add/keep a tiny node check asserting the helper math:

  • note 1 (C-0): oct 0 → 0.650
  • note 13 (C-1): oct 1 → ~0.689
  • note 109 (C-9): oct 9 → 1.000
    Visual: load a module spanning low→high registers (e.g. 4-mat_-_space_debris.mod), select each
    of v0.46/0.47/0.48, and confirm (a) octave brightness gradient on active notes, (b) sustain
    tails carry the dimmed pitch tint, (c) effect-only/amber rows look identical to before.
    Cross-check the same module/notes against v0.50 for parity.

Out of scope (do NOT do here)

  • Raising the 0.65 floor or adding a perceptual gamma curve (separate cross-shader issue).
  • Adding v0.49 or any other shader.
  • Any TypeScript, pack-step, renderer, uniform-layout, or deploy changes.

Open questions to resolve during implementation

  1. Confirm v0.50's exact NOTE_MAX constant name + value (96 vs 119) and copy it; do not guess.
  2. Confirm the sustain-tail path in v0.46/0.47/0.48 already has access to the sustaining note's
    integer (last_note). The DURA/high-precision packing copies the note into tail cells — verify
    that's true for these three shaders. If a tail row does NOT carry last_note, STOP and flag it:
    octave-scaling the tail would otherwise require a packing change, which is out of scope.
  3. Confirm v0.47/v0.48 don't already define a brightness helper under a different name.

Related

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions