// deck_groups.cpp — see deck_groups.h. Pure data; no host types. #include "core/instrument/ui/deck_groups.h" #include namespace reasampler::instrument::ui { namespace { int id(DeckParam p) { return static_cast(p); } double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); } // Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays // no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob // row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen // the CONTOUR row, which has 144px of slack, not the SOUND row. constexpr int kEnvModeSegW = 23; // The output meter's column, right of MASTER's cell slots. 62 + the 60px cell + the gap + the // group's own padding is exactly kDeckSpanningW. constexpr int kMasterMeterW = 62; } // namespace double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; } double deckNormFromBipolar(double value) { return clamp(value, -1.0, 1.0) * 0.5 + 0.5; } std::vector sampleDeckGroups(PlayMode playMode) { const bool trigger = (playMode == PlayMode::Trigger); std::vector out; { // PITCH/RATE. The three cells make the knob row 180, which is what the group measures // from; the caption row (caption + gap + two 48px segments) must stay under it, so the // caption reserve has a hard ceiling of 80 — past that the caption row overtakes the knob // row and the group grows past 192. Widening the group is not the answer if the text ever // outgrows 80: narrow the Varisp|Presrv segments to 44 instead. DeckGroupDesc pitch; pitch.id = kGroupPitch; pitch.captionWidth = 70; pitch.captionToggle = {id(DeckParam::kPitchEngine), 48}; pitch.cellIds = {id(DeckParam::kKeyTrack), id(DeckParam::kRate), id(DeckParam::kPitch)}; out.push_back(std::move(pitch)); } { DeckGroupDesc penv; penv.id = kGroupPitchEnv; penv.captionWidth = 58; penv.captionRadio = {id(DeckParam::kPitchEnvSelect)}; penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32}; // The mode toggle rides the caption slack rather than the knob row — costs no group // width; see this module's CLAUDE.md bullet (knob_deck) for the headroom this relies on. penv.captionToggle2 = {id(DeckParam::kPitchEnvMode), kEnvModeSegW}; penv.cellIds = {id(DeckParam::kPitchEnvAttack), id(DeckParam::kPitchEnvHold), id(DeckParam::kPitchEnvDecay), id(DeckParam::kPitchEnvDepth)}; out.push_back(std::move(penv)); } { // Tone shaping left-to-right, then the three modulation depths that all target cutoff. DeckGroupDesc filter; filter.id = kGroupFilter; filter.captionWidth = 46; filter.captionToggle = {id(DeckParam::kFilterEnable), 32}; filter.cellIds = {id(DeckParam::kFilterMorph), id(DeckParam::kFilterCutoff), id(DeckParam::kFilterQ), id(DeckParam::kFilterDrive), id(DeckParam::kFilterModAmt), id(DeckParam::kFilterVel), id(DeckParam::kFilterKeyTrack)}; // The morph law rides the caption slack. Moving it back to the knob row costs the // group 92px and the SOUND row stops fitting its block. filter.captionToggle2 = {id(DeckParam::kFilterLaw), 44}; out.push_back(std::move(filter)); } { DeckGroupDesc fenv; fenv.id = kGroupFilterEnv; fenv.captionWidth = 66; fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)}; fenv.captionToggle2 = {id(DeckParam::kFilterEnvMode), kEnvModeSegW}; if (trigger) { fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold), id(DeckParam::kFilterTrigDecay), -1, -1}; } else { fenv.cellIds = {id(DeckParam::kFilterEnvAttack), id(DeckParam::kFilterEnvHold), id(DeckParam::kFilterEnvDecay), id(DeckParam::kFilterEnvSustain), id(DeckParam::kFilterEnvRelease)}; } out.push_back(std::move(fenv)); } { DeckGroupDesc amp; amp.id = kGroupAmpEnv; amp.captionWidth = 78; amp.captionRadio = {id(DeckParam::kAmpEnvSelect)}; amp.captionToggle = {id(DeckParam::kPlayMode), 44}; amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW}; if (trigger) { // The play span first, then the AHD that shapes it, time-ordered left-to-right so // the row reads like the drawn envelope. One reserve (-1) keeps the group's width — // and therefore its neighbours' placement — identical across a mode flip; its // pixels go to the four cells that remain (knob_deck.h). amp.cellIds = {id(DeckParam::kTrigLength), id(DeckParam::kTrigAttack), id(DeckParam::kTrigHold), id(DeckParam::kTrigDecay), -1}; } else { amp.cellIds = {id(DeckParam::kAttack), id(DeckParam::kHold), id(DeckParam::kDecay), id(DeckParam::kSustain), id(DeckParam::kRelease)}; } out.push_back(std::move(amp)); } { // The three velocity transfer curves share one home, immediately left of VOICE: they // shape three different destinations but are one gesture, and MASTER is reserved for // post-voice-mixer concerns. DeckGroupDesc vel; vel.id = kGroupVelocity; vel.captionWidth = 54; vel.cellIds = {id(DeckParam::kAmpVelCurve), id(DeckParam::kPitchVelCurve), id(DeckParam::kFilterVelCurve)}; out.push_back(std::move(vel)); } { DeckGroupDesc voice; voice.id = kGroupVoice; voice.captionWidth = 38; voice.captionToggle = {id(DeckParam::kVoiceMode), 40}; voice.cellIds = {id(DeckParam::kVoiceCount)}; voice.rowToggle = {id(DeckParam::kMonoTrigger), 44}; out.push_back(std::move(voice)); } { // The lower slot is reserved and draws NOTHING: blank reads as breathing room where a // dashed placeholder would read as unfinished. It is one cell, not two — a second // would spend 60 of the layout's whole 90px budget on a control nobody has named. DeckGroupDesc master; master.id = kGroupMaster; master.captionWidth = 46; master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true}; master.captionToggle = {id(DeckParam::kLimiterEnable), 32}; master.cellIds = {id(DeckParam::kMasterGain), -1}; master.column = {id(DeckParam::kMasterMeter), kMasterMeterW}; out.push_back(std::move(master)); } for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast(d.id)); return out; } DeckRow deckRowFor(DeckGroupId group) { // Every enumerator listed and no default, on the same gate deckParamCommit below relies on. switch (group) { case kGroupPitch: case kGroupFilter: case kGroupVelocity: case kGroupVoice: return DeckRow::Sound; case kGroupPitchEnv: case kGroupFilterEnv: case kGroupAmpEnv: return DeckRow::Contour; case kGroupMaster: return DeckRow::Spanning; } // Unreachable for a valid enumerator, and Spanning rather than Sound ON PURPOSE: the // -Wswitch gate is compiler-dependent, so on a toolchain that does not raise it a dropped // case arm falls here instead. Sound is what a new group most plausibly IS, which would // make the fall-through invisible; Spanning is the one row nothing may silently join, so // the tests' partition count catches it. return DeckRow::Spanning; } CurveTarget curveTargetFor(int controlId) { switch (static_cast(controlId)) { case DeckParam::kAmpVelCurve: return CurveTarget::kAmp; case DeckParam::kPitchVelCurve: return CurveTarget::kPitch; case DeckParam::kFilterVelCurve: return CurveTarget::kFilter; default: return CurveTarget::kNone; } } DeckParam curveParamFor(DeckParam knob) { switch (knob) { case DeckParam::kAttack: return DeckParam::kAttackCurve; case DeckParam::kDecay: return DeckParam::kDecayCurve; case DeckParam::kRelease: return DeckParam::kReleaseCurve; case DeckParam::kTrigAttack: return DeckParam::kTrigAttackCurve; case DeckParam::kTrigDecay: return DeckParam::kTrigDecayCurve; case DeckParam::kPitchEnvAttack: return DeckParam::kPitchEnvAttackCurve; case DeckParam::kPitchEnvDecay: return DeckParam::kPitchEnvDecayCurve; case DeckParam::kFilterEnvAttack: return DeckParam::kFilterEnvAttackCurve; case DeckParam::kFilterEnvDecay: return DeckParam::kFilterEnvDecayCurve; case DeckParam::kFilterEnvRelease: return DeckParam::kFilterEnvReleaseCurve; case DeckParam::kFilterTrigAttack: return DeckParam::kFilterTrigAttackCurve; case DeckParam::kFilterTrigDecay: return DeckParam::kFilterTrigDecayCurve; default: return DeckParam::kCount; } } LiveCommit deckParamCommit(DeckParam id) { switch (id) { // The one note-on-latched control; the header owns why. case DeckParam::kRate: return LiveCommit::NoteOnLatched; case DeckParam::kPitch: case DeckParam::kAttack: case DeckParam::kHold: case DeckParam::kDecay: case DeckParam::kSustain: case DeckParam::kRelease: case DeckParam::kTrigAttack: case DeckParam::kTrigHold: case DeckParam::kTrigDecay: case DeckParam::kPitchEnvAttack: case DeckParam::kPitchEnvHold: case DeckParam::kPitchEnvDecay: case DeckParam::kPitchEnvDepth: case DeckParam::kFilterMorph: case DeckParam::kFilterCutoff: case DeckParam::kFilterQ: case DeckParam::kFilterDrive: case DeckParam::kFilterModAmt: case DeckParam::kFilterVel: case DeckParam::kFilterKeyTrack: case DeckParam::kFilterEnvAttack: case DeckParam::kFilterEnvHold: case DeckParam::kFilterEnvDecay: case DeckParam::kFilterEnvSustain: case DeckParam::kFilterEnvRelease: case DeckParam::kFilterTrigAttack: case DeckParam::kFilterTrigHold: case DeckParam::kFilterTrigDecay: case DeckParam::kAttackCurve: case DeckParam::kDecayCurve: case DeckParam::kReleaseCurve: case DeckParam::kTrigAttackCurve: case DeckParam::kTrigDecayCurve: case DeckParam::kPitchEnvAttackCurve: case DeckParam::kPitchEnvDecayCurve: case DeckParam::kFilterEnvAttackCurve: case DeckParam::kFilterEnvDecayCurve: case DeckParam::kFilterEnvReleaseCurve: case DeckParam::kFilterTrigAttackCurve: case DeckParam::kFilterTrigDecayCurve: return LiveCommit::Live; // Listed rather than defaulted so a newly added control is a COMPILE error here on // every toolchain — /we4062 on MSVC, -Werror=switch on GCC/Clang, both set on this // library alone in cmake/reasampler_targets.cmake — instead of silently defaulting // to non-live. Reasons live in the header. case DeckParam::kPlayMode: case DeckParam::kPitchEngine: case DeckParam::kTrigLength: case DeckParam::kPitchEnvEnable: case DeckParam::kKeyTrack: case DeckParam::kFilterEnable: case DeckParam::kAmpVelCurve: case DeckParam::kPitchVelCurve: case DeckParam::kFilterVelCurve: case DeckParam::kFilterLaw: case DeckParam::kAmpEnvSelect: case DeckParam::kPitchEnvSelect: case DeckParam::kFilterEnvSelect: // A mode toggle names a different envelope, not a different setting of one — the same // reason every other discrete toggle above is excluded. case DeckParam::kAmpEnvMode: case DeckParam::kPitchEnvMode: case DeckParam::kFilterEnvMode: case DeckParam::kVoiceCount: case DeckParam::kVoiceMode: case DeckParam::kMonoTrigger: case DeckParam::kMasterGain: case DeckParam::kLimiterEnable: // MASTER's two readouts reach no parameter at all — the same footing as the overlay // radios above. case DeckParam::kMasterMeter: case DeckParam::kMasterGr: case DeckParam::kCount: // not a control return LiveCommit::Reload; } return LiveCommit::Reload; // unreachable for a valid enumerator; silences a warning. } OverlayEnv overlayEnvForRadio(int radioId) { switch (static_cast(radioId)) { case DeckParam::kAmpEnvSelect: return OverlayEnv::kAmp; case DeckParam::kPitchEnvSelect: return OverlayEnv::kPitch; case DeckParam::kFilterEnvSelect: return OverlayEnv::kFilter; default: return OverlayEnv::kNone; } } OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) { const OverlayEnv picked = overlayEnvForRadio(radioId); if (picked == OverlayEnv::kNone) return current; // not a radio: nothing selects return (current == picked) ? OverlayEnv::kNone : picked; } OverlayEnv overlayEnvForModeToggle(int toggleId) { switch (static_cast(toggleId)) { case DeckParam::kAmpEnvMode: return OverlayEnv::kAmp; case DeckParam::kPitchEnvMode: return OverlayEnv::kPitch; case DeckParam::kFilterEnvMode: return OverlayEnv::kFilter; default: return OverlayEnv::kNone; } } bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state) { switch (env) { case OverlayEnv::kPitch: return state.pitchEnvEnabled; case OverlayEnv::kFilter: return state.filterEnabled; case OverlayEnv::kAmp: case OverlayEnv::kNone: return true; } return true; // unreachable for a valid enumerator; silences a warning. } bool overlayEnvInert(OverlayEnv env, const DeckEnableState& state) { if (env == OverlayEnv::kNone) return false; if (!overlayEnvEnabled(env, state)) return true; switch (env) { case OverlayEnv::kPitch: return state.pitchSpline; case OverlayEnv::kFilter: return state.filterSpline; case OverlayEnv::kAmp: return state.ampSpline; case OverlayEnv::kNone: return false; } return false; // unreachable for a valid enumerator; silences a warning. } bool deckKnobInert(DeckParam id, const DeckEnableState& state) { switch (id) { case DeckParam::kAttack: case DeckParam::kHold: case DeckParam::kDecay: case DeckParam::kSustain: case DeckParam::kRelease: case DeckParam::kTrigAttack: case DeckParam::kTrigHold: case DeckParam::kTrigDecay: return state.ampSpline; // The Trigger %-length knob is inert whenever ANY spline is active (not just the amp's): // a drawn contour always covers the full sample length (splineActive, play_params.h), so // the engine ignores lengthFraction in that case regardless of which EG is drawn. case DeckParam::kTrigLength: return state.ampSpline || state.pitchSpline || state.filterSpline; case DeckParam::kPitchEnvAttack: case DeckParam::kPitchEnvHold: case DeckParam::kPitchEnvDecay: return !state.pitchEnvEnabled || state.pitchSpline; case DeckParam::kPitchEnvDepth: return !state.pitchEnvEnabled; case DeckParam::kFilterEnvAttack: case DeckParam::kFilterEnvHold: case DeckParam::kFilterEnvDecay: case DeckParam::kFilterEnvSustain: case DeckParam::kFilterEnvRelease: case DeckParam::kFilterTrigAttack: case DeckParam::kFilterTrigHold: case DeckParam::kFilterTrigDecay: return !state.filterEnabled || state.filterSpline; case DeckParam::kFilterMorph: case DeckParam::kFilterCutoff: case DeckParam::kFilterQ: case DeckParam::kFilterDrive: case DeckParam::kFilterModAmt: case DeckParam::kFilterVel: case DeckParam::kFilterKeyTrack: // The filter's velocity curve sits in the VELOCITY group but is a filter parameter: // it goes inert with every other one, so no surface can reach a param the knobs can't. case DeckParam::kFilterVelCurve: return !state.filterEnabled; default: return false; } } LiveCommit liveCommitFor(LiveDragKind kind, int paramId) { switch (kind) { case LiveDragKind::kDeckKnob: return (paramId >= 0 && paramId < static_cast(DeckParam::kCount)) ? deckParamCommit(static_cast(paramId)) : LiveCommit::Reload; case LiveDragKind::kEnvNode: return LiveCommit::Live; case LiveDragKind::kOther: return LiveCommit::Reload; } return LiveCommit::Reload; } } // namespace reasampler::instrument::ui