Code/Demos/BeatPad/KnobMath.cs
using System;

namespace Sandbox.BeatPad;

// Pure mapping math for the knob row. Angle convention: -135deg (min, 7 o'clock) ..
// 0deg (mid) .. +135deg (max, 5 o'clock), a 270deg sweep.
public static class KnobMath
{
    public const float MinAngle = -135f;
    public const float MaxAngle = 135f;
    public const float Sweep = MaxAngle - MinAngle; // 270

    public static float Clamp01(float v) => v < 0f ? 0f : (v > 1f ? 1f : v);

    public static float ValueToAngle(float value) => MinAngle + Clamp01(value) * Sweep;

    public static float AngleToValue(float angle) => Clamp01((angle - MinAngle) / Sweep);

    // Vertical drag: down (+y) lowers value, up (-y) raises it. pixelsPerDegree ~ 2.
    public static float ApplyDrag(float current, float deltaPixelsY, float pixelsPerDegree)
    {
        float degrees = -deltaPixelsY / pixelsPerDegree;
        return Clamp01(current + degrees / Sweep);
    }

    // PCH knob: value 0..1 -> pitch multiplier, +/-1 octave (0.5..2.0), 1.0 at mid.
    public static float ValueToPitch(float value) => MathF.Pow(2f, (Clamp01(value) - 0.5f) * 2f);

    // Most-closed one-pole Cutoff at full filter deflection (audible, never silent).
    public const float FilterFloor = 0.05f;

    // Max wet reverb send so full-right REV still keeps the dry transient.
    public const float MaxReverbMix = 0.6f;

    // amount 0 (centered) .. 1 (full) -> processor Cutoff (1 = transparent .. FilterFloor),
    // curved so the onset near center is gentle and the extreme is dramatic.
    static float Deflect(float amount) => 1f - (1f - FilterFloor) * (amount * amount);

    // FLT is bipolar: center 0.5 leaves both filters open. Left of center drives the
    // low-pass down (darker); right of center leaves the low-pass open.
    public static float ValueToLowPassCutoff(float value)
    {
        float v = Clamp01(value);
        return v >= 0.5f ? 1f : Deflect((0.5f - v) / 0.5f);
    }

    // Right of center drives the high-pass down (thinner); left of center leaves it open.
    public static float ValueToHighPassCutoff(float value)
    {
        float v = Clamp01(value);
        return v <= 0.5f ? 1f : Deflect((v - 0.5f) / 0.5f);
    }

    // REV knob -> DspProcessor.Mix (wet/dry); linear, capped at MaxReverbMix.
    public static float ValueToReverbMix(float value) => Clamp01(value) * MaxReverbMix;
}