Demos/BeatPad/BeatPadUI.cs
using System;
using Goo;
using Goo.Animation;
using Goo.Input;
using Sandbox.UI;
using Sandbox.BeatPad;
using static Sandbox.BeatPadTokens;
namespace Sandbox;
// MPD-style beat pad demo. Root owns ALL state and ALL animators in one AnimationSet;
// PadBlob/KnobBlob/ModeButtonBlob are pure presenters. See
// docs/superpowers/specs/2026-05-31-goo-beatpad-design.md.
public sealed class BeatPadUI : GooPanel<Container>
{
// ---- constants ----
const int Pads = BeatPadKits.PadCount; // 16
const int Knobs = 6; // VOL/PCH/FLT/REV/SWG/TMP (OUT replaced by scope, TMP fills 6th cell)
const int Modes = 6;
static readonly string[] KnobLabels = { "VOL", "PCH", "FLT", "REV", "SWG", "TMP" };
const int SwingKnob = 4;
const int TempoKnob = 5;
static readonly string[] ModeLabels = { "BANK", "NOTE", "CTRL", "PROG", "PLAY", "REC" };
const int BankMode = 0;
const int NoteMode = 1;
// Shared width for the left column (knobs over modes) so pads + scope left-align.
const float SideW = ModeW * 2 + 20f;
// ---- oscilloscope (synthetic, scrolling; replaces the OUT knob) ----
const int ScopeSamples = 48;
readonly float[] _scope = new float[ScopeSamples]; // ring contents, newest at end
float _scopePhase; // running oscillator phase for the active pad's tone
float _scopeFreq = 24f; // radians/sec-ish; set per hit from pad index
float _scopeNoise; // 0..1 noise mix for hats/cymbals
int _scopeSeed = 1; // tiny LCG state so we avoid Math.Random (banned)
// ---- state ----
int _activeKit; // 0 = A, 1 = B
readonly float[] _knobValues = new float[Knobs];
readonly bool[] _modeActive = new bool[Modes];
int _lastPad = -1;
bool _noteHeld;
float _hitTimer; // seconds remaining on HIT overlay
string _hitLabel = "";
NoteRepeatClock _noteClock = new(NoteRepeatClock.DefaultIntervalSeconds);
// ---- sequencer ----
const int PlayMode = 4; // index into ModeLabels ("PLAY")
const int RecMode = 5; // index into ModeLabels ("REC")
readonly SequencerClock _seqClock = new();
readonly StepPattern _pattern = new();
readonly System.Collections.Generic.List<int> _firedSteps = new();
readonly System.Collections.Generic.List<int> _padsAtStep = new();
readonly RackTracks _tracks = new();
// per-cell + per-track click handlers (stable per key, preserve diffing)
readonly HandlerTable<(int, int)> _cellClick;
readonly HandlerTable<int> _trackDelete;
readonly HandlerTable<int> _trackMute;
readonly HandlerTable<int> _trackSolo;
readonly System.Collections.Generic.List<ChannelRackBlob.Track> _rackTracks = new();
bool _recArmed;
bool _recDown; // REC button is physically held right now
float _recHoldTime; // seconds REC has been held (for tap-vs-hold + clear)
bool _recErasing; // true once the hold passes the threshold this press
const float RecHoldThreshold = 0.3f; // tap below this, hold (erase) above
const float RecClearHold = 3.0f; // hold this long with no pad -> wipe all
readonly Goo.Input.KeyTracker _keys = new();
// ---- animators ----
readonly DecayFloat[] _litFade = new DecayFloat[Pads]; // 1 (hot) -> 0 (paper)
readonly SpringFloat[] _padPress = new SpringFloat[Pads]; // 0 -> 4 -> 0 px
readonly SpringFloat[] _padBounce = new SpringFloat[Pads];// scale ~1, overshoot on release
readonly DecayFloat[] _knobFlash = new DecayFloat[Knobs]; // readout fade 1 -> 0
readonly SmoothFloat[] _modeFlood = new SmoothFloat[Modes];// active crossfade 0..1
readonly SpringFloat[] _modePress = new SpringFloat[Modes];
DecayFloat _outMeter = new(0f, 0.18f);
readonly AnimationSet _anims = new();
// ---- pointer drag state for knobs ----
int _draggingKnob = -1;
float _dragLastY;
// ---- stable pointer handlers (stable per key; preserve structural diffing) ----
readonly HandlerTable<int> _padDown;
readonly HandlerTable<int> _padUp;
readonly HandlerTable<int> _padEnter;
readonly HandlerTable<int> _padLeave;
readonly HandlerTable<int> _knobDown;
readonly HandlerTable<int> _knobUp;
readonly HandlerTable<int> _knobMove;
readonly HandlerTable<int> _modeDown;
readonly HandlerTable<int> _modeUp;
readonly bool[] _padHover = new bool[Pads];
public BeatPadUI()
{
_padDown = new(OnPadDown);
_padUp = new(OnPadUp);
_padEnter = new(k => _padHover[k] = true);
_padLeave = new(k => _padHover[k] = false);
_knobDown = new(OnKnobDown);
_knobUp = new(OnKnobUp);
_knobMove = new(OnKnobMove);
_modeDown = new(OnModeDown);
_modeUp = new(OnModeUp);
_cellClick = new((k, _) => OnCellClick(k.Item1, k.Item2));
_trackDelete = new(OnTrackDelete);
_trackMute = new(k => _tracks.ToggleMute(k));
_trackSolo = new(k => _tracks.ToggleSolo(k));
}
protected override void OnEnabled()
{
base.OnEnabled();
for (int i = 0; i < Pads; i++)
{
_litFade[i] = new DecayFloat(0f, 0.06f);
_padPress[i] = new SpringFloat(0f, 18f, 0.6f);
_padBounce[i] = new SpringFloat(1f, 14f, 0.35f);
}
for (int i = 0; i < Knobs; i++) _knobFlash[i] = new DecayFloat(0f, 0.18f);
for (int i = 0; i < Modes; i++)
{
_modeFlood[i] = new SmoothFloat(0f, 0.08f);
_modePress[i] = new SpringFloat(0f, 18f, 0.6f);
}
// Knob defaults: VOL audible, others centered.
_knobValues[0] = 0.8f; // VOL
_knobValues[1] = 0.5f; // PCH (mid = unity pitch)
_knobValues[2] = 0.5f; // FLT (center = neutral bipolar filter)
_knobValues[3] = 0f; // REV (dry)
_knobValues[4] = 0.5f; // SWG (mid = straight)
_knobValues[5] = QuantizeMath.BpmToValue(120f); // TMP -> 120 BPM (0.5)
_anims.Clear();
for (int i = 0; i < Pads; i++)
{
int k = i;
_anims.Add(dt => { _litFade[k].Update(dt); return !_litFade[k].IsSettled; });
_anims.Add(dt => { _padPress[k].Update(dt); return !_padPress[k].IsSettled; });
_anims.Add(dt => { _padBounce[k].Update(dt); return !_padBounce[k].IsSettled; });
}
for (int i = 0; i < Knobs; i++)
{
int k = i;
_anims.Add(dt => { _knobFlash[k].Update(dt); return !_knobFlash[k].IsSettled; });
}
for (int i = 0; i < Modes; i++)
{
int k = i;
_anims.Add(dt => { _modeFlood[k].Update(dt); return !_modeFlood[k].IsSettled; });
_anims.Add(dt => { _modePress[k].Update(dt); return !_modePress[k].IsSettled; });
}
_anims.Add(dt => { _outMeter.Update(dt); return !_outMeter.IsSettled; });
Panel.Style.Width = Length.Percent(100);
Panel.Style.Height = Length.Percent(100);
Panel.Style.JustifyContent = Justify.Center;
Panel.Style.AlignItems = Align.Center;
}
protected override bool Tick(float dt)
{
bool dirty = false;
_keys.Poll();
_seqClock.SetBpm(QuantizeMath.ValueToBpm(_knobValues[TempoKnob]));
_seqClock.SetSwing(QuantizeMath.ValueToSwing(_knobValues[SwingKnob]));
var pressed = _keys.JustPressed;
for (int i = 0; i < pressed.Count; i++)
{
if (BeatPadKeys.TryMap(pressed[i].EngineName, out int pad))
{
_padPress[pad].Target = BeatPadTokens.PadShadow;
_padPress[pad].Velocity = 0f;
TriggerPad(pad);
RecordLiveHit(pad);
// keyboard has no key-up wiring; ease the press back immediately
_padPress[pad].Target = 0f;
}
}
if (_noteHeld && _lastPad >= 0)
{
int fires = _noteClock.Tick(dt);
for (int f = 0; f < fires; f++) { TriggerPad(_lastPad); RecordLiveHit(_lastPad); }
}
if (_recDown)
{
_recHoldTime += dt;
if (_recHoldTime >= RecHoldThreshold && !_recErasing)
{
_recErasing = true; // entered erase mode; release will not toggle arm
_modeFlood[RecMode].Target = 1f; // light REC to show erase is active
}
if (_recErasing && _recHoldTime >= RecClearHold)
{
_pattern.ClearAll();
_tracks.Clear();
dirty = true;
}
}
if (_seqClock.Playing)
{
_firedSteps.Clear();
_seqClock.Advance(dt, _firedSteps);
for (int i = 0; i < _firedSteps.Count; i++)
{
_pattern.CollectPadsAt(_firedSteps[i], _padsAtStep);
for (int j = 0; j < _padsAtStep.Count; j++)
if (_tracks.ShouldPlay(_padsAtStep[j])) TriggerPad(_padsAtStep[j]);
}
}
bool moving = _anims.UpdateAll(dt);
if (_hitTimer > 0f) { _hitTimer -= dt; moving = true; }
// Scope: while the envelope is live, scroll fresh synthetic samples in.
if (_outMeter.Current > 0.001f)
{
AdvanceScope(dt);
moving = true;
}
return dirty || moving || _seqClock.Playing;
}
void TriggerPad(int index)
{
string sound = BeatPadKits.Sounds(_activeKit)[index];
float flt = _knobValues[2];
BeatPadAudio.Play(sound, _knobValues[0], KnobMath.ValueToPitch(_knobValues[1]),
KnobMath.ValueToLowPassCutoff(flt), KnobMath.ValueToHighPassCutoff(flt),
KnobMath.ValueToReverbMix(_knobValues[3]));
float halflife = BeatPadAudio.ResolveHalflife(sound);
_litFade[index] = new DecayFloat(1f, halflife);
_litFade[index].Target = 0f;
_padBounce[index].Current = 0.96f;
_padBounce[index].Target = 1f;
_padBounce[index].Velocity = 6f;
_outMeter.Current = 1f;
_outMeter.Target = 0f;
// Seed the scope tone from the pad: low pads = low hum, high pads = bright + noisy.
float row = index / 4; // 0 (bottom) .. 3 (top)
_scopeFreq = 14f + row * 16f + (index % 4) * 2f;
_scopeNoise = row >= 2 ? 0.35f + 0.2f * (row - 2) : 0.05f;
_scopePhase = 0f;
_lastPad = index;
_hitLabel = BeatPadKits.Labels(_activeKit)[index];
_hitTimer = 0.5f;
}
// Push fresh synthetic samples into the scope ring. Amplitude follows the decaying
// hit envelope (_outMeter); tone is a damped sine plus per-pad noise. Scrolls L<-R.
void AdvanceScope(float dt)
{
// a few samples per frame keeps the trace moving at ~display rate
const int perFrame = 3;
float amp = _outMeter.Current;
for (int s = 0; s < perFrame; s++)
{
_scopePhase += _scopeFreq * dt / perFrame;
float tone = MathF.Sin(_scopePhase);
float noise = (NextNoise() * 2f - 1f) * _scopeNoise;
float v = amp * (tone * (1f - _scopeNoise) + noise);
// shift left, append newest
Array.Copy(_scope, 1, _scope, 0, ScopeSamples - 1);
_scope[ScopeSamples - 1] = v;
}
}
// Deterministic 0..1 noise (Math.Random is banned in this harness).
float NextNoise()
{
_scopeSeed = unchecked(_scopeSeed * 1103515245 + 12345);
return ((_scopeSeed >> 16) & 0x7fff) / 32767f;
}
void OnPadDown(int i)
{
if (_recErasing) { _tracks.Remove(i); _pattern.Erase(i); return; } // hold-REC + pad = wipe pad
_padPress[i].Target = BeatPadTokens.PadShadow; // press translate +4
TriggerPad(i);
RecordLiveHit(i);
}
// Record a live pad hit at the current quantized step. Only writes while the loop is
// running AND record-arm is on -- so loop playback (which calls TriggerPad directly)
// never self-records. Quantizes to the nearest 1/16 of the bar.
void RecordLiveHit(int pad)
{
if (!_recArmed || !_seqClock.Playing) return;
float bpm = QuantizeMath.ValueToBpm(_knobValues[TempoKnob]);
int step = QuantizeMath.NearestStep(
_seqClock.PlayheadFraction * QuantizeMath.BarDuration(bpm), bpm);
_pattern.Record(step, pad);
_tracks.EnsureTrack(pad);
}
// Click a cell to add/remove a hit; audition the pad when turning it on.
void OnCellClick(int step, int pad)
{
if (_pattern.Toggle(step, pad)) TriggerPad(pad);
}
// Delete a whole track (its row + every hit on that pad).
void OnTrackDelete(int pad)
{
_tracks.Remove(pad);
_pattern.Erase(pad);
}
void OnPadUp(int i)
{
_padPress[i].Target = 0f; // release; spring eases back, bounce already kicked
}
void OnKnobDown(int i, MousePanelEvent e)
{
_draggingKnob = i;
_dragLastY = e.LocalPosition.y;
}
void OnKnobUp(int i)
{
if (_draggingKnob == i) _draggingKnob = -1;
}
void OnKnobMove(int i, MousePanelEvent e)
{
if (_draggingKnob != i) return;
float dy = e.LocalPosition.y - _dragLastY;
_dragLastY = e.LocalPosition.y;
_knobValues[i] = KnobMath.ApplyDrag(_knobValues[i], dy, 2f);
_knobFlash[i].Current = 1f;
_knobFlash[i].Target = 0f;
}
void OnModeDown(int i)
{
_modePress[i].Target = BeatPadTokens.ModeShadow;
switch (i)
{
case BankMode:
_activeKit = 1 - _activeKit;
_modeActive[BankMode] = _activeKit == 1; // lit reflects kit B selected; reverts on kit A
_modeFlood[BankMode].Target = _modeActive[BankMode] ? 1f : 0f;
break;
case NoteMode:
_noteHeld = true;
_noteClock.Reset();
_modeActive[NoteMode] = true;
_modeFlood[NoteMode].Target = 1f;
break;
case PlayMode:
if (_seqClock.Playing) _seqClock.Stop();
else _seqClock.Start();
_modeActive[PlayMode] = _seqClock.Playing;
_modeFlood[PlayMode].Target = _seqClock.Playing ? 1f : 0f;
break;
case RecMode:
// start the hold timer; tap-vs-hold decided on release / over time (OnUpdate)
_recDown = true;
_recHoldTime = 0f;
_recErasing = false;
break;
default:
// CTRL/PROG: visual-only toggle/momentary
_modeActive[i] = !_modeActive[i];
_modeFlood[i].Target = _modeActive[i] ? 1f : 0f;
break;
}
}
void OnModeUp(int i)
{
_modePress[i].Target = 0f;
if (i == RecMode)
{
_recDown = false;
if (!_recErasing)
{
// a tap -> toggle record-arm
_recArmed = !_recArmed;
_modeActive[RecMode] = _recArmed;
_modeFlood[RecMode].Target = _recArmed ? 1f : 0f;
// arming record starts the loop so the playhead sweeps and hits land in time
if (_recArmed && !_seqClock.Playing)
{
_seqClock.Start();
_modeActive[PlayMode] = true;
_modeFlood[PlayMode].Target = 1f;
}
}
else
{
// was an erase hold -> restore the arm light, do not toggle
_modeFlood[RecMode].Target = _recArmed ? 1f : 0f;
}
_recErasing = false;
}
if (i == NoteMode)
{
_noteHeld = false;
_modeActive[NoteMode] = false;
_modeFlood[NoteMode].Target = 0f;
}
}
protected override Container Build()
{
return new Container
{
Key = "chassis",
FlexDirection = FlexDirection.Column,
BackgroundColor = Paper,
BorderColor = Ink,
BorderWidth = OutlineWidth,
BorderRadius = ChassisRadius,
PaddingLeft = 18f, PaddingTop = 14f, PaddingRight = 18f, PaddingBottom = 18f,
Children =
{
BuildTitleStrip(),
new Container
{
Key = "bodyrow",
FlexDirection = FlexDirection.Row,
AlignItems = Align.FlexStart,
Children = { BuildBody(), BuildRack() },
},
},
};
}
Container BuildTitleStrip()
{
int bpm = (int)System.MathF.Round(QuantizeMath.ValueToBpm(_knobValues[TempoKnob]));
string status =
_hitTimer > 0f ? $"HIT {_hitLabel}" :
(_seqClock.Playing && _recArmed) ? $"REC · {bpm} BPM" :
_seqClock.Playing ? $"PLAY · {bpm} BPM" :
$"BANK {BeatPadKits.KitName(_activeKit)} · {bpm} BPM";
return new Container
{
Key = "title",
FlexDirection = FlexDirection.Row,
JustifyContent = Justify.SpaceBetween,
AlignItems = Align.Center,
MarginBottom = 14f,
Children =
{
new Text("GOO MPD")
{
Key = "brand",
FontSize = 18f, FontColor = Ink, FontFamily = FontLabel,
FontWeight = 600, TextTransform = TextTransform.Uppercase, LetterSpacing = 3f,
},
new Container
{
Key = "screen",
BackgroundColor = Ink,
BorderRadius = 4f,
// Pin a stable LCD width so transport-state strings (BANK/PLAY/REC) don't
// reflow the title row as they swap; longest status is ~16 mono chars.
MinWidth = 156f,
PaddingLeft = 10f, PaddingRight = 10f, PaddingTop = 4f, PaddingBottom = 4f,
Children =
{
new Text(status) { FontSize = 12f, FontColor = Hot, FontFamily = FontMono },
},
},
},
};
}
// 2x2 cluster: knobs | pads (top) / modes | scope (bottom).
Container BuildBody()
{
return new Container
{
Key = "body",
FlexDirection = FlexDirection.Column,
Children =
{
new Container
{
Key = "toprow",
FlexDirection = FlexDirection.Row,
Children = { BuildKnobGrid(), BuildPadGrid() },
},
new Container
{
Key = "botrow",
FlexDirection = FlexDirection.Row,
Children = { BuildModeGrid(), BuildScope() },
},
},
};
}
// The FL-style channel rack to the right of the pads: one row per active track,
// built in recording order from _tracks, occupancy read live from _pattern.
Container BuildRack()
{
_rackTracks.Clear();
var labels = BeatPadKits.Labels(_activeKit);
var order = _tracks.Order;
for (int i = 0; i < order.Count; i++)
{
int pad = order[i];
var occ = new bool[QuantizeMath.StepsPerBar];
for (int s = 0; s < QuantizeMath.StepsPerBar; s++) occ[s] = _pattern.Has(s, pad);
_rackTracks.Add(new ChannelRackBlob.Track(
pad, labels[pad], occ,
_tracks.IsMuted(pad), _tracks.IsSoloed(pad), !_tracks.ShouldPlay(pad)));
}
return new Container
{
Key = "rackwrap",
MarginLeft = 16f,
Children =
{
ChannelRackBlob.Build(new ChannelRackBlob.Props(
_rackTracks, QuantizeMath.StepsPerBar, _seqClock.PlayheadFraction,
_cellClick, _trackDelete, _trackMute, _trackSolo)),
},
};
}
Container BuildKnobGrid()
{
var grid = new Container
{
Key = "knobs",
FlexDirection = FlexDirection.Row,
FlexWrap = Wrap.Wrap,
Width = SideW,
MarginRight = 20f,
MarginBottom = 16f,
};
for (int i = 0; i < Knobs; i++)
{
grid.Children.Add(new Container
{
Key = $"knobcell-{i}",
MarginRight = 16f, MarginBottom = 8f,
Children =
{
KnobBlob.Build(new KnobBlob.Props(
i, KnobLabels[i],
_knobValues[i],
_knobFlash[i].Current,
ReadOnly: false,
OnDown: _knobDown[i], OnUp: _knobUp[i], OnMove: _knobMove[i])),
},
});
}
return grid;
}
// Big oscilloscope LCD filling the area under the pad grid (right of the modes).
Container BuildScope()
{
return new Container
{
Key = "scope",
Width = 4f * (PadSize + PadGap), // match the pad grid width above it
Height = ScopeHeight,
Children =
{
OscilloscopeBlob.Build(new OscilloscopeBlob.Props(_scope, _outMeter.Current)),
},
};
}
Container BuildModeGrid()
{
var grid = new Container
{
Key = "modes",
FlexDirection = FlexDirection.Row,
FlexWrap = Wrap.Wrap,
Width = SideW,
MarginRight = 20f,
};
for (int i = 0; i < Modes; i++)
{
grid.Children.Add(new Container
{
Key = $"modecell-{i}",
MarginRight = 8f, MarginBottom = 8f,
Children =
{
ModeButtonBlob.Build(new ModeButtonBlob.Props(
i, ModeLabels[i], _modeFlood[i].Current, _modePress[i].Current,
Hovered: false, OnDown: _modeDown[i], OnUp: _modeUp[i])),
},
});
}
return grid;
}
Container BuildPadGrid()
{
var labels = BeatPadKits.Labels(_activeKit);
var grid = new Container
{
Key = "pads",
FlexDirection = FlexDirection.Column,
};
// rows top (12..15) down to bottom (0..3) so visual order matches MPD numbering
for (int row = 3; row >= 0; row--)
{
var rowC = new Container { Key = $"row-{row}", FlexDirection = FlexDirection.Row };
for (int col = 0; col < 4; col++)
{
int idx = row * 4 + col;
rowC.Children.Add(new Container
{
Key = $"padcell-{idx}",
MarginRight = PadGap, MarginBottom = PadGap,
Children =
{
PadBlob.Build(new PadBlob.Props(
idx, labels[idx],
_litFade[idx].Current, _padPress[idx].Current, _padBounce[idx].Current,
Hovered: _padHover[idx],
OnDown: _padDown[idx], OnUp: _padUp[idx],
OnEnter: _padEnter[idx], OnLeave: _padLeave[idx])),
},
});
}
grid.Children.Add(rowC);
}
return grid;
}
}