From 6c0bb4c204c309b0bf4616b23c66bedbd68cc9b9 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 18:25:07 -0400 Subject: [PATCH] =?UTF-8?q?feat(param=5Fslider):=20radial=20KNOB=20primiti?= =?UTF-8?q?ve=20=E2=80=94=20parameterized=206->4=20o=20clock=20arc,=20need?= =?UTF-8?q?le=20endpoint,=20vertical-drag=20value=20map,=20circular=20hit-?= =?UTF-8?q?test=20(FA4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/vst/param_slider.cpp | 61 ++++++++++++++++++++ src/vst/param_slider.h | 98 +++++++++++++++++++++++++++----- tests/test_param_slider.cpp | 108 +++++++++++++++++++++++++++++++++++- 3 files changed, 253 insertions(+), 14 deletions(-) diff --git a/src/vst/param_slider.cpp b/src/vst/param_slider.cpp index a01df57..6fa84b6 100644 --- a/src/vst/param_slider.cpp +++ b/src/vst/param_slider.cpp @@ -4,6 +4,7 @@ #include "param_slider.h" #include +#include namespace reasampler::vst { @@ -78,10 +79,70 @@ double valueAtPoint(const Rect& control, int x) { return static_cast(x - track.left) / static_cast(span); } +// --- Radial knob (Wave A FA4) --------------------------------------------------------- + +namespace { + +constexpr double kPi = 3.14159265358979323846; + +// Normalize an angle in degrees to [0, 360). +double normDeg(double deg) { + deg = std::fmod(deg, 360.0); + if (deg < 0.0) deg += 360.0; + return deg; +} + +double clamp01(double v) { return (std::min)(1.0, (std::max)(0.0, v)); } + +} // namespace + +KnobGeometry computeKnob(const Rect& cell) { + if (cell.width() <= 0 || cell.height() <= 0) return KnobGeometry{}; + KnobGeometry g; + g.centerX = (cell.left + cell.right) / 2.0; + g.centerY = (cell.top + cell.bottom) / 2.0; + g.radius = (std::min)(cell.width(), cell.height()) / 2.0; + return g; +} + +bool knobHitTest(const KnobGeometry& knob, int x, int y) { + if (knob.radius <= 0.0) return false; + const double dx = x - knob.centerX; + const double dy = y - knob.centerY; + return dx * dx + dy * dy <= knob.radius * knob.radius; +} + +double knobSweepDeg(const KnobArc& arc) { + const double sweep = normDeg(arc.endDeg) - normDeg(arc.startDeg); + // An end at-or-behind the start wraps clockwise past 12 o'clock; equal angles mean a + // full circle. + return sweep <= 0.0 ? sweep + 360.0 : sweep; +} + +double knobValueAngleDeg(const KnobArc& arc, double value) { + return normDeg(normDeg(arc.startDeg) + knobSweepDeg(arc) * clamp01(value)); +} + +KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value) { + // Clock angle -> screen direction: 0° points up (-y), 90° points right (+x). + const double rad = knobValueAngleDeg(arc, value) * kPi / 180.0; + return KnobPoint{knob.centerX + knob.radius * std::sin(rad), + knob.centerY - knob.radius * std::cos(rad)}; +} + +double knobDragValue(double startValue, int dyPixels, int dragRangePixels) { + const double start = clamp01(startValue); + if (dragRangePixels <= 0) return start; + // Screen y grows downward: an upward drag (negative dy) increases the value. + return clamp01(start - static_cast(dyPixels) / dragRangePixels); +} + int controlAtPoint(const std::vector& rows, int x, int y) { for (const ControlRow& r : rows) { if (r.kind == ControlKind::Toggle) { if (contains(r.control, x, y)) return r.id; + } else if (r.kind == ControlKind::Knob) { + if (knobHitTest(computeKnob(r.control), x, y)) return r.id; } else { // Slider — the interactive area is the track if (contains(sliderTrackRect(r.control), x, y)) return r.id; } diff --git a/src/vst/param_slider.h b/src/vst/param_slider.h index 60ebe14..10b0fe3 100644 --- a/src/vst/param_slider.h +++ b/src/vst/param_slider.h @@ -10,12 +10,13 @@ // grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR // amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade // controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope -// enable/attack/decay/depth. They are two shapes only — a two-segment TOGGLE and a -// horizontal SLIDER — laid out as a vertical stack of fixed-height rows. This module lays out -// that stack and maps a slider's NORMALIZED value (0..1) to/from its handle pixel; the shell -// converts each control's engine value (frames, seconds, a fraction, a signed semitone -// depth) to/from that 0..1 with its own domain knowledge (this module stays engine-free so it -// tests without the audio core). +// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal +// SLIDER, and (Wave A FA4) a radial KNOB with a needle indicator and vertical-drag value +// mapping — laid out as a vertical stack of fixed-height rows. This module lays out that +// stack and maps a control's NORMALIZED value (0..1) to/from its handle pixel / needle +// angle; the shell converts each control's engine value (frames, seconds, a fraction, a +// signed semitone depth) to/from that 0..1 with its own domain knowledge (this module stays +// engine-free so it tests without the audio core). // // It reuses editor_geometry's Rect + contains() (one shared geometry idiom). @@ -34,9 +35,11 @@ inline constexpr int kControlLabelWidth = 92; // the label column at the row's inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px) inline constexpr int kToggleSegments = 2; // a toggle is always two segments -// A control is one of two shapes. Toggle = a two-segment selector (the active segment -// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value. -enum class ControlKind { Toggle, Slider }; +// A control is one of three shapes. Toggle = a two-segment selector (the active segment +// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value; +// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY +// (up = increase). +enum class ControlKind { Toggle, Slider, Knob }; // One control the shell places in the panel, in stack order. `id` is the shell's own control // identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the @@ -94,11 +97,80 @@ Rect sliderHandleRect(const Rect& control, double value); // shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones). double valueAtPoint(const Rect& control, int x); +// --- Radial knob (Wave A FA4) -------------------------------------------------------------- +// +// Angle convention: DEGREES CLOCKWISE FROM 12 O'CLOCK, matching a clock face in screen +// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6 +// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg +// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal +// angles mean a full 360° sweep. +// +// The DEFAULT arc encodes Daniel's "6 to 4 o'clock" spec: min at 6 o'clock (180°) sweeping +// clockwise 300° around to max at 4 o'clock (120°), leaving a minimal 60° dead arc at the +// bottom-right. The angles are PARAMETERS, not hardcoded — the shell sets the final sweep +// when the parallel layout spec lands. +inline constexpr double kKnobArcStartDeg = 180.0; // value 0 — 6 o'clock +inline constexpr double kKnobArcEndDeg = 120.0; // value 1 — 4 o'clock (clockwise wrap) + +// Default vertical-drag sensitivity: pixels of upward drag for one full 0->1 sweep. +inline constexpr int kKnobDragRangePixels = 128; + +// The configurable value arc of a knob. Defaults to the 6->4 o'clock reading above. +struct KnobArc { + double startDeg = kKnobArcStartDeg; + double endDeg = kKnobArcEndDeg; +}; + +// A knob's circle within its control cell: center + radius in pixel space (doubles so the +// shell rounds once, at draw time). radius == 0 marks a degenerate cell. +struct KnobGeometry { + double centerX = 0.0; + double centerY = 0.0; + double radius = 0.0; +}; + +// A pixel-space point (the needle endpoint the shell draws to). +struct KnobPoint { + double x = 0.0; + double y = 0.0; +}; + +// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A +// degenerate cell yields radius 0. The shell passes whatever cell it wants the knob in +// (the whole control column, or a square sub-cell from its own layout). Pure. +KnobGeometry computeKnob(const Rect& cell); + +// True if (x, y) falls inside the knob circle (boundary inclusive). A degenerate knob +// (radius <= 0) hits nothing. Pure. +bool knobHitTest(const KnobGeometry& knob, int x, int y); + +// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping +// +360 when the end is at-or-behind the start (default arc -> 300). Pure. +double knobSweepDeg(const KnobArc& arc); + +// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1, +// linear between, returned normalized to [0, 360). Pure. +double knobValueAngleDeg(const KnobArc& arc, double value); + +// The needle endpoint for normalized `value`: the point on the knob circle at the value's +// angle, from the center. The shell draws the needle from (centerX, centerY) to this point +// (or lerps toward the center for a shorter needle). Pure. +KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value); + +// Map a vertical drag onto a knob value: `startValue` is the value at drag start (clamped), +// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging +// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1 +// range. Result clamps to [0,1]; a non-positive drag range yields the clamped start value. +// Pure — the inverse map for the knob's drag interaction. +double knobDragValue(double startValue, int dyPixels, + int dragRangePixels = kKnobDragRangePixels); + // The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc -// id) whose interactive area (a Slider's track, a Toggle's whole control area) contains the -// point, or -1 for a miss (a gap, the label column, or outside every row). The FIRST matching -// row wins (rows never overlap, so at most one matches). Pure — the shell's routing entry -// point: on a hit it reads the value (valueAtPoint / toggleSegmentHitTest) and commits. +// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's +// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every +// row). The FIRST matching row wins (rows never overlap, so at most one matches). Pure — the +// shell's routing entry point: on a hit it reads the value (valueAtPoint / +// toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits. int controlAtPoint(const std::vector& rows, int x, int y); } // namespace reasampler::vst diff --git a/tests/test_param_slider.cpp b/tests/test_param_slider.cpp index 7887c9d..c115f6c 100644 --- a/tests/test_param_slider.cpp +++ b/tests/test_param_slider.cpp @@ -9,7 +9,11 @@ // sliderTrackRect insetting a half-handle at each end; sliderHandleRect at value 0/0.5/1 and // out-of-range clamping; valueAtPoint mapping x back to 0..1 (endpoints saturate) as the inverse // of the handle position; controlAtPoint routing a point to the right control id (toggle whole -// area vs slider track) and MISSING in the label column, a row gap, and off-panel. +// area vs slider track vs knob circle) and MISSING in the label column, a row gap, and +// off-panel. FA4 adds the radial KNOB: computeKnob inscribing the circle in its cell, the +// circular hit-test, the arc angle<->value mapping (min at startDeg, max at endDeg, linear +// midpoint; default = the 6->4 o'clock 300-degree sweep), the needle endpoint on the circle, +// and the vertical-drag delta->value map (up = increase) with clamping at 0/1. #include "../src/vst/param_slider.h" @@ -152,6 +156,95 @@ static void testValueAtPointDegenerateTrack() { CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0)); } +// --- knob (FA4) ----------------------------------------------------------------- + +static bool near(double a, double b, double tol) { return (a - b) < tol && (b - a) < tol; } + +static void testKnobGeometryInscribesCell() { + // A 44x44 cell at (100,0): center (122,22), radius 22. + const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); + CHECK(approx(g.centerX, 122.0)); + CHECK(approx(g.centerY, 22.0)); + CHECK(approx(g.radius, 22.0)); + // A wide cell inscribes on the smaller (vertical) dimension. + const KnobGeometry w = computeKnob(Rect{0, 0, 200, 22}); + CHECK(approx(w.radius, 11.0)); + CHECK(approx(w.centerX, 100.0)); + // Degenerate cells yield radius 0. + CHECK(computeKnob(Rect{0, 0, 0, 22}).radius == 0.0); + CHECK(computeKnob(Rect{0, 0, 22, 0}).radius == 0.0); +} + +static void testKnobHitTestCircle() { + const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); // center (122,22), r 22 + CHECK(knobHitTest(g, 122, 22)); // center + CHECK(knobHitTest(g, 122 + 22, 22)); // on the circle boundary (inclusive) + CHECK(!knobHitTest(g, 122 + 22, 44)); // cell corner: inside the rect, outside the circle + CHECK(!knobHitTest(g, 122, 45)); // just below the circle + CHECK(!knobHitTest(KnobGeometry{}, 0, 0)); // degenerate knob hits nothing +} + +static void testKnobDefaultArcIsSixToFourOClock() { + const KnobArc arc; // default: 180 (6 o'clock) clockwise to 120 (4 o'clock) + CHECK(approx(knobSweepDeg(arc), 300.0)); + CHECK(approx(knobValueAngleDeg(arc, 0.0), kKnobArcStartDeg)); // min at 6 o'clock + CHECK(approx(knobValueAngleDeg(arc, 1.0), kKnobArcEndDeg)); // max at 4 o'clock + // Midpoint: halfway around the clockwise sweep -> 180 + 150 = 330. + CHECK(approx(knobValueAngleDeg(arc, 0.5), 330.0)); + // Out-of-range values clamp to the arc ends. + CHECK(approx(knobValueAngleDeg(arc, -0.5), kKnobArcStartDeg)); + CHECK(approx(knobValueAngleDeg(arc, 1.5), kKnobArcEndDeg)); +} + +static void testKnobArcIsParameterized() { + // A custom non-wrapping arc: 3 o'clock down to 9 o'clock through 6. + const KnobArc arc{90.0, 270.0}; + CHECK(approx(knobSweepDeg(arc), 180.0)); + CHECK(approx(knobValueAngleDeg(arc, 0.0), 90.0)); + CHECK(approx(knobValueAngleDeg(arc, 0.5), 180.0)); + CHECK(approx(knobValueAngleDeg(arc, 1.0), 270.0)); + // Equal start/end means a full-circle sweep (end at-or-behind start wraps +360). + CHECK(approx(knobSweepDeg(KnobArc{0.0, 0.0}), 360.0)); +} + +static void testKnobNeedlePointOnCircle() { + const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); // center (122,22), r 22 + // Default arc, value 0 -> 6 o'clock -> straight DOWN from the center (screen +y). + const KnobPoint p6 = knobNeedlePoint(g, KnobArc{}, 0.0); + CHECK(near(p6.x, 122.0, 1e-6) && near(p6.y, 44.0, 1e-6)); + // A 12 o'clock needle points straight UP; 3 o'clock points RIGHT. + const KnobPoint p12 = knobNeedlePoint(g, KnobArc{0.0, 180.0}, 0.0); + CHECK(near(p12.x, 122.0, 1e-6) && near(p12.y, 0.0, 1e-6)); + const KnobPoint p3 = knobNeedlePoint(g, KnobArc{0.0, 180.0}, 0.5); + CHECK(near(p3.x, 144.0, 1e-6) && near(p3.y, 22.0, 1e-6)); + // Every needle endpoint sits ON the circle. + for (double v : {0.0, 0.25, 0.5, 0.75, 1.0}) { + const KnobPoint p = knobNeedlePoint(g, KnobArc{}, v); + const double dx = p.x - g.centerX, dy = p.y - g.centerY; + CHECK(near(dx * dx + dy * dy, g.radius * g.radius, 1e-6)); + } +} + +static void testKnobDragUpIncreases() { + // Up (negative dy) increases, down decreases, scaled by the drag range. + CHECK(approx(knobDragValue(0.5, -32, 128), 0.75)); + CHECK(approx(knobDragValue(0.5, +32, 128), 0.25)); + // A full-range upward drag from 0 lands exactly at 1. + CHECK(approx(knobDragValue(0.0, -128, 128), 1.0)); + // Default sensitivity applies when the range is omitted. + CHECK(approx(knobDragValue(0.0, -kKnobDragRangePixels), 1.0)); +} + +static void testKnobDragClamps() { + CHECK(approx(knobDragValue(0.9, -64, 128), 1.0)); // over-drag up clamps at 1 + CHECK(approx(knobDragValue(0.1, +64, 128), 0.0)); // over-drag down clamps at 0 + // The start value itself is clamped before the delta applies. + CHECK(approx(knobDragValue(1.5, 0, 128), 1.0)); + CHECK(approx(knobDragValue(-0.5, 0, 128), 0.0)); + // A degenerate drag range yields the clamped start value. + CHECK(approx(knobDragValue(0.7, -50, 0), 0.7)); +} + // --- controlAtPoint routing --------------------------------------------------- static void testControlAtPointRoutes() { @@ -160,6 +253,7 @@ static void testControlAtPointRoutes() { {10, ControlKind::Toggle}, {20, ControlKind::Slider}, }; + ctl.push_back({30, ControlKind::Knob}); const std::vector rows = layoutControls(panel, ctl); // A point in the toggle's control area routes to the toggle id. const Rect tctl = rows[0].control; @@ -168,6 +262,11 @@ static void testControlAtPointRoutes() { const Rect strack = sliderTrackRect(rows[1].control); CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2, (strack.top + strack.bottom) / 2) == 20); + // A point at the knob's center routes to the knob id; the control-rect corner (outside + // the circle) is a miss. + const KnobGeometry kg = computeKnob(rows[2].control); + CHECK(controlAtPoint(rows, static_cast(kg.centerX), static_cast(kg.centerY)) == 30); + CHECK(controlAtPoint(rows, rows[2].control.left + 1, rows[2].control.top + 1) == -1); } static void testControlAtPointMisses() { @@ -196,6 +295,13 @@ int main() { testValueAtPointEndpointsSaturate(); testValueAtPointIsHandleInverse(); testValueAtPointDegenerateTrack(); + testKnobGeometryInscribesCell(); + testKnobHitTestCircle(); + testKnobDefaultArcIsSixToFourOClock(); + testKnobArcIsParameterized(); + testKnobNeedlePointOnCircle(); + testKnobDragUpIncreases(); + testKnobDragClamps(); testControlAtPointRoutes(); testControlAtPointMisses();