Demos/BeatPad/QuantizeMath.cs
using System;

namespace Sandbox.BeatPad;

// Pure timing/quantize math for the step sequencer. 16 steps per bar (1/16 grid).
// No state, no engine types -- compiles headless for unit tests.
public static class QuantizeMath
{
    public const int StepsPerBar = 16;
    public const float MinBpm = 60f;
    public const float MaxBpm = 180f;
    public const float MaxSwing = 0.6f; // fraction of a step that odd steps may be delayed

    public static float SafeBpm(float bpm) => bpm < MinBpm ? MinBpm : (bpm > MaxBpm ? MaxBpm : bpm);

    // Sixteenth-note duration: quarter (60/bpm) divided by 4.
    public static float StepDuration(float bpm) => 60f / SafeBpm(bpm) / 4f;

    public static float BarDuration(float bpm) => StepDuration(bpm) * StepsPerBar;

    // Snap a time-within-the-bar to the nearest of the 16 steps, wrapping (so a hit a
    // hair before the bar end belongs to step 0 of the next bar).
    public static int NearestStep(float timeInBar, float bpm)
    {
        float step = timeInBar / StepDuration(bpm);
        int rounded = (int)MathF.Round(step);
        int wrapped = rounded % StepsPerBar;
        return wrapped < 0 ? wrapped + StepsPerBar : wrapped;
    }

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

    public static float ValueToBpm(float knob01) => MinBpm + Clamp01(knob01) * (MaxBpm - MinBpm);

    public static float BpmToValue(float bpm) => Clamp01((SafeBpm(bpm) - MinBpm) / (MaxBpm - MinBpm));

    // Knob centered (0.5) = straight; up adds swing to MaxSwing; below center clamps to 0.
    public static float ValueToSwing(float knob01)
    {
        float up = (Clamp01(knob01) - 0.5f) * 2f; // -1..+1
        return up <= 0f ? 0f : up * MaxSwing;
    }

    // Playback delay for a step: odd (off-beat) steps are pushed later by swingAmount of a step.
    public static float SwingOffset(int stepIndex, float swingAmount, float bpm)
        => (stepIndex % 2 == 1) ? swingAmount * StepDuration(bpm) : 0f;
}