Skip to content

FUG-110: FX blending — dual FX decks + crossfade, Show Mode, device surface - #82

Open
issuefleet[bot] wants to merge 7 commits into
mainfrom
agent/fug-110-feature-fx-blending
Open

FUG-110: FX blending — dual FX decks + crossfade, Show Mode, device surface#82
issuefleet[bot] wants to merge 7 commits into
mainfrom
agent/fug-110-feature-fx-blending

Conversation

@issuefleet

@issuefleet issuefleet Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

FUG-110: FX blending

Run two FX programs at once and crossfade between them, with a Show-Mode workspace to drive it live.

Protocol (shared/protocol/proto/ledmapper.proto)

  • SubmitEffect/SetEffect/SetUniforms/GetEffectUniforms gain a uint32 deck (0 = A default/legacy, 1 = B); EffectUniforms echoes it.
  • New SetCrossfade{double position, uint32 mode} global (arm 32), echoed in Welcome (crossfade + crossfade_mode), handled in the session core like set_brightness.
  • deck/mode are uint32 (not proto enums) to match SetTexture.format and avoid the enum-JSON-name pitfall the proto header warns about. TS bindings regenerated; firmware micropb types regen at build (all new fields scalar — no capacity-table changes).

Firmware

  • Two concurrent decks in firmware/player_app/ffi.rs: per-deck state is now a [_; 2] array, and the single 24 KB fx arena splits into two 12 KB halves so total static RAM stays flat (no new TLS heap pressure, FUG-71). Deck-aware FFI (lm_fx_*_deck); the bare lm_fx_* stay as deck-A wrappers for backward compat. The topology graph binds to both decks.
  • Blend: lm_fx_update advances every active deck; lm_fx_shade_blended shades the active decks per-LED and blends via a new pure, unit-tested fx_vm::blend_rgb8mode 0 = linear-RGB per-channel lerp, mode 1 = linear-HSV (shortest-hue path); exact at the crossfade endpoints, and it short-circuits a fully-faded-out deck so a single-deck show pays no second-deck cost.
  • Crossfade lives in the session core (value + gen counter, like brightness); main.cpp polls it and feeds the render loop. Boot-resume persists deck A only (crossfade resets to 0 on reboot → deck A is the visible deck), gated by lm_fx_frame_deck.
  • set_effect("", deck) parks a deck (keeps it cued, stops rendering) so Show-Mode play/pause round-trips; a parked deck falls back to the built-in idle exactly as a legacy set_effect("off") did.
  • Verified: bazel build -c opt //firmware/player_app:esp32c6 + all firmware tests green.

Web

  • Device surface: new per-device (store/deviceEffects.ts) record of which library effects the app has pushed. The effects browser shows a green "on device" badge and an ephemeral "On " section (label uses the device name; keyed by the stable device id for robustness). Editor "Send to device" + Show-Mode cue record it live.
  • Show Mode (/show, from the Effects ⋯ menu): left/right FX deck cards (cue → compile → submit_effect on a deck), a crossfade fader, an RGB/HSV blend-mode toggle, per-deck play/pause, and a focusable effect list.
  • MIDI-mappable transport: midiStore gains a show-action binding layer; midi/showRouter.ts is the pure resolver; a learn sheet binds a control per action. Actions: crossfade (fader), blend-mode, per-deck play/pause, effect-list prev/next, cue A/B (fader → value, buttons → rising-edge triggers).
  • Verified: //web:unit_tests 62/62 (incl. new showRouter / deviceEffects / midiStore-show / proto crossfade+deck tests); full app typechecks; //web:dist bundles clean.

Design notes / possible follow-ups

  • The app is the effect-library source of truth and cues by sending .fxb bytes to a deck — the device holds only the two live decks in RAM, not a browsable flash store, which is why the "on device" surface is app-side per-device tracking.
  • Per-deck arena is now 12 KB (was a single 24 KB); a very heavy single-deck effect could want more — easy to rebalance if it ever bites.
  • Perf/Tier-1 attribution tracks deck A only.

🤖 Generated with Claude Code

Closes-Linear: FUG-110 (https://linear.app/fughilli/issue/FUG-110/feature-fx-blending)

Claude Agent and others added 5 commits August 12, 2026 12:46
Add the wire contract for running two effect decks concurrently and
blending between them:

- SubmitEffect/SetEffect/SetUniforms/GetEffectUniforms gain a uint32
  deck field (0=A default/legacy, 1=B), so the app can cue two effects.
- New SetCrossfade{position, mode} global (like SetBrightness): position
  0..1 blends deck A->B, mode 0=linear RGB / 1=linear HSV. Echoed in
  Welcome for parity. deck represented as uint32 (not a proto enum) to
  match SetTexture.format and dodge the enum-JSON-name pitfall the proto
  header warns about.
- Regenerated web/src/gen protobuf bindings; added the flat effect-arm
  shapes + set_crossfade arm in web/src/net/proto.ts; new client methods
  setCrossfade() + deck params on the effect methods.

Firmware micropb types regenerate from the proto at build time; the new
fields are all scalar (no capacity-table changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run two effect programs concurrently and blend their per-LED output:

- ffi.rs: per-deck state (bytes/len/vm/active) is now a [_; 2] array; the
  24 KB fx arena splits into two 12 KB halves so total static RAM stays
  flat (no new TLS heap pressure, FUG-71). New deck-aware FFI
  (lm_fx_load_deck / clear / set_active / set_uniform_deck / deck_active /
  any_active) with the bare lm_fx_* kept as deck-A wrappers. The topology
  graph now binds to both decks' VMs. Effect arms read the deck field
  (submit=4, set/uniforms/get=2).
- lm_fx_update runs update() for every active deck; lm_fx_shade_blended
  shades the active decks and blends via fx_vm::blend_rgb8, short-
  circuiting a fully-faded-out deck at the endpoints. Perf/Tier-1
  attribution tracks deck A.
- fx_vm::blend_rgb8: pure, unit-tested blend — mode 0 linear RGB per-
  channel lerp, mode 1 linear HSV (shortest-hue path); exact at t=0/1.
- Session core stores crossfade position+mode+gen like brightness
  (set_crossfade -> welcome), exposed via lm_crossfade_*; main.cpp polls
  it and feeds lm_fx_shade_blended in the render loop.
- Persistence resumes only the deck-A effect on boot (crossfade resets to
  0 -> deck A visible), gated by lm_fx_frame_deck.

esp32c6 image builds -c opt; all firmware tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New per-device sent-effect store (web/src/store/deviceEffects.ts,
  localStorage keyed by device id): tracks which library effects the app
  has pushed to each device.
- Editor 'Send to device' now records the effect on the connected device.
- Effects browser shows a green 'on device' badge on any library effect
  present on the connected device, plus an ephemeral 'On <device>'
  section listing them (most-recently-cued first). Repaints live on
  connection / on-device-set changes; deleting an effect forgets it
  everywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New /show workspace to run a two-deck crossfade show:

- Left/right FX deck cards (cue an effect -> compile -> submit_effect on
  deck A/B), a crossfade fader, an RGB/HSV blend-mode toggle, per-deck
  play/pause (set_effect id/""), and a focusable effect list to pick
  what to cue. Reached from the Effects tab ... menu.
- Every transport control is MIDI-mappable: new show-action binding layer
  in midiStore (crossfade, blend-mode, per-deck play/pause, list prev/next,
  cue A/B) + a pure resolver (midi/showRouter.ts). A learn sheet binds a
  control per action; the screen routes hardware MIDI into the transport
  (fader -> value, buttons -> rising-edge triggers).
- Cueing marks the effect on the connected device (green badge + On-device
  section update live).

Tests: showRouter (resolve/kind/coverage), deviceEffects store, midiStore
show bindings, and proto round-trips for set_crossfade + the deck selector
on the effect arms. All 62 web unit tests pass; full app typechecks.

showMode.ts joins the browser-only bundle-entry exclude list (it statically
imports the worker compiler, like effectEditor/acidMode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Show-Mode play/pause sends set_effect("", deck) to pause and
set_effect(id, deck) to resume. Clearing the deck dropped the VM so
resume had nothing to re-activate; parking (set inactive, keep the loaded
effect + VM state) makes pause/resume round-trip. A parked deck isn't
drawn, so a legacy set_effect("off") still falls back to the built-in
idle. Adds the FUG-110 WORKLOG handoff entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://fughilli.github.io/splanc/pr-preview/pr-82/

Built to branch gh-pages at 2026-08-14 20:21 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

…-fx-blending

# Conflicts:
#	web/src/net/client.ts
#	web/src/ui/screens/effectEditor.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants